@ordewell/core 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/{ITerminalRunner-Bd-vAJnw.d.ts → ITerminalRunner-BV9Rd2o9.d.ts} +3 -1
  2. package/dist/{ITerminalRunner-ByeoLF57.d.mts → ITerminalRunner-C77ZNZS9.d.mts} +3 -1
  3. package/dist/{ModeResolver-DVJ7HV3k.d.mts → ModeResolver-D-SUFRNF.d.mts} +1 -1
  4. package/dist/{ModeResolver-Dkig8ghQ.d.ts → ModeResolver-D3XO0fT9.d.ts} +1 -1
  5. package/dist/{Task-BxQkPlXO.d.mts → Task-Vl5Zq_D-.d.mts} +168 -7
  6. package/dist/{Task-BxQkPlXO.d.ts → Task-Vl5Zq_D-.d.ts} +168 -7
  7. package/dist/{chunk-JVMDEHRQ.mjs → chunk-HD2FWPRV.mjs} +72 -37
  8. package/dist/chunk-HD2FWPRV.mjs.map +1 -0
  9. package/dist/{chunk-GWPIYDQW.mjs → chunk-KLN7ELXO.mjs} +897 -18
  10. package/dist/chunk-KLN7ELXO.mjs.map +1 -0
  11. package/dist/{chunk-XWOUIA6A.mjs → chunk-UUBGVCGJ.mjs} +2 -2
  12. package/dist/index.d.mts +320 -60
  13. package/dist/index.d.ts +320 -60
  14. package/dist/index.js +2497 -672
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +1605 -659
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/order-labels.d.mts +1 -1
  19. package/dist/order-labels.d.ts +1 -1
  20. package/dist/{parsing-CF_grC29.d.ts → parsing-BTP4bwkk.d.ts} +11 -3
  21. package/dist/{parsing-DRp4dPC0.d.mts → parsing-CDtRSxBY.d.mts} +11 -3
  22. package/dist/parsing.d.mts +3 -3
  23. package/dist/parsing.d.ts +3 -3
  24. package/dist/parsing.js.map +1 -1
  25. package/dist/parsing.mjs +2 -2
  26. package/dist/plan-utils-BFaPo-IT.d.ts +708 -0
  27. package/dist/plan-utils-pE4TBwxl.d.mts +708 -0
  28. package/dist/plan-utils.d.mts +3 -3
  29. package/dist/plan-utils.d.ts +3 -3
  30. package/dist/plan-utils.js +668 -7
  31. package/dist/plan-utils.js.map +1 -1
  32. package/dist/plan-utils.mjs +38 -4
  33. package/dist/testing.d.mts +5 -3
  34. package/dist/testing.d.ts +5 -3
  35. package/dist/testing.js +3 -0
  36. package/dist/testing.js.map +1 -1
  37. package/dist/testing.mjs +3 -0
  38. package/dist/testing.mjs.map +1 -1
  39. package/package.json +1 -1
  40. package/dist/chunk-GWPIYDQW.mjs.map +0 -1
  41. package/dist/chunk-JVMDEHRQ.mjs.map +0 -1
  42. package/dist/plan-utils-CkNbqAmS.d.ts +0 -329
  43. package/dist/plan-utils-CtB3_Ovf.d.mts +0 -329
  44. /package/dist/{chunk-XWOUIA6A.mjs.map → chunk-UUBGVCGJ.mjs.map} +0 -0
package/dist/index.mjs CHANGED
@@ -13,9 +13,15 @@ import {
13
13
  resolveTaskMode,
14
14
  runnerModesFrom,
15
15
  validatePlanModification
16
- } from "./chunk-XWOUIA6A.mjs";
16
+ } from "./chunk-UUBGVCGJ.mjs";
17
17
  import {
18
18
  CHECKPOINT_TRUNCATE_LENGTH,
19
+ EMPTY_CONVERSATION,
20
+ EMPTY_HOLD,
21
+ NO_TURN,
22
+ addPlannerUsage,
23
+ addUsage,
24
+ aheadOfDraft,
19
25
  applyTaskOps,
20
26
  canMergeTasks,
21
27
  canSetDependencies,
@@ -25,19 +31,41 @@ import {
25
31
  coerceAssignments,
26
32
  dependencyCandidates,
27
33
  dependentsOf,
34
+ drainNext,
28
35
  effectiveAllowlist,
29
36
  executionSummary,
30
37
  filterModelsForPrompt,
38
+ flattenTerminalOutput,
39
+ followTurn,
40
+ fromTranscript,
41
+ hasHiddenDetail,
42
+ holdPrompt,
43
+ isMeasured,
44
+ outputLines,
45
+ outputPreview,
31
46
  parseTaskOpsJson,
47
+ partedPromptUsage,
48
+ plannerContextFill,
49
+ reduceConversation,
50
+ refMatchesTask,
51
+ renderCleanCapture,
52
+ renderTerminalOutput,
32
53
  serializePlan,
33
54
  serializeTask,
34
55
  serializeTaskStatus,
56
+ stopTurn,
35
57
  summarizeToolCall,
58
+ taskOpRefs,
36
59
  taskOpsProtocol,
60
+ taskStartedNotice,
37
61
  textHasTaskOps,
62
+ toolHeadline,
38
63
  truncateCheckpointSummary,
64
+ unsendAll,
65
+ unsendLatest,
66
+ usageLine,
39
67
  validateTaskEdit
40
- } from "./chunk-GWPIYDQW.mjs";
68
+ } from "./chunk-KLN7ELXO.mjs";
41
69
  import {
42
70
  PLAN_ENVELOPE_KEY,
43
71
  PlanParseError,
@@ -53,9 +81,11 @@ import {
53
81
  extractObjectsWithKey,
54
82
  flattenTasks,
55
83
  flattenTasksWithParents,
84
+ keepExecutionState,
56
85
  migrateLegacyPlan,
57
86
  migratePlanState,
58
87
  migrateTask,
88
+ opensWithJsonObject,
59
89
  removeTaskFromPlan,
60
90
  renumberTasks,
61
91
  stripModelNoise,
@@ -63,7 +93,7 @@ import {
63
93
  updateTaskInPlan,
64
94
  validateModifiedPlan,
65
95
  warningsText
66
- } from "./chunk-JVMDEHRQ.mjs";
96
+ } from "./chunk-HD2FWPRV.mjs";
67
97
  import {
68
98
  resolveOrderLabel,
69
99
  taskOrderLabel
@@ -1628,7 +1658,9 @@ var POSIX_DIALECT = {
1628
1658
  escape: "\\",
1629
1659
  escapeInQuotes: true,
1630
1660
  quotes: ["'", '"'],
1631
- expansion: /^\$[A-Za-z_{]/,
1661
+ // Positional and special parameters (`$1`, `$@`, `$$`, …) expand too.
1662
+ expansion: /^\$[A-Za-z_{0-9@*#?$!-]/,
1663
+ dollarQuotes: true,
1632
1664
  strippedExtensions: []
1633
1665
  };
1634
1666
  var CMD_DIALECT = {
@@ -1637,6 +1669,7 @@ var CMD_DIALECT = {
1637
1669
  quotes: ['"'],
1638
1670
  // `%VAR%` and delayed-expansion `!VAR!`.
1639
1671
  expansion: /^[%!][A-Za-z_]/,
1672
+ dollarQuotes: false,
1640
1673
  // Without this, `del.exe` and `C:\bin\del.exe` both missed the refusal list.
1641
1674
  strippedExtensions: [".exe", ".cmd", ".bat", ".com", ".ps1", ".msc"]
1642
1675
  };
@@ -1675,7 +1708,11 @@ function lex(command, nested, dialect) {
1675
1708
  let quote = "";
1676
1709
  let redirectTargetMode = false;
1677
1710
  let redirectOperator = "";
1711
+ let braceOpen = false;
1712
+ let braceList = false;
1678
1713
  const endToken = (hardBoundary = true) => {
1714
+ braceOpen = false;
1715
+ braceList = false;
1679
1716
  if (redirectTargetMode) {
1680
1717
  if (!started && !hardBoundary) return;
1681
1718
  if (!unsafeRedirect && !isDevNullTarget(current)) {
@@ -1727,7 +1764,9 @@ function lex(command, nested, dialect) {
1727
1764
  break;
1728
1765
  }
1729
1766
  nested.push(command.slice(i + 2, close));
1767
+ current += command.slice(i, close + 1);
1730
1768
  started = true;
1769
+ expandable = true;
1731
1770
  i = close + 1;
1732
1771
  continue;
1733
1772
  }
@@ -1738,10 +1777,19 @@ function lex(command, nested, dialect) {
1738
1777
  break;
1739
1778
  }
1740
1779
  nested.push(command.slice(i + 1, close));
1780
+ current += command.slice(i, close + 1);
1741
1781
  started = true;
1782
+ expandable = true;
1742
1783
  i = close + 1;
1743
1784
  continue;
1744
1785
  }
1786
+ if (dialect.dollarQuotes && quote === "" && c === "$" && (command[i + 1] === "'" || command[i + 1] === '"')) {
1787
+ expandable = true;
1788
+ current += c;
1789
+ started = true;
1790
+ i++;
1791
+ continue;
1792
+ }
1745
1793
  if (dialect.expansion.test(command.slice(i, i + 2))) {
1746
1794
  expandable = true;
1747
1795
  current += c;
@@ -1811,7 +1859,7 @@ function lex(command, nested, dialect) {
1811
1859
  if (c === "|") {
1812
1860
  const double = command[i + 1] === "|";
1813
1861
  endSegment(!double);
1814
- i += double ? 2 : 1;
1862
+ i += double || command[i + 1] === "&" ? 2 : 1;
1815
1863
  continue;
1816
1864
  }
1817
1865
  if (c === "&" || c === ";" || c === "\n") {
@@ -1824,6 +1872,9 @@ function lex(command, nested, dialect) {
1824
1872
  i++;
1825
1873
  continue;
1826
1874
  }
1875
+ if (c === "{") braceOpen = true;
1876
+ else if (braceOpen && (c === "," || c === "." && command[i + 1] === ".")) braceList = true;
1877
+ else if (braceOpen && braceList && c === "}") expandable = true;
1827
1878
  current += c;
1828
1879
  started = true;
1829
1880
  i++;
@@ -1839,6 +1890,11 @@ function binaryName(token, dialect) {
1839
1890
  return dialect.strippedExtensions.includes(base.slice(dot).toLowerCase()) ? base.slice(0, dot) : base;
1840
1891
  }
1841
1892
  var ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
1893
+ function isComputedWord(token, dialect) {
1894
+ if (/[$`]/.test(token) || /\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(token)) return true;
1895
+ for (let i = 0; i < token.length; i++) if (dialect.expansion.test(token.slice(i, i + 2))) return true;
1896
+ return false;
1897
+ }
1842
1898
  function toSegment(tokens, piped, dialect) {
1843
1899
  const rest = [...tokens];
1844
1900
  const assignments = [];
@@ -1848,7 +1904,8 @@ function toSegment(tokens, piped, dialect) {
1848
1904
  args: rest.slice(1),
1849
1905
  assignments,
1850
1906
  piped,
1851
- expandable: false
1907
+ expandable: false,
1908
+ ...rest.length > 0 && isComputedWord(rest[0], dialect) ? { computedBinary: rest[0] } : {}
1852
1909
  };
1853
1910
  }
1854
1911
  function joinedShortFlag(list, token) {
@@ -1940,7 +1997,7 @@ function lexAll(command, dialect) {
1940
1997
  }
1941
1998
  function looksLikePath(arg) {
1942
1999
  if (arg.startsWith("--") && arg.includes("=")) return looksLikePath(arg.slice(arg.indexOf("=") + 1));
1943
- return arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("../") || arg === ".." || arg.startsWith("./") || /^[A-Za-z]:/.test(arg) || arg.startsWith("\\") || arg.startsWith("..\\") || arg.startsWith(".\\");
2000
+ return arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("../") || arg === ".." || arg.startsWith("./") || /^[A-Za-z]:/.test(arg) || arg.startsWith("\\") || arg.startsWith("..\\") || arg.startsWith(".\\") || /(^|[\\/])\.\.([\\/]|$)/.test(arg);
1944
2001
  }
1945
2002
  function pathLikeArgs(command, opts = {}) {
1946
2003
  const dialect = dialectFor(opts.dialect);
@@ -1949,9 +2006,18 @@ function pathLikeArgs(command, opts = {}) {
1949
2006
  const v = a.slice(a.indexOf("=") + 1);
1950
2007
  return looksLikePath(v) ? [v] : [];
1951
2008
  }
1952
- return !a.startsWith("-") && looksLikePath(a) ? [a] : [];
2009
+ if (a.startsWith("-")) {
2010
+ const glued = gluedValue(a);
2011
+ return glued ? [glued] : [];
2012
+ }
2013
+ return looksLikePath(a) ? [a] : [];
1953
2014
  }));
1954
2015
  }
2016
+ function gluedValue(arg) {
2017
+ if (arg.startsWith("--")) return void 0;
2018
+ for (let i = 2; i < arg.length; i++) if (looksLikePath(arg.slice(i))) return arg.slice(i);
2019
+ return void 0;
2020
+ }
1955
2021
  function matchFlag(spec, booleans, values, token) {
1956
2022
  if (token.startsWith("--")) {
1957
2023
  const eq = token.indexOf("=");
@@ -2028,6 +2094,9 @@ function isAuto(seg) {
2028
2094
  return sub !== void 0 && GIT_READONLY_SUBCOMMANDS.includes(sub);
2029
2095
  }
2030
2096
  function refusalFor(seg) {
2097
+ if (seg.computedBinary !== void 0) {
2098
+ return `"${seg.computedBinary}" is a command name the shell computes as it runs, so this classifier cannot tell what would run. Name the program directly.`;
2099
+ }
2031
2100
  if (REFUSED_COMMANDS.includes(seg.binary)) {
2032
2101
  return `"${seg.binary}" modifies state. You are a read-only planner \u2014 describe the change as a task instead, and the runner executing the plan will make it.`;
2033
2102
  }
@@ -4482,13 +4551,52 @@ var GitWorktreeIsolation = class {
4482
4551
  return this.admin(run.workspaceRoot, async () => {
4483
4552
  await this.removeIntegrationWorktrees(run);
4484
4553
  await this.settleLanding(run);
4554
+ const kept = [];
4485
4555
  for (const record of Object.values(run.tasks)) {
4486
- if (record.status === "active") await this.removeTask(run, record, { dropRecord: true });
4487
- else if (record.status === "merged") await this.removeTask(run, record, { dropRecord: false });
4556
+ if (record.status === "active") {
4557
+ if (await this.holdsUnlandedWork(run, record)) {
4558
+ record.status = "kept";
4559
+ kept.push({ taskId: record.taskId, order: record.order, title: record.title });
4560
+ } else {
4561
+ await this.removeTask(run, record, { dropRecord: true });
4562
+ }
4563
+ } else if (record.status === "merged") await this.removeTask(run, record, { dropRecord: false });
4488
4564
  else if (record.status === "repairing") settleStatus(record, "conflict");
4489
4565
  }
4490
4566
  await this.removeUnowned(run);
4491
4567
  for (const repo of run.repos) await this.tryGit(repo.root, ["worktree", "prune"]);
4568
+ return { kept };
4569
+ });
4570
+ }
4571
+ /**
4572
+ * Whether an attempt record still holds work nobody else has: commits its
4573
+ * branch carries that the integration branch does not, or edits in its
4574
+ * worktree. Only an attempt that left neither behind is safe to prune.
4575
+ */
4576
+ async holdsUnlandedWork(run, record) {
4577
+ for (const repo of run.repos) {
4578
+ const entry = record.repos[repo.path];
4579
+ if (!entry) continue;
4580
+ if (await this.brings(repo, record.branch)) return true;
4581
+ if (await this.worktreeHasChanges(entry, await this.prefixOf(repo))) return true;
4582
+ }
4583
+ return false;
4584
+ }
4585
+ /**
4586
+ * Changes in a worktree, ignoring the artifacts Ordewell bootstrapped there
4587
+ * so a prepared-but-untouched attempt still reads as empty. `prefix` is where
4588
+ * the workspace sits in the repo, which is what the recorded link names are
4589
+ * relative to. A worktree git cannot read is treated as holding work: it is
4590
+ * out of sync, and deleting it is the one action that cannot be undone.
4591
+ */
4592
+ async worktreeHasChanges(entry, prefix) {
4593
+ if (!fs4.existsSync(entry.worktree)) return false;
4594
+ const status = await this.tryGit(entry.worktree, ["status", "--porcelain", "-z"]);
4595
+ if (!status.ok) return true;
4596
+ const linked = new Set(entry.linked);
4597
+ return statusPaths(status.stdout).some((p) => {
4598
+ const rel = (prefix && p.startsWith(prefix) ? p.slice(prefix.length) : p).replace(/\/+$/, "");
4599
+ return !linked.has(rel) && !linked.has(rel.split("/")[0]);
4492
4600
  });
4493
4601
  }
4494
4602
  /**
@@ -4662,10 +4770,14 @@ var GitWorktreeIsolation = class {
4662
4770
  for (const repo of run.repos) {
4663
4771
  const entry = record.repos[repo.path];
4664
4772
  if (!entry) continue;
4773
+ if (!fs4.existsSync(entry.worktree)) {
4774
+ return this.stopLanding(record, "failed", repo, void 0, `its worktree is gone (${entry.worktree})`);
4775
+ }
4665
4776
  try {
4666
4777
  await this.commitWorktree(repo, record, entry);
4667
- } catch {
4668
- return this.stopLanding(record, "failed", repo);
4778
+ } catch (err) {
4779
+ const detail = firstLine(err instanceof Error ? err.message : String(err));
4780
+ return this.stopLanding(record, "failed", repo, void 0, `git could not commit its work (${detail})`);
4669
4781
  }
4670
4782
  if (await this.brings(repo, record.branch)) {
4671
4783
  entry.changed = true;
@@ -4694,11 +4806,14 @@ var GitWorktreeIsolation = class {
4694
4806
  settleStatus(record, "merged");
4695
4807
  delete record.conflictRepo;
4696
4808
  delete record.conflictFiles;
4809
+ delete record.landingError;
4697
4810
  await this.admin(run.workspaceRoot, () => this.removeTask(run, record, { dropRecord: false })).catch(() => void 0);
4698
4811
  return "merged";
4699
4812
  }
4700
- stopLanding(record, outcome, repo, files) {
4813
+ stopLanding(record, outcome, repo, files, error) {
4701
4814
  settleStatus(record, outcome);
4815
+ if (error) record.landingError = error;
4816
+ else delete record.landingError;
4702
4817
  if (repo) record.conflictRepo = repo.path;
4703
4818
  else delete record.conflictRepo;
4704
4819
  if (files && files.length > 0) record.conflictFiles = files;
@@ -5103,12 +5218,12 @@ function parseTaskQueryObject(json, text) {
5103
5218
  }
5104
5219
  }
5105
5220
  const catalog = rawCatalog === true;
5106
- let outputLines;
5221
+ let outputLines2;
5107
5222
  if (rawLines !== void 0) {
5108
5223
  if (typeof rawLines !== "number" || !Number.isFinite(rawLines) || rawLines < 1) {
5109
5224
  throw new PlanParseError('"outputLines" in a taskQuery must be a positive number of lines', text);
5110
5225
  }
5111
- outputLines = rawLines;
5226
+ outputLines2 = rawLines;
5112
5227
  }
5113
5228
  let outputSince;
5114
5229
  if (rawSince !== void 0) {
@@ -5120,7 +5235,7 @@ function parseTaskQueryObject(json, text) {
5120
5235
  if (tasks.length === 0 && !catalog) {
5121
5236
  throw new PlanParseError('A taskQuery must name at least one task or set "catalog": true', text);
5122
5237
  }
5123
- return { tasks, fields, catalog, outputLines, outputSince };
5238
+ return { tasks, fields, catalog, outputLines: outputLines2, outputSince };
5124
5239
  }
5125
5240
  function taskQuerySignature(query) {
5126
5241
  return JSON.stringify([query.tasks, query.fields ?? null, query.catalog, query.outputLines ?? null, query.outputSince ?? null]);
@@ -5875,7 +5990,8 @@ async function repairLoop(opts) {
5875
5990
  if (repairs >= opts.maxRepairs) {
5876
5991
  return opts.onExhausted({ reply, errors: verdict.retry.errors, cause: verdict.retry.cause });
5877
5992
  }
5878
- reply = await opts.resend(verdict.retry.corrective);
5993
+ const { corrective } = verdict.retry;
5994
+ reply = await opts.resend(typeof corrective === "function" ? corrective() : corrective);
5879
5995
  }
5880
5996
  }
5881
5997
  var JSON_REPAIR_INSTRUCTION = "Your previous response could not be parsed as JSON. Re-send ONLY the JSON object \u2014 no prose before or after, no explanation, no markdown code fences.";
@@ -5958,6 +6074,81 @@ async function generatePlanWithRepair(generate, runners, maxAttempts = 2, runner
5958
6074
  });
5959
6075
  }
5960
6076
 
6077
+ // src/services/settleReply.ts
6078
+ var MAX_JSON_REPAIRS = 2;
6079
+ async function settleReply(opts) {
6080
+ const researchLog = [];
6081
+ const message = (text) => ({ kind: "message", text, researchLog });
6082
+ const retract = () => opts.onProgress({ type: "text_retracted" });
6083
+ const asProse = (attempt) => {
6084
+ if (opts.replyJoinsSegments) retract();
6085
+ return message(said(attempt));
6086
+ };
6087
+ let nudged = false;
6088
+ const send = async (text) => {
6089
+ const attempt = await opts.send(text);
6090
+ researchLog.push(...attempt.researchLog);
6091
+ if (attempt.text.trim() || attempt.aborted || attempt.failure !== void 0 || nudged) return attempt;
6092
+ nudged = true;
6093
+ retract();
6094
+ return send(emptyReplyNudge(deniedStep(attempt)));
6095
+ };
6096
+ return repairLoop({
6097
+ first: () => send(opts.message),
6098
+ resend: (corrective) => {
6099
+ retract();
6100
+ return send(corrective);
6101
+ },
6102
+ interpret: (attempt) => {
6103
+ if (attempt.aborted) {
6104
+ const turn = asProse(attempt);
6105
+ opts.onProgress({ type: "interrupted" });
6106
+ return { done: turn };
6107
+ }
6108
+ if (attempt.failure !== void 0) return { done: message(attempt.failure) };
6109
+ if (!attempt.text.trim()) return { done: message(emptyReplyReport(deniedStep(attempt))) };
6110
+ const reply = classifyPlannerReply(attempt.text, opts.classify);
6111
+ switch (reply.kind) {
6112
+ case "plan":
6113
+ return { done: { kind: "plan", tasks: reply.tasks, text: said(attempt), researchLog } };
6114
+ case "task_ops":
6115
+ return { done: { kind: "task_ops", ops: reply.ops, text: said(attempt), researchLog } };
6116
+ // A read is answered by the conversation, which owns the plan and the
6117
+ // catalog, so it leaves here the way a plan or an edit does.
6118
+ case "task_query":
6119
+ return { done: { kind: "task_query", query: reply.query, text: said(attempt), researchLog } };
6120
+ case "prose":
6121
+ return { done: asProse(attempt) };
6122
+ }
6123
+ if (opts.signal?.aborted) return { done: asProse(attempt) };
6124
+ const errors = [reply.error.message];
6125
+ switch (reply.kind) {
6126
+ case "broken_task_ops":
6127
+ return { retry: { errors, corrective: reEmitTaskOpsPrompt(reply.error.message), cause: reply.error } };
6128
+ case "broken_task_query":
6129
+ return { retry: { errors, corrective: reEmitTaskQueryPrompt(reply.error.message), cause: reply.error } };
6130
+ case "broken_plan": {
6131
+ const corrective = reply.error.truncated || attempt.cutOff ? () => truncatedPlanReEmitPrompt((opts.compactHistory?.() ?? 0) > 0) : reEmitPlanPrompt(reply.error.message);
6132
+ return { retry: { errors, corrective, cause: reply.error } };
6133
+ }
6134
+ }
6135
+ },
6136
+ maxRepairs: MAX_JSON_REPAIRS,
6137
+ onExhausted: ({ reply }) => asProse(reply)
6138
+ });
6139
+ }
6140
+ var said = (attempt) => attempt.fullText ?? attempt.text;
6141
+ function deniedStep(attempt) {
6142
+ return attempt.researchLog.find((e) => !("type" in e) && e.outcome === "denied");
6143
+ }
6144
+ var stepName = (step) => step.toolLabel ?? step.tool;
6145
+ function emptyReplyNudge(denied) {
6146
+ return denied ? `Your last reply was empty after "${stepName(denied)}" was denied: ${denied.result} Do not retry it. Answer the user now with what you already know, or ask your next question.` : "Your last reply was empty. Respond to the user now: answer their last message directly, ask your next question, or emit the plan JSON.";
6147
+ }
6148
+ function emptyReplyReport(denied) {
6149
+ return denied ? `The planner stopped without replying after "${stepName(denied)}" was denied: ${denied.result}` : "The planner returned an empty reply twice. Please rephrase or try again.";
6150
+ }
6151
+
5961
6152
  // src/services/researchTools.ts
5962
6153
  import { SchemaType } from "@google/generative-ai";
5963
6154
  var SPAWN_RESEARCH_AGENT = "spawn_research_agent";
@@ -6305,7 +6496,12 @@ function nonPromptingFs(fs15) {
6305
6496
  async function runLoop(prompt, deps) {
6306
6497
  if (deps.signal?.aborted) return "[research agent aborted before starting]";
6307
6498
  const fs15 = nonPromptingFs(deps.fs);
6308
- const chat = deps.createChat((delta) => deps.onProgress?.({ type: "thinking", text: delta }));
6499
+ const chat = deps.createChat(
6500
+ (delta) => deps.onProgress?.({ type: "thinking", text: delta, subagentId: deps.subagentId }),
6501
+ // The record says who made the call: unstamped, the ledger would read the
6502
+ // subagent's prompt as the planner's own and measure its context by it.
6503
+ (record) => deps.onProgress?.({ type: "usage", record: deps.subagentId ? { ...record, subagentId: deps.subagentId } : record })
6504
+ );
6309
6505
  let turn = await chat.sendMessage(prompt, deps.signal);
6310
6506
  for (let step = 0; turn.hasToolCalls && step < SUBAGENT_LIMITS.maxSteps; step++) {
6311
6507
  if (deps.signal?.aborted) return `[research agent aborted] Partial findings:
@@ -6320,21 +6516,25 @@ ${turn.text}`;
6320
6516
  const args = { ...tc.args };
6321
6517
  if (tc.name === "read_file" && !("maxBytes" in args) && !("limit" in args)) args.limit = 2e3;
6322
6518
  const toolArgs = JSON.stringify(tc.args);
6323
- deps.onProgress?.({ type: "tool_call", tool: tc.name, toolArgs, toolCallId: tc.id });
6519
+ deps.onProgress?.({ type: "tool_call", tool: tc.name, toolArgs, toolCallId: tc.id, subagentId: deps.subagentId });
6324
6520
  const res = await executeTool(tc.name, args, fs15, void 0, deps.signal);
6325
6521
  const output = res.output.length > SUBAGENT_LIMITS.toolOutputMaxChars ? res.output.slice(0, SUBAGENT_LIMITS.toolOutputMaxChars) + `
6326
6522
  [... truncated to ${SUBAGENT_LIMITS.toolOutputMaxChars} chars, total ${res.output.length}]` : res.output;
6327
6523
  const stepEntry = {
6328
- id: `subrs-${Date.now()}-${step}`,
6524
+ // The subagentId in the id: two concurrent subagents run the same
6525
+ // step numbers in the same millisecond, and an id collision would
6526
+ // make their persisted steps indistinguishable on reload.
6527
+ id: `subrs-${deps.subagentId ?? "local"}-${Date.now()}-${step}`,
6329
6528
  tool: tc.name,
6330
6529
  args: toolArgs,
6331
6530
  result: output,
6332
6531
  success: res.success,
6333
6532
  outcome: classifyOutcome(res.success, res.output),
6533
+ subagentId: deps.subagentId,
6334
6534
  toolCallId: tc.id,
6335
6535
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
6336
6536
  };
6337
- deps.onProgress?.({ type: "tool_result", toolResult: output, step: stepEntry, toolCallId: tc.id });
6537
+ deps.onProgress?.({ type: "tool_result", toolResult: output, step: stepEntry, toolCallId: tc.id, subagentId: deps.subagentId });
6338
6538
  results.push({ name: tc.name, output, truncated: res.truncated || output.length < res.output.length, totalChars: res.output.length, id: tc.id });
6339
6539
  }
6340
6540
  turn = await chat.sendToolResults(results, deps.signal);
@@ -6351,11 +6551,17 @@ ${turn.text}`;
6351
6551
  [... digest truncated to ${SUBAGENT_LIMITS.digestMaxChars} chars, total ${digest.length}]` : digest;
6352
6552
  }
6353
6553
  async function runResearchAgent(prompt, deps) {
6554
+ deps.onProgress?.({ type: "subagent_started", subagentId: deps.subagentId, brief: prompt, model: deps.model });
6354
6555
  try {
6355
- return { success: true, output: await runLoop(prompt, deps), truncated: false };
6556
+ const digest = await runLoop(prompt, deps);
6557
+ const stopped = deps.signal?.aborted ?? false;
6558
+ deps.onProgress?.({ type: "subagent_finished", subagentId: deps.subagentId, outcome: stopped ? "stopped" : "done", digest, usage: deps.usage?.() });
6559
+ return { success: !stopped, output: digest, truncated: false };
6356
6560
  } catch (err) {
6357
6561
  const reason = err instanceof Error ? err.message : String(err);
6358
- return { success: false, output: `[research agent failed: ${reason}] Continue researching this area yourself with your own tools.`, truncated: false };
6562
+ const output = `[research agent failed: ${reason}] Continue researching this area yourself with your own tools.`;
6563
+ deps.onProgress?.({ type: "subagent_finished", subagentId: deps.subagentId, outcome: "failed", digest: output, usage: deps.usage?.() });
6564
+ return { success: false, output, truncated: false };
6359
6565
  }
6360
6566
  }
6361
6567
  async function mapWithConcurrency(items, limit, fn) {
@@ -6369,6 +6575,14 @@ async function mapWithConcurrency(items, limit, fn) {
6369
6575
  return results;
6370
6576
  }
6371
6577
 
6578
+ // src/utils/abortScope.ts
6579
+ function abortScope(callerSignal) {
6580
+ const scope = new AbortController();
6581
+ if (callerSignal?.aborted) scope.abort();
6582
+ else callerSignal?.addEventListener("abort", () => scope.abort(), { once: true });
6583
+ return scope;
6584
+ }
6585
+
6372
6586
  // src/services/BaseAiService.ts
6373
6587
  var PARALLEL_SAFE_TOOLS = /* @__PURE__ */ new Set(["read_file", "read_files", "glob", "grep", "find_symbol", "list_dir"]);
6374
6588
  var TOOL_ROUND_CONCURRENCY = 8;
@@ -6386,13 +6600,7 @@ var BaseAiService = class _BaseAiService {
6386
6600
  return this.conversation?.ctx.chat.compactHistory?.() ?? 0;
6387
6601
  }
6388
6602
  startAbortScope(callerSignal) {
6389
- this.activeAbort = new AbortController();
6390
- if (!callerSignal) return this.activeAbort.signal;
6391
- if (callerSignal.aborted) {
6392
- this.activeAbort.abort();
6393
- return this.activeAbort.signal;
6394
- }
6395
- callerSignal.addEventListener("abort", () => this.activeAbort?.abort(), { once: true });
6603
+ this.activeAbort = abortScope(callerSignal);
6396
6604
  return this.activeAbort.signal;
6397
6605
  }
6398
6606
  stopAbortScope() {
@@ -6426,9 +6634,10 @@ var BaseAiService = class _BaseAiService {
6426
6634
  * Build a fresh chat for one research subagent (own history, subagent system
6427
6635
  * prompt, cheap model). Null means the provider does not support subagents —
6428
6636
  * the spawn tool then degrades to a steering message. `onReasoning` streams
6429
- * live reasoning deltas on models that expose them, same as the top-level loop.
6637
+ * live reasoning deltas on models that expose them, same as the top-level loop;
6638
+ * `onUsage` takes each call's usage, as the planner's own chat reports it.
6430
6639
  */
6431
- createSubagentChat(_onReasoning) {
6640
+ createSubagentChat(_onReasoning, _onUsage) {
6432
6641
  return null;
6433
6642
  }
6434
6643
  /**
@@ -6442,15 +6651,22 @@ var BaseAiService = class _BaseAiService {
6442
6651
  if (!prompt) {
6443
6652
  return { success: false, output: 'spawn_research_agent requires a non-empty "prompt" string: a self-contained task for the agent, including what its digest must report back.', truncated: false };
6444
6653
  }
6654
+ let usage;
6445
6655
  return runResearchAgent(prompt, {
6446
- createChat: (onReasoning) => {
6447
- const chat = this.createSubagentChat(onReasoning);
6656
+ createChat: (onReasoning, onUsage) => {
6657
+ const chat = this.createSubagentChat(onReasoning, onUsage);
6448
6658
  if (!chat) throw new Error("subagent chats are not available for this provider");
6449
6659
  return chat;
6450
6660
  },
6451
6661
  fs: fs15,
6452
6662
  signal,
6453
- onProgress: (progress) => onProgress({ ...progress, subagentId })
6663
+ subagentId,
6664
+ model: this.config.researchSubagentModel,
6665
+ usage: () => usage,
6666
+ onProgress: (progress) => {
6667
+ if (progress.type === "usage" && progress.record) usage = addUsage(usage ?? {}, progress.record);
6668
+ onProgress(progress);
6669
+ }
6454
6670
  });
6455
6671
  }
6456
6672
  /** Collect project context for the planning phase. Shared with the harness backend. */
@@ -6539,138 +6755,80 @@ ${llmOutput}
6539
6755
  }
6540
6756
  /**
6541
6757
  * Run one planner conversation turn (ADR-0002): send the message, satisfy
6542
- * tool calls until the model answers in prose or JSON, then classify the
6543
- * result. The model decides transitions — there are no sentinels, no
6544
- * question tags, and no correction nags. A turn whose final text parses as
6545
- * a `{tasks:[...]}` object commits the plan; anything else is a message to
6546
- * the user.
6758
+ * tool calls until the model answers in prose or JSON, and settle the reply
6759
+ * through {@link settleReply}, which owns classification and every
6760
+ * corrective retry. The model decides transitions — there are no sentinels
6761
+ * and no question tags. A turn whose final text parses as a `{tasks:[...]}`
6762
+ * object commits the plan; anything else is a message to the user.
6547
6763
  */
6548
6764
  async runConversationTurn(ctx, message, onProgress, signal) {
6765
+ const chat = withProactiveCompaction(ctx.chat);
6766
+ return settleReply({
6767
+ message,
6768
+ send: (text) => this.runToolRounds(chat, ctx, text, onProgress, signal),
6769
+ classify: { runners: ctx.runners, runnerModes: ctx.runnerModes, autonomousDefault: ctx.autonomousDefault },
6770
+ onProgress,
6771
+ signal,
6772
+ compactHistory: chat.compactHistory,
6773
+ replyJoinsSegments: false
6774
+ });
6775
+ }
6776
+ /** One model call of a conversation turn: the message, then tool rounds until the model replies without tools. */
6777
+ async runToolRounds(chat, ctx, message, onProgress, signal) {
6549
6778
  const researchLog = [];
6550
6779
  const MAX_STEPS = this.config.researchMaxSteps;
6551
- const chat = withProactiveCompaction(ctx.chat);
6552
- let pending = message;
6553
- let emptyNudgeSent = false;
6554
- let jsonRepairAttempts = 0;
6555
- const MAX_JSON_REPAIRS2 = 2;
6556
- for (; ; ) {
6557
- let turn = await chat.sendMessage(pending, signal);
6558
- let wrapUpRounds = 0;
6559
- for (let step = 0; turn.hasToolCalls; step++) {
6560
- if (signal?.aborted) {
6561
- onProgress({ type: "interrupted" });
6562
- return { kind: "message", text: turn.text, researchLog };
6563
- }
6564
- if (step >= MAX_STEPS) {
6565
- if (wrapUpRounds >= 2) break;
6566
- wrapUpRounds++;
6567
- const notice = `Research tool budget for this turn is exhausted (${MAX_STEPS} rounds) \u2014 this call was NOT executed and no further tool calls will be. Reply to the user now using what you already learned: summarize your findings and ask how to proceed, ask your next question, or emit the plan JSON.`;
6568
- for (const tc of turn.toolCalls) {
6569
- const step2 = {
6570
- id: `rs-${Date.now()}-budget-${researchLog.length}`,
6571
- tool: tc.name,
6572
- args: JSON.stringify(tc.args),
6573
- result: notice,
6574
- success: false,
6575
- outcome: "not_executed",
6576
- toolCallId: tc.id,
6577
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
6578
- };
6579
- researchLog.push(step2);
6580
- onProgress({ type: "tool_call", tool: tc.name, toolArgs: step2.args, toolCallId: tc.id });
6581
- onProgress({ type: "tool_result", toolResult: notice, step: step2, toolCallId: tc.id });
6582
- }
6583
- turn = await chat.sendToolResults(
6584
- turn.toolCalls.map((tc) => ({ name: tc.name, output: notice, truncated: false, totalChars: notice.length, id: tc.id })),
6585
- signal
6586
- );
6587
- continue;
6780
+ let turn = await chat.sendMessage(message, signal);
6781
+ let wrapUpRounds = 0;
6782
+ for (let step = 0; turn.hasToolCalls; step++) {
6783
+ if (signal?.aborted) return { text: turn.text, researchLog, aborted: true };
6784
+ if (step >= MAX_STEPS) {
6785
+ if (wrapUpRounds >= 2) break;
6786
+ wrapUpRounds++;
6787
+ const notice = `Research tool budget for this turn is exhausted (${MAX_STEPS} rounds) \u2014 this call was NOT executed and no further tool calls will be. Reply to the user now using what you already learned: summarize your findings and ask how to proceed, ask your next question, or emit the plan JSON.`;
6788
+ for (const tc of turn.toolCalls) {
6789
+ const step2 = {
6790
+ id: `rs-${Date.now()}-budget-${researchLog.length}`,
6791
+ tool: tc.name,
6792
+ args: JSON.stringify(tc.args),
6793
+ result: notice,
6794
+ success: false,
6795
+ outcome: "not_executed",
6796
+ toolCallId: tc.id,
6797
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
6798
+ };
6799
+ researchLog.push(step2);
6800
+ onProgress({ type: "tool_call", tool: tc.name, toolArgs: step2.args, toolCallId: tc.id });
6801
+ onProgress({ type: "tool_result", toolResult: notice, step: step2, toolCallId: tc.id });
6588
6802
  }
6589
- const thinking = turn.reasoning ? turn.reasoning.slice(-200) : "";
6590
- const { toolResults, logEntries } = await this.executeToolCalls(
6591
- turn.toolCalls,
6592
- ctx.fs,
6593
- onProgress,
6594
- ctx.fetcher,
6595
- step,
6596
- thinking,
6803
+ turn = await chat.sendToolResults(
6804
+ turn.toolCalls.map((tc) => ({ name: tc.name, output: notice, truncated: false, totalChars: notice.length, id: tc.id })),
6597
6805
  signal
6598
6806
  );
6599
- researchLog.push(...logEntries);
6600
- _BaseAiService.appendBudgetCountdown(toolResults, MAX_STEPS - step - 1, "reply to the user (summary, question, or plan JSON)");
6601
- turn = await chat.sendToolResults(toolResults, signal);
6602
- }
6603
- if (signal?.aborted) {
6604
- onProgress({ type: "interrupted" });
6605
- return { kind: "message", text: turn.text, researchLog };
6606
- }
6607
- if (turn.hasToolCalls && !turn.text.trim()) {
6608
- return {
6609
- kind: "message",
6610
- text: `I hit the research tool budget for this turn (${MAX_STEPS} rounds) before finishing. Tell me to continue, or narrow the request.`,
6611
- researchLog
6612
- };
6613
- }
6614
- if (!turn.text.trim()) {
6615
- if (!emptyNudgeSent && !signal?.aborted) {
6616
- emptyNudgeSent = true;
6617
- pending = "Your last reply was empty. Respond to the user now: answer their last message directly, ask your next question, or emit the plan JSON.";
6618
- continue;
6619
- }
6620
- return {
6621
- kind: "message",
6622
- text: "The planner returned an empty reply twice. Please rephrase or try again.",
6623
- researchLog
6624
- };
6625
- }
6626
- const reply = classifyPlannerReply(turn.text, {
6627
- runners: ctx.runners,
6628
- runnerModes: ctx.runnerModes,
6629
- autonomousDefault: ctx.autonomousDefault
6630
- });
6631
- switch (reply.kind) {
6632
- case "task_ops":
6633
- return { kind: "task_ops", ops: reply.ops, text: turn.text, researchLog };
6634
- // A read is settled by the Session (it owns the plan and the catalog),
6635
- // so it leaves this loop the same way a plan or an edit does.
6636
- case "task_query":
6637
- return { kind: "task_query", query: reply.query, text: turn.text, researchLog };
6638
- case "plan":
6639
- return { kind: "plan", tasks: reply.tasks, text: turn.text, researchLog };
6640
- // Botched attempts get a bounded corrective retry — otherwise the
6641
- // broken JSON would surface as a prose bubble and the edit or plan
6642
- // would silently fail to commit.
6643
- case "broken_task_ops":
6644
- if (jsonRepairAttempts < MAX_JSON_REPAIRS2 && !signal?.aborted) {
6645
- jsonRepairAttempts++;
6646
- pending = reEmitTaskOpsPrompt(reply.error.message);
6647
- continue;
6648
- }
6649
- break;
6650
- case "broken_task_query":
6651
- if (jsonRepairAttempts < MAX_JSON_REPAIRS2 && !signal?.aborted) {
6652
- jsonRepairAttempts++;
6653
- pending = reEmitTaskQueryPrompt(reply.error.message);
6654
- continue;
6655
- }
6656
- break;
6657
- case "broken_plan":
6658
- if (jsonRepairAttempts < MAX_JSON_REPAIRS2 && !signal?.aborted) {
6659
- jsonRepairAttempts++;
6660
- if (reply.error.truncated || turn.finishReason === "length") {
6661
- const removed = chat.compactHistory?.() ?? 0;
6662
- pending = truncatedPlanReEmitPrompt(removed > 0);
6663
- } else {
6664
- pending = reEmitPlanPrompt(reply.error.message);
6665
- }
6666
- continue;
6667
- }
6668
- break;
6669
- case "prose":
6670
- break;
6807
+ continue;
6671
6808
  }
6672
- return { kind: "message", text: turn.text, researchLog };
6809
+ const thinking = turn.reasoning ? turn.reasoning.slice(-200) : "";
6810
+ const { toolResults, logEntries } = await this.executeToolCalls(
6811
+ turn.toolCalls,
6812
+ ctx.fs,
6813
+ onProgress,
6814
+ ctx.fetcher,
6815
+ step,
6816
+ thinking,
6817
+ signal
6818
+ );
6819
+ researchLog.push(...logEntries);
6820
+ _BaseAiService.appendBudgetCountdown(toolResults, MAX_STEPS - step - 1, "reply to the user (summary, question, or plan JSON)");
6821
+ turn = await chat.sendToolResults(toolResults, signal);
6822
+ }
6823
+ if (signal?.aborted) return { text: turn.text, researchLog, aborted: true };
6824
+ if (turn.hasToolCalls && !turn.text.trim()) {
6825
+ return {
6826
+ text: turn.text,
6827
+ researchLog,
6828
+ failure: `I hit the research tool budget for this turn (${MAX_STEPS} rounds) before finishing. Tell me to continue, or narrow the request.`
6829
+ };
6673
6830
  }
6831
+ return { text: turn.text, researchLog, cutOff: turn.finishReason === "length" };
6674
6832
  }
6675
6833
  /**
6676
6834
  * Run the LLM tool-calling research loop for one-shot planning. Returns
@@ -6744,45 +6902,40 @@ ${turn.text}
6744
6902
  };
6745
6903
 
6746
6904
  // src/services/GeminiService.ts
6905
+ import { v4 as uuidv42 } from "uuid";
6747
6906
  import {
6748
6907
  GoogleGenerativeAI
6749
6908
  } from "@google/generative-ai";
6750
6909
  var TOOL_DEFINITIONS = toGeminiToolDeclarations();
6751
- function parseGeminiTurn(resultRaw) {
6752
- const result = resultRaw;
6753
- const candidate = result.response.candidates?.[0];
6754
- if (!candidate) return { text: "", toolCalls: [], hasToolCalls: false };
6755
- const parts = candidate.content?.parts || [];
6756
- let text = "";
6757
- let reasoning = "";
6758
- const toolCalls = [];
6759
- for (const part of parts) {
6760
- if ("text" in part) {
6761
- if (part.thought) reasoning += part.text;
6762
- else text += part.text;
6763
- }
6764
- if ("functionCall" in part) {
6765
- const fc = part.functionCall;
6766
- toolCalls.push({ name: fc.name, args: fc.args });
6767
- }
6768
- }
6769
- const finishReason = candidate.finishReason === "MAX_TOKENS" ? "length" : void 0;
6770
- const promptTokens = result.response.usageMetadata?.promptTokenCount;
6771
- return { text, toolCalls, hasToolCalls: toolCalls.length > 0, reasoning: reasoning || void 0, finishReason, promptTokens };
6910
+ function tokenCount(value) {
6911
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
6912
+ }
6913
+ function usageRecordFrom(usage) {
6914
+ if (!usage) return void 0;
6915
+ const inputTokens = tokenCount(usage.promptTokenCount);
6916
+ const answerTokens = tokenCount(usage.candidatesTokenCount);
6917
+ const thoughtTokens = tokenCount(usage.thoughtsTokenCount);
6918
+ const reportedOutput = [answerTokens, thoughtTokens].filter((t) => t !== void 0);
6919
+ const record = {
6920
+ source: "google",
6921
+ ...inputTokens !== void 0 ? { inputTokens } : {},
6922
+ ...reportedOutput.length > 0 ? { outputTokens: reportedOutput.reduce((a, b) => a + b, 0) } : {},
6923
+ ...tokenCount(usage.cachedContentTokenCount) !== void 0 ? { cachedInputTokens: tokenCount(usage.cachedContentTokenCount) } : {}
6924
+ };
6925
+ const anyReported = record.inputTokens !== void 0 || record.outputTokens !== void 0 || record.cachedInputTokens !== void 0;
6926
+ return anyReported ? record : void 0;
6772
6927
  }
6773
6928
  var GeminiResearchChat = class {
6774
- constructor(chat, onReasoning, onContent) {
6929
+ constructor(chat, hooks = {}) {
6775
6930
  this.chat = chat;
6776
- this.onReasoning = onReasoning;
6777
- this.onContent = onContent;
6931
+ this.hooks = hooks;
6778
6932
  }
6779
6933
  chat;
6780
- onReasoning;
6781
- onContent;
6934
+ hooks;
6782
6935
  async sendMessage(text, signal) {
6783
6936
  if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
6784
- const result = await this.chat.sendMessage(text);
6785
- return this.emit(parseGeminiTurn(result));
6937
+ const result = await this.chat.sendMessageStream(text);
6938
+ return this.consume(result.stream);
6786
6939
  }
6787
6940
  async sendToolResults(results, signal) {
6788
6941
  if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
@@ -6792,13 +6945,56 @@ var GeminiResearchChat = class {
6792
6945
  response: { output: r.output, truncated: r.truncated, totalChars: r.totalChars }
6793
6946
  }
6794
6947
  }));
6795
- const result = await this.chat.sendMessage(funcResponses);
6796
- return this.emit(parseGeminiTurn(result));
6948
+ const result = await this.chat.sendMessageStream(funcResponses);
6949
+ return this.consume(result.stream);
6797
6950
  }
6798
- emit(turn) {
6799
- if (turn.reasoning) this.onReasoning?.(turn.reasoning);
6800
- if (turn.text && !turn.hasToolCalls) this.onContent?.(turn.text);
6801
- return turn;
6951
+ /**
6952
+ * Drain one streaming chat call: thought parts answer on the reasoning
6953
+ * channel, plain text on the reply channel, function calls accumulate into
6954
+ * the turn exactly as the non-streaming parse used to produce. Each channel
6955
+ * mints one fresh segmentId per call — thinking and reply never share, since
6956
+ * a segment is one continuous run of model text — and the call produces
6957
+ * exactly one usage record, from the final chunk: Gemini's streaming chunks
6958
+ * each carry a running total, so the last one is the whole call's bill.
6959
+ */
6960
+ async consume(stream) {
6961
+ const textSegmentId = uuidv42();
6962
+ const thinkingSegmentId = uuidv42();
6963
+ let text = "";
6964
+ let reasoning = "";
6965
+ let finishReason;
6966
+ let usage;
6967
+ const toolCalls = [];
6968
+ for await (const chunkRaw of stream) {
6969
+ const chunk = chunkRaw;
6970
+ const candidate = chunk.candidates?.[0];
6971
+ if (candidate?.finishReason) finishReason = candidate.finishReason;
6972
+ if (chunk.usageMetadata) usage = chunk.usageMetadata;
6973
+ for (const part of candidate?.content?.parts ?? []) {
6974
+ if (part.text !== void 0) {
6975
+ if (part.thought) {
6976
+ reasoning += part.text;
6977
+ this.hooks.onReasoning?.(part.text, thinkingSegmentId);
6978
+ } else {
6979
+ text += part.text;
6980
+ this.hooks.onContent?.(part.text, textSegmentId);
6981
+ }
6982
+ }
6983
+ if (part.functionCall) {
6984
+ toolCalls.push({ name: part.functionCall.name, args: part.functionCall.args });
6985
+ }
6986
+ }
6987
+ }
6988
+ const usageRecord3 = usageRecordFrom(usage);
6989
+ if (usageRecord3) this.hooks.onUsage?.(usageRecord3);
6990
+ return {
6991
+ text,
6992
+ toolCalls,
6993
+ hasToolCalls: toolCalls.length > 0,
6994
+ reasoning: reasoning || void 0,
6995
+ finishReason: finishReason === "MAX_TOKENS" ? "length" : void 0,
6996
+ promptTokens: usageRecord3?.inputTokens
6997
+ };
6802
6998
  }
6803
6999
  };
6804
7000
  var GeminiService = class extends BaseAiService {
@@ -6870,11 +7066,11 @@ ${m.content}` });
6870
7066
  generationConfig: { temperature: 0.3, topP: 0.95, maxOutputTokens: 16384 }
6871
7067
  });
6872
7068
  let currentProgress = req.onProgress;
6873
- const researchChat = new GeminiResearchChat(
6874
- chat,
6875
- (text) => currentProgress({ type: "thinking", text }),
6876
- (text) => currentProgress({ type: "plan_token", planToken: text })
6877
- );
7069
+ const researchChat = new GeminiResearchChat(chat, {
7070
+ onReasoning: (text, segmentId) => currentProgress({ type: "thinking", text, segmentId }),
7071
+ onContent: (text, segmentId) => currentProgress({ type: "text_delta", text, segmentId }),
7072
+ onUsage: (record) => currentProgress({ type: "usage", record })
7073
+ });
6878
7074
  const ctx = {
6879
7075
  chat: researchChat,
6880
7076
  fs: req.fs,
@@ -6953,7 +7149,9 @@ Explore the workspace to understand the codebase, then generate the plan.`;
6953
7149
  tools: [{ functionDeclarations: TOOL_DEFINITIONS }],
6954
7150
  generationConfig: { temperature: 0.3, topP: 0.95, maxOutputTokens: 16384 }
6955
7151
  });
6956
- const researchChat = new GeminiResearchChat(chat);
7152
+ const researchChat = new GeminiResearchChat(chat, {
7153
+ onUsage: (record) => onProgress({ type: "usage", record })
7154
+ });
6957
7155
  const result = await this.runResearchLoop(researchChat, firstMessage, fs15, onProgress, runners, void 0, fetcher, userDescription, runnerModes, autonomousDefault, signal);
6958
7156
  if (result.tasks) return { tasks: result.tasks, researchLog: result.researchLog, researchResults: result.researchResults };
6959
7157
  const fallback = await this.generatePlanFallback(userDescription, contextStr, result.researchResults, modelsByRunner, runners, onProgress, result.researchLog, runnerModes, autonomousDefault, signal, modes);
@@ -7010,14 +7208,18 @@ ${collected.aiflowContext}
7010
7208
 
7011
7209
  // src/services/OpenAiService.ts
7012
7210
  import OpenAI from "openai";
7211
+ import { v4 as uuidv43 } from "uuid";
7013
7212
  var OpenAiResearchChat = class {
7014
- constructor(messages, client, model, tools, onReasoning, onContent) {
7213
+ constructor(messages, client, model, tools, onReasoning, onContent, source = "openai", onUsage, contextWindow) {
7015
7214
  this.messages = messages;
7016
7215
  this.client = client;
7017
7216
  this.model = model;
7018
7217
  this.tools = tools;
7019
7218
  this.onReasoning = onReasoning;
7020
7219
  this.onContent = onContent;
7220
+ this.source = source;
7221
+ this.onUsage = onUsage;
7222
+ this.contextWindow = contextWindow;
7021
7223
  }
7022
7224
  messages;
7023
7225
  client;
@@ -7025,10 +7227,26 @@ var OpenAiResearchChat = class {
7025
7227
  tools;
7026
7228
  onReasoning;
7027
7229
  onContent;
7230
+ source;
7231
+ onUsage;
7232
+ contextWindow;
7028
7233
  async sendMessage(text, signal) {
7234
+ this.answerAbandonedCalls();
7029
7235
  this.messages.push({ role: "user", content: text });
7030
7236
  return this.callApi(signal);
7031
7237
  }
7238
+ /**
7239
+ * A turn that gave up on its tool budget, or was stopped, can end on calls
7240
+ * nothing answered — and the API refuses every later request over a history
7241
+ * like that. They are answered as never run before the next message.
7242
+ */
7243
+ answerAbandonedCalls() {
7244
+ const last = this.messages[this.messages.length - 1];
7245
+ if (last?.role !== "assistant" || !last.tool_calls?.length) return;
7246
+ for (const call of last.tool_calls) {
7247
+ this.messages.push({ role: "tool", tool_call_id: call.id, content: "Not executed: the turn that asked for this ended first." });
7248
+ }
7249
+ }
7032
7250
  async sendToolResults(results, signal) {
7033
7251
  for (const r of results) {
7034
7252
  this.messages.push({ role: "tool", tool_call_id: r.id, content: r.output });
@@ -7053,24 +7271,30 @@ var OpenAiResearchChat = class {
7053
7271
  // proactive history compaction keys on.
7054
7272
  stream_options: { include_usage: true }
7055
7273
  }, signal ? { signal } : void 0);
7274
+ const segmentId = uuidv43();
7056
7275
  let content = "";
7057
7276
  let reasoning = "";
7058
7277
  let finishReason;
7059
7278
  let promptTokens;
7279
+ let reported;
7060
7280
  const toolAcc = /* @__PURE__ */ new Map();
7061
7281
  for await (const chunk of stream) {
7062
- if (chunk.usage) promptTokens = chunk.usage.prompt_tokens;
7282
+ const usage2 = chunk.usage;
7283
+ if (usage2) {
7284
+ reported = usage2;
7285
+ promptTokens = usage2.prompt_tokens;
7286
+ }
7063
7287
  const fr = chunk.choices[0]?.finish_reason;
7064
7288
  if (fr) finishReason = fr;
7065
7289
  const delta = chunk.choices[0]?.delta;
7066
7290
  if (!delta) continue;
7067
7291
  if (delta.reasoning) {
7068
7292
  reasoning += delta.reasoning;
7069
- this.onReasoning?.(delta.reasoning);
7293
+ this.onReasoning?.(delta.reasoning, segmentId);
7070
7294
  }
7071
7295
  if (delta.content) {
7072
7296
  content += delta.content;
7073
- this.onContent?.(delta.content);
7297
+ this.onContent?.(delta.content, segmentId);
7074
7298
  }
7075
7299
  for (const tc of delta.tool_calls ?? []) {
7076
7300
  const acc = toolAcc.get(tc.index) ?? { id: "", name: "", args: "" };
@@ -7080,6 +7304,8 @@ var OpenAiResearchChat = class {
7080
7304
  toolAcc.set(tc.index, acc);
7081
7305
  }
7082
7306
  }
7307
+ const usage = this.usageRecord(reported);
7308
+ if (usage) this.onUsage?.(usage);
7083
7309
  const accepted = [...toolAcc.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v).filter((v) => v.name);
7084
7310
  const assistantMsg = accepted.length > 0 ? { role: "assistant", content: content || null, tool_calls: accepted.map((v) => ({ id: v.id, type: "function", function: { name: v.name, arguments: v.args } })) } : { role: "assistant", content };
7085
7311
  this.messages.push(assistantMsg);
@@ -7093,6 +7319,34 @@ var OpenAiResearchChat = class {
7093
7319
  });
7094
7320
  return { text: content, toolCalls, hasToolCalls: toolCalls.length > 0, reasoning: reasoning || void 0, finishReason, promptTokens };
7095
7321
  }
7322
+ /**
7323
+ * The one record for this API call, built only from what the provider
7324
+ * reported. OpenRouter's `cost` is in USD (its credits are dollar-pegged);
7325
+ * nothing is estimated. A field the provider left out stays absent.
7326
+ */
7327
+ usageRecord(reported) {
7328
+ if (!reported) return void 0;
7329
+ const record = { source: this.source, model: this.model };
7330
+ let hasMeasure = false;
7331
+ if (reported.prompt_tokens !== void 0) {
7332
+ record.inputTokens = reported.prompt_tokens;
7333
+ hasMeasure = true;
7334
+ }
7335
+ if (reported.completion_tokens !== void 0) {
7336
+ record.outputTokens = reported.completion_tokens;
7337
+ hasMeasure = true;
7338
+ }
7339
+ if (reported.prompt_tokens_details?.cached_tokens !== void 0) {
7340
+ record.cachedInputTokens = reported.prompt_tokens_details.cached_tokens;
7341
+ hasMeasure = true;
7342
+ }
7343
+ if (reported.cost !== void 0) {
7344
+ record.reportedCost = { amount: reported.cost, currency: "USD" };
7345
+ hasMeasure = true;
7346
+ }
7347
+ if (this.contextWindow && this.contextWindow > 0) record.contextWindow = this.contextWindow;
7348
+ return hasMeasure ? record : void 0;
7349
+ }
7096
7350
  };
7097
7351
  var OpenAiService = class extends BaseAiService {
7098
7352
  client = null;
@@ -7151,7 +7405,7 @@ var OpenAiService = class extends BaseAiService {
7151
7405
  return fullResponse;
7152
7406
  }
7153
7407
  /** A research subagent: fresh history, digest contract, cheap model, read-only tools. */
7154
- createSubagentChat(onReasoning) {
7408
+ createSubagentChat(onReasoning, onUsage) {
7155
7409
  const client = this.getClient();
7156
7410
  const messages = [
7157
7411
  { role: "system", content: buildSubagentSystemPrompt() }
@@ -7161,7 +7415,10 @@ var OpenAiService = class extends BaseAiService {
7161
7415
  client,
7162
7416
  this.requireModel("researchSubagentModel", this.config.researchSubagentModel),
7163
7417
  toOpenAiSubagentTools(),
7164
- onReasoning
7418
+ onReasoning,
7419
+ void 0,
7420
+ this.config.aiProvider,
7421
+ onUsage
7165
7422
  );
7166
7423
  }
7167
7424
  // --- Conversation loop (ADR-0002) ---
@@ -7194,8 +7451,11 @@ ${buildResearchToolsPrompt()}` }
7194
7451
  client,
7195
7452
  this.requireModel("orchestratorModel", this.config.orchestratorModel),
7196
7453
  toOpenAiTools(),
7197
- (delta) => currentProgress({ type: "thinking", text: delta }),
7198
- (delta) => currentProgress({ type: "plan_token", planToken: delta })
7454
+ (delta, segmentId) => currentProgress({ type: "thinking", text: delta, segmentId }),
7455
+ (delta, segmentId) => currentProgress({ type: "text_delta", text: delta, segmentId }),
7456
+ this.config.aiProvider,
7457
+ (record) => currentProgress({ type: "usage", record }),
7458
+ req.contextWindow
7199
7459
  );
7200
7460
  const ctx = {
7201
7461
  chat,
@@ -7241,7 +7501,10 @@ ${userText}`;
7241
7501
  client,
7242
7502
  this.requireModel("orchestratorModel", this.config.orchestratorModel),
7243
7503
  toOpenAiTools(),
7244
- (delta) => onProgress({ type: "thinking", text: delta })
7504
+ (delta) => onProgress({ type: "thinking", text: delta }),
7505
+ void 0,
7506
+ this.config.aiProvider,
7507
+ (record) => onProgress({ type: "usage", record })
7245
7508
  );
7246
7509
  const result = await this.runResearchLoop(researchChat, firstMessage, fs15, onProgress, runners, void 0, fetcher, userDescription, runnerModes, autonomousDefault, signal);
7247
7510
  if (result.tasks) return { tasks: result.tasks, researchLog: result.researchLog, researchResults: result.researchResults };
@@ -7468,182 +7731,6 @@ function composeAugmentedPrompt(task, allTasks, opts) {
7468
7731
  ${basePrompt}${marker2}`;
7469
7732
  }
7470
7733
 
7471
- // src/services/terminalRender.ts
7472
- var ANSI_OR_CTRL_RE = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[()][AB012]|\x1b[=>]|[\x00-\x08\x0b-\x1f\x7f]/g;
7473
- function flattenTerminalOutput(raw) {
7474
- return raw.replace(ANSI_OR_CTRL_RE, "").replace(/[─-▟]/g, "").replace(/\s+/g, "");
7475
- }
7476
- function renderTerminalOutput(raw) {
7477
- const rows = /* @__PURE__ */ new Map();
7478
- let row = 1;
7479
- let col = 1;
7480
- let savedRow = 1;
7481
- let savedCol = 1;
7482
- const cells = (r) => {
7483
- let line = rows.get(r);
7484
- if (!line) {
7485
- line = [];
7486
- rows.set(r, line);
7487
- }
7488
- return line;
7489
- };
7490
- const firstParam = (params, fallback = 1) => params[0] || fallback;
7491
- const eraseLine = (mode) => {
7492
- const line = cells(row);
7493
- if (mode === 2) {
7494
- rows.set(row, []);
7495
- } else if (mode === 1) {
7496
- for (let c = 0; c < col; c++) line[c] = " ";
7497
- } else {
7498
- line.length = Math.max(0, col - 1);
7499
- }
7500
- };
7501
- for (let i = 0; i < raw.length; ) {
7502
- const ch = raw[i];
7503
- if (ch === "\x1B") {
7504
- const kind = raw[i + 1];
7505
- if (kind === "[") {
7506
- let end = i + 2;
7507
- while (end < raw.length) {
7508
- const code = raw.charCodeAt(end);
7509
- if (code >= 64 && code <= 126) break;
7510
- end++;
7511
- }
7512
- if (end >= raw.length) break;
7513
- const final = raw[end];
7514
- const body = raw.slice(i + 2, end).replace(/^[?<>=!]+/, "");
7515
- const params = body.split(";").map((p) => Number.parseInt(p, 10) || 0);
7516
- switch (final) {
7517
- case "H":
7518
- case "f":
7519
- row = params[0] || 1;
7520
- col = params[1] || 1;
7521
- break;
7522
- case "G":
7523
- col = firstParam(params);
7524
- break;
7525
- case "d":
7526
- row = firstParam(params);
7527
- break;
7528
- case "A":
7529
- row = Math.max(1, row - firstParam(params));
7530
- break;
7531
- case "B":
7532
- row += firstParam(params);
7533
- break;
7534
- case "C":
7535
- col += firstParam(params);
7536
- break;
7537
- case "D":
7538
- col = Math.max(1, col - firstParam(params));
7539
- break;
7540
- case "E":
7541
- row += firstParam(params);
7542
- col = 1;
7543
- break;
7544
- case "F":
7545
- row = Math.max(1, row - firstParam(params));
7546
- col = 1;
7547
- break;
7548
- case "s":
7549
- savedRow = row;
7550
- savedCol = col;
7551
- break;
7552
- case "u":
7553
- row = savedRow;
7554
- col = savedCol;
7555
- break;
7556
- case "K":
7557
- eraseLine(params[0] || 0);
7558
- break;
7559
- case "J":
7560
- if ((params[0] || 0) === 2 || (params[0] || 0) === 3) rows.clear();
7561
- break;
7562
- case "X": {
7563
- const line = cells(row);
7564
- for (let c = 0; c < firstParam(params); c++) line[col - 1 + c] = " ";
7565
- break;
7566
- }
7567
- case "P": {
7568
- cells(row).splice(col - 1, firstParam(params));
7569
- break;
7570
- }
7571
- case "@": {
7572
- cells(row).splice(col - 1, 0, ...Array(firstParam(params)).fill(" "));
7573
- break;
7574
- }
7575
- default:
7576
- break;
7577
- }
7578
- i = end + 1;
7579
- continue;
7580
- }
7581
- if (kind === "]" || kind === "P" || kind === "^" || kind === "_") {
7582
- let end = i + 2;
7583
- while (end < raw.length && raw[end] !== "\x07" && !(raw[end] === "\x1B" && raw[end + 1] === "\\")) end++;
7584
- if (end >= raw.length) break;
7585
- i = raw[end] === "\x07" ? end + 1 : end + 2;
7586
- continue;
7587
- }
7588
- if (kind === "7") {
7589
- savedRow = row;
7590
- savedCol = col;
7591
- } else if (kind === "8") {
7592
- row = savedRow;
7593
- col = savedCol;
7594
- } else if (kind === "c") {
7595
- rows.clear();
7596
- row = 1;
7597
- col = 1;
7598
- }
7599
- i += kind === "(" || kind === ")" ? 3 : 2;
7600
- continue;
7601
- }
7602
- if (ch === "\r") {
7603
- col = 1;
7604
- i++;
7605
- continue;
7606
- }
7607
- if (ch === "\n") {
7608
- row++;
7609
- i++;
7610
- continue;
7611
- }
7612
- if (ch === "\b") {
7613
- col = Math.max(1, col - 1);
7614
- i++;
7615
- continue;
7616
- }
7617
- if (ch === " ") {
7618
- col += 8 - (col - 1) % 8;
7619
- i++;
7620
- continue;
7621
- }
7622
- if (ch < " " || ch === "\x7F") {
7623
- i++;
7624
- continue;
7625
- }
7626
- cells(row)[col - 1] = ch;
7627
- col++;
7628
- i++;
7629
- }
7630
- const populated = [...rows.keys()].sort((a, b) => a - b);
7631
- return populated.map((r) => cells(r).join("")).join("\n");
7632
- }
7633
- function renderCleanCapture(raw, doneToken) {
7634
- const rendered = renderTerminalOutput(raw).replace(/[\s─-▟]+$/gm, "");
7635
- if (!doneToken) return rendered.trim();
7636
- const lines = rendered.split("\n");
7637
- const flat = (s) => flattenTerminalOutput(s);
7638
- for (let i = 0; i < lines.length; i++) {
7639
- const own = flat(lines[i]).includes(doneToken);
7640
- if (own) return lines.slice(0, i).join("\n").trim();
7641
- const window = flat(lines[i]) + (i + 1 < lines.length ? flat(lines[i + 1]) : "");
7642
- if (window.includes(doneToken)) return lines.slice(0, i + 1).join("\n").trim();
7643
- }
7644
- return rendered.trim();
7645
- }
7646
-
7647
7734
  // src/services/VerdictEngine.ts
7648
7735
  var CHECKPOINT_RE = /<<<ORDEWELL_CHECKPOINT:\s*(.*?)>>>/gs;
7649
7736
  function markerVisible(raw, doneToken) {
@@ -8719,6 +8806,8 @@ function watchBlockingPrompts(session, prompts, onPrompt) {
8719
8806
 
8720
8807
  // src/services/TaskOrchestrator.ts
8721
8808
  var SHARED_ROOT_TAIL = "tasks run in the workspace root without worktree isolation.";
8809
+ var USAGE_LIMIT_RE = /\b(?:usage|session|weekly|daily|monthly) limit\b|\brate limit (?:exceeded|reached)\b|\brate[- ]limited\b|\blimit (?:will )?reset\b|\bquota (?:exceeded|reached)\b|\btoo many requests\b/i;
8810
+ var USAGE_LIMIT_TAIL = 4096;
8722
8811
  function sharedRootNotice(reason, repos) {
8723
8812
  switch (reason) {
8724
8813
  case "disabled":
@@ -8772,6 +8861,7 @@ var TaskOrchestrator = class {
8772
8861
  running = false;
8773
8862
  planStatus = "approved";
8774
8863
  messageQueue = [];
8864
+ queueSeq = 0;
8775
8865
  reviewApproved = false;
8776
8866
  /*
8777
8867
  * Retry counts, spawn counts and holds describe a task across attempts, so
@@ -8986,7 +9076,10 @@ var TaskOrchestrator = class {
8986
9076
  this.resolvers = { ...state?.resolvers ?? {} };
8987
9077
  if (!this.isolationRun) return;
8988
9078
  try {
8989
- await this.isolation.pruneOrphans(this.isolationRun);
9079
+ const { kept } = await this.isolation.pruneOrphans(this.isolationRun);
9080
+ for (const task of kept) {
9081
+ this.tell("warn", `Task "${task.title}" was still holding unlanded work when this plan was re-opened, so its worktree and branch were kept. Retry it to land the work, or review it by hand.`);
9082
+ }
8990
9083
  } catch (err) {
8991
9084
  this.tell("warn", `Could not prune leftover worktrees: ${err instanceof Error ? err.message : String(err)}`);
8992
9085
  }
@@ -9007,7 +9100,7 @@ var TaskOrchestrator = class {
9007
9100
  const group = run.repos.some((r) => r.path !== SELF_REPO);
9008
9101
  const repaired = handoffOf(run).landed.filter((t) => t.repairedFiles?.length);
9009
9102
  const { level, message } = describeMergeResult(result, branch, group, repaired);
9010
- this.notifications[level](message);
9103
+ this.tell(level, message);
9011
9104
  if (result.outcome === "merged") await this.clearMergedRun(run);
9012
9105
  return result;
9013
9106
  }
@@ -9064,7 +9157,9 @@ var TaskOrchestrator = class {
9064
9157
  }
9065
9158
  queueMessage(text) {
9066
9159
  this.messageQueue.push({
9067
- id: `q-${Date.now()}`,
9160
+ // A sequence, not just the clock: two sends inside one millisecond must
9161
+ // stay distinguishable, since a surface removes one by id.
9162
+ id: `q-${Date.now()}-${++this.queueSeq}`,
9068
9163
  text,
9069
9164
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
9070
9165
  });
@@ -9073,6 +9168,14 @@ var TaskOrchestrator = class {
9073
9168
  getQueuedMessages() {
9074
9169
  return [...this.messageQueue];
9075
9170
  }
9171
+ /** Take one unsent message back out of the queue; false when it was never there (or already drained). */
9172
+ removeQueuedMessage(id) {
9173
+ const index = this.messageQueue.findIndex((m) => m.id === id);
9174
+ if (index < 0) return false;
9175
+ this.messageQueue.splice(index, 1);
9176
+ this.emit("onTaskChanged");
9177
+ return true;
9178
+ }
9076
9179
  setQueuedMessages(messages) {
9077
9180
  this.messageQueue = [...messages];
9078
9181
  }
@@ -9182,6 +9285,11 @@ var TaskOrchestrator = class {
9182
9285
  ${summary || "(empty \u2014 no output captured)"}`);
9183
9286
  if (verdict.outcome === "pass") {
9184
9287
  await this.landPassed(task, landing);
9288
+ } else if (this.stoppedOnUsageLimit(attempt)) {
9289
+ this.store.markAwaitingUser(taskId);
9290
+ this.haltOnFailure();
9291
+ this.tell("warn", `Task "${task.title}" stopped before its completion marker: ${attempt.runner} hit its usage limit. Retry it once the limit resets \u2014 its worktree is kept.`);
9292
+ if (attempt.worktree) await this.releaseWorktree(taskId, { keep: true });
9185
9293
  } else {
9186
9294
  this.store.markFailed(taskId);
9187
9295
  this.haltOnFailure();
@@ -9192,6 +9300,11 @@ ${summary || "(empty \u2014 no output captured)"}`);
9192
9300
  this.logAndArchive(task, verdict);
9193
9301
  await this.afterVerdict();
9194
9302
  }
9303
+ /** Whether a stopped runner's own tail says its account, not the task, ran out. */
9304
+ stoppedOnUsageLimit(attempt) {
9305
+ const tail = attempt.session?.getOutput().slice(-USAGE_LIMIT_TAIL) ?? "";
9306
+ return USAGE_LIMIT_RE.test(tail);
9307
+ }
9195
9308
  async afterVerdict() {
9196
9309
  this.emit("onTaskChanged");
9197
9310
  if (!this.running) {
@@ -9323,9 +9436,9 @@ ${summary || "(empty \u2014 no output captured)"}`);
9323
9436
  const whyNot = this.noRepairReason(task);
9324
9437
  if (whyNot) this.tell("info", whyNot);
9325
9438
  } else {
9326
- this.store.markFailed(task.id);
9327
- this.haltOnFailure();
9328
- this.notifications.error(inRepo ? `Task "${task.title}" passed, but git could not integrate its work in ${inRepo}, so none of it landed. Its worktrees are kept for inspection.` : `Task "${task.title}" passed, but git could not integrate its work. Its worktree is kept for inspection.`);
9439
+ const why = record?.landingError ? ` (${record.landingError})` : "";
9440
+ this.store.markAwaitingUser(task.id);
9441
+ this.notifications.error(inRepo ? `Task "${task.title}" passed, but git could not integrate its work in ${inRepo}${why}, so none of it landed. Its worktrees are kept for inspection.` : `Task "${task.title}" passed, but git could not integrate its work${why}. Its worktree is kept for inspection.`);
9329
9442
  }
9330
9443
  }
9331
9444
  /** The repair a conflicted task is owed next; null when repair is off, used up, or there is no isolated run to repair it in. */
@@ -9432,27 +9545,35 @@ ${summary || "(empty \u2014 no output captured)"}`);
9432
9545
  * Cancel a running (or scheduled) task: kill its session and return it to
9433
9546
  * 'pending' — "not executed". The task is put on hold so the scheduler
9434
9547
  * doesn't immediately restart it; Retry / Force Start release the hold.
9548
+ *
9549
+ * The attempt's worktree is kept, as a stopped or failed one is: a runner is
9550
+ * often cancelled because it looked stuck after doing the work, and Mark
9551
+ * complete can still land that work. The next attempt replaces it.
9435
9552
  */
9436
9553
  async cancelTask(taskId) {
9554
+ await this.cancelAttempt(taskId, { keep: true });
9555
+ }
9556
+ async cancelAttempt(taskId, worktree) {
9437
9557
  const task = this.store.get(taskId);
9438
9558
  if (!task) return;
9439
9559
  const ended = this.endAttempt(taskId, "cancel");
9440
9560
  this.store.markPending(taskId);
9441
9561
  this.onHold.add(taskId);
9442
9562
  this.emit("onTaskChanged");
9443
- await this.releaseWorktree(taskId, { keep: false }, ended?.integration);
9563
+ await this.releaseWorktree(taskId, worktree, ended?.integration);
9444
9564
  await this.tick();
9445
9565
  }
9446
9566
  /**
9447
9567
  * Let go of a task that is leaving the plan. A live runner is cancelled
9448
- * through {@link cancelTask}; a spawn still in flight just loses its attempt,
9568
+ * as {@link cancelTask} does, but its worktree goes: no task is left to land
9569
+ * it into. A spawn still in flight just loses its attempt,
9449
9570
  * which is what makes {@link startTask} kill the session it is about to
9450
9571
  * receive. The id's cross-attempt bookkeeping goes too — a hold or retry
9451
9572
  * count kept for a task that no longer exists would be inherited by nothing.
9452
9573
  */
9453
9574
  async releaseTask(taskId) {
9454
9575
  const phase = this.attempts.get(taskId)?.phase;
9455
- if (phase === "running" || phase === "integrating") await this.cancelTask(taskId);
9576
+ if (phase === "running" || phase === "integrating") await this.cancelAttempt(taskId, { keep: false });
9456
9577
  else {
9457
9578
  this.endAttempt(taskId, "release");
9458
9579
  await this.releaseWorktree(taskId, { keep: false });
@@ -9544,12 +9665,12 @@ ${summary || "(empty \u2014 no output captured)"}`);
9544
9665
  }
9545
9666
  /**
9546
9667
  * Run exactly one task outside full-plan scheduling. The active/starting
9547
- * session still contributes to isRunning so every surface exposes Stop and
9548
- * disables Execute Plan, but onVerdict cannot auto-schedule other tasks
9549
- * because the plan scheduler's `running` flag remains false.
9668
+ * session still contributes to {@link hasLiveWork} so every surface exposes
9669
+ * Stop and disables Execute Plan, but onVerdict cannot auto-schedule other
9670
+ * tasks because the plan scheduler's `running` flag remains false.
9550
9671
  */
9551
9672
  async runTask(taskId) {
9552
- if (this.isRunning) return;
9673
+ if (this.hasLiveWork) return;
9553
9674
  const task = this.store.get(taskId);
9554
9675
  if (!task || task.type !== "ai") return;
9555
9676
  if (!await this.openRun(() => this.runTask(taskId))) return;
@@ -9691,7 +9812,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
9691
9812
  await this.releaseWorktree(task.id, { keep: false });
9692
9813
  this.store.markPending(task.id);
9693
9814
  this.onHold.add(task.id);
9694
- this.notifications.error(`Failed to start task "${task.title}": ${err}`);
9815
+ this.tell("error", `Failed to start task "${task.title}": ${err}`);
9695
9816
  this.emit("onTaskChanged");
9696
9817
  await this.tick();
9697
9818
  }
@@ -9830,19 +9951,21 @@ ${summary || "(empty \u2014 no output captured)"}`);
9830
9951
  }
9831
9952
  }
9832
9953
  /**
9833
- * The plan's run carries on while anything has landed on its integration
9834
- * branch: a resumed plan's dependents must start from a tip that holds their
9835
- * predecessors' work, and a fresh branch from the checked-out commit does not.
9954
+ * The plan's run carries on while it still has any record: anything landed
9955
+ * means a resumed plan's dependents must start from a tip that holds their
9956
+ * predecessors' work, and anything held — a kept attempt, a conflict, a
9957
+ * repair — is work the user may still want, which a fresh run's mint would
9958
+ * delete. Only a run with no records at all is superseded.
9836
9959
  */
9837
9960
  continuableRun(root) {
9838
9961
  const run = this.isolationRun;
9839
- return run && run.workspaceRoot === root && Object.values(run.tasks).some((r) => r.status === "merged") ? run : null;
9962
+ return run && run.workspaceRoot === root && Object.values(run.tasks).some((r) => r.status !== "active") ? run : null;
9840
9963
  }
9841
9964
  /**
9842
- * A run with nothing landed holds only superseded attempts, so it goes whole.
9843
- * One that cannot be continued for another reason — it ran from a different
9844
- * workspace path — keeps its integration branch in each repo that has not
9845
- * merged it: only the user gives landed work up.
9965
+ * A run with no records at all holds only superseded attempts, so it goes
9966
+ * whole. One that cannot be continued for another reason — it ran from a
9967
+ * different workspace path — keeps its integration branch in each repo that
9968
+ * has not merged it: only the user gives landed work up.
9846
9969
  */
9847
9970
  async mintRun(root) {
9848
9971
  const previous = this.isolationRun;
@@ -10020,11 +10143,12 @@ var Planner = class {
10020
10143
  req.runnerModes,
10021
10144
  req.autonomousDefault ?? true
10022
10145
  );
10146
+ const finished = new Set(req.executionLog.filter((t) => t.status === "completed").map((t) => t.id));
10023
10147
  return repairLoop({
10024
10148
  first: () => send(),
10025
10149
  resend: (corrective) => send(corrective),
10026
10150
  interpret: (tasks) => {
10027
- const coerced = coerceAssignments(tasks, allowlist, req.runners, req.modelsByRunner);
10151
+ const coerced = coerceAssignments(tasks.filter((t) => !finished.has(t.id)), allowlist, req.runners, req.modelsByRunner);
10028
10152
  const validation = validatePlanModification({
10029
10153
  executionLog: req.executionLog,
10030
10154
  oldPending: req.pendingTasks,
@@ -11072,7 +11196,7 @@ function discoveredToCatalog(m) {
11072
11196
  name: m.modelLabel,
11073
11197
  description: "",
11074
11198
  pricing: { prompt: "?", completion: "?" },
11075
- contextLength: 0
11199
+ contextLength: m.contextWindow ?? 0
11076
11200
  };
11077
11201
  }
11078
11202
  async function fetchAllProviderModels(opts) {
@@ -11177,7 +11301,8 @@ function toOrchestratorOptions(providerModels, shortcuts) {
11177
11301
  provider: providerLabel,
11178
11302
  apiProvider: provider,
11179
11303
  description: s?.description || m.description || void 0,
11180
- pricing
11304
+ pricing,
11305
+ ...m.contextLength > 0 ? { contextWindow: m.contextLength } : {}
11181
11306
  });
11182
11307
  }
11183
11308
  }
@@ -11260,6 +11385,23 @@ var ModelResolver = class {
11260
11385
  getCachedPickerOptions() {
11261
11386
  return this.cachedPickerOptions ?? [];
11262
11387
  }
11388
+ /**
11389
+ * The planner model's context window, from whatever catalog is already
11390
+ * cached (#49): a vendor model comes from the picker catalog, a harness
11391
+ * planner's model from the runner's own discovered models. Reads only —
11392
+ * never triggers a fetch or discovery of its own, so an unknown window stays
11393
+ * unknown and context fill is omitted rather than guessed.
11394
+ */
11395
+ contextWindowFor(modelId) {
11396
+ if (!modelId) return void 0;
11397
+ const picker = this.cachedPickerOptions?.find((o) => o.id === modelId)?.contextWindow;
11398
+ if (picker && picker > 0) return picker;
11399
+ for (const runner of this.config.enabledRunners ?? []) {
11400
+ const found = this.discovery.getCached(runner)?.find((m) => m.modelId === modelId)?.contextWindow;
11401
+ if (found && found > 0) return found;
11402
+ }
11403
+ return void 0;
11404
+ }
11263
11405
  invalidate() {
11264
11406
  this.discovery.clear();
11265
11407
  this.cachedPickerOptions = null;
@@ -11386,6 +11528,81 @@ var RunnerInstallation = class {
11386
11528
 
11387
11529
  // src/services/harness/CliAgentAiService.ts
11388
11530
  import { spawn as nodeSpawn } from "child_process";
11531
+ import { v4 as uuidv44 } from "uuid";
11532
+
11533
+ // src/services/replyStream.ts
11534
+ var ReplySplitter = class {
11535
+ segments = /* @__PURE__ */ new Map();
11536
+ push(segmentId, text) {
11537
+ const state = this.segments.get(segmentId);
11538
+ if (state === "text" || state === "plan") return { route: state, text };
11539
+ const held = (state?.held ?? "") + text;
11540
+ const opensWithObject = opensWithJsonObject(held);
11541
+ if (opensWithObject === void 0) {
11542
+ this.segments.set(segmentId, { held });
11543
+ return null;
11544
+ }
11545
+ const route = opensWithObject ? "plan" : "text";
11546
+ this.segments.set(segmentId, route);
11547
+ return { route, text: held };
11548
+ }
11549
+ };
11550
+ var TurnStream = class {
11551
+ constructor(turnId, emit) {
11552
+ this.turnId = turnId;
11553
+ this.emit = emit;
11554
+ }
11555
+ turnId;
11556
+ emit;
11557
+ splitter = new ReplySplitter();
11558
+ /**
11559
+ * Segments streamed and not taken back, to the chat or to the plan display,
11560
+ * each with the sink it came through. A botched envelope streams to the plan
11561
+ * display, and its retry must not build on top of it.
11562
+ */
11563
+ shown = /* @__PURE__ */ new Map();
11564
+ /**
11565
+ * Progress for one backend call. A backend that takes back its attempt
11566
+ * without naming a segment means the text of that call only — not what an
11567
+ * earlier call of the same turn streamed, such as a read it answered.
11568
+ */
11569
+ sink() {
11570
+ const call = {};
11571
+ return (progress) => {
11572
+ if (progress.type === "text_delta" && progress.segmentId && progress.text) {
11573
+ const routed = this.splitter.push(progress.segmentId, progress.text);
11574
+ if (!routed) return;
11575
+ this.shown.set(progress.segmentId, call);
11576
+ if (routed.route === "plan") {
11577
+ this.emit({ type: "plan_token", planToken: routed.text, segmentId: progress.segmentId, turnId: this.turnId });
11578
+ return;
11579
+ }
11580
+ this.emit({ ...progress, text: routed.text, turnId: this.turnId });
11581
+ return;
11582
+ }
11583
+ if (progress.type === "text_retracted") {
11584
+ const { segmentId } = progress;
11585
+ this.retractWhere((id, owner) => segmentId ? id === segmentId : owner === call);
11586
+ return;
11587
+ }
11588
+ this.emit({ ...progress, turnId: this.turnId });
11589
+ };
11590
+ }
11591
+ /**
11592
+ * Take back every segment the turn still shows: the owner is discarding the
11593
+ * whole attempt, which spans every call since the last one it discarded.
11594
+ */
11595
+ retract() {
11596
+ this.retractWhere(() => true);
11597
+ }
11598
+ retractWhere(matches) {
11599
+ for (const [segmentId, owner] of this.shown) {
11600
+ if (!matches(segmentId, owner)) continue;
11601
+ this.shown.delete(segmentId);
11602
+ this.emit({ type: "text_retracted", segmentId, turnId: this.turnId });
11603
+ }
11604
+ }
11605
+ };
11389
11606
 
11390
11607
  // src/utils/workspace.ts
11391
11608
  import * as fs8 from "fs";
@@ -11654,6 +11871,31 @@ ${tail}` : ""}`;
11654
11871
  // src/services/harness/ClaudeCodeAdapter.ts
11655
11872
  var DISALLOWED_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit", "KillShell"];
11656
11873
  var ASYNC_LAUNCH_MARKER = "Async agent launched successfully";
11874
+ var SUBAGENT_TOOLS = /* @__PURE__ */ new Set(["Agent", "Task"]);
11875
+ function usageRecord(usage, model, subagentId) {
11876
+ const record = {
11877
+ source: "claude-code",
11878
+ ...partedPromptUsage({ uncached: usage.input_tokens, cacheRead: usage.cache_read_input_tokens, cacheWrite: usage.cache_creation_input_tokens })
11879
+ };
11880
+ if (model) record.model = model;
11881
+ if (usage.output_tokens !== void 0) record.outputTokens = usage.output_tokens;
11882
+ if (subagentId) record.subagentId = subagentId;
11883
+ return record;
11884
+ }
11885
+ function subagentFinalCall(result) {
11886
+ if (typeof result !== "object" || result === null) return null;
11887
+ const { usage, resolvedModel } = result;
11888
+ if (typeof usage !== "object" || usage === null) return null;
11889
+ return { usage, model: typeof resolvedModel === "string" ? resolvedModel : void 0 };
11890
+ }
11891
+ function notificationOutcome(status) {
11892
+ if (status === "completed") return "done";
11893
+ if (status === "killed" || status === "stopped") return "stopped";
11894
+ return "failed";
11895
+ }
11896
+ function blocksOf(msg) {
11897
+ return Array.isArray(msg.message?.content) ? msg.message.content : [];
11898
+ }
11657
11899
  function flattenContent(content) {
11658
11900
  if (typeof content === "string") return content;
11659
11901
  if (Array.isArray(content)) {
@@ -11665,6 +11907,23 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
11665
11907
  agentId = "claude-code";
11666
11908
  /** Whether this turn has already emitted reply text — see {@link handleLine}. */
11667
11909
  turnHasText = false;
11910
+ /** The text block streaming now follows earlier reply text, so its first delta opens the paragraph. */
11911
+ pendingBreak = false;
11912
+ /** The model answering the planner's current message, as its `message_start` named it. */
11913
+ plannerModel;
11914
+ /**
11915
+ * The session's `total_cost_usd` as last reported. Undefined after a resume:
11916
+ * the CLI restores the resumed session's running total, and what Ordewell
11917
+ * already counted of it is not ours to know here.
11918
+ */
11919
+ reportedCostUsd = 0;
11920
+ /**
11921
+ * Subagents started and not yet finished, keyed by the `Agent` call's id.
11922
+ * Kept across turns: a backgrounded one reports after its turn has ended.
11923
+ */
11924
+ openSubagents = /* @__PURE__ */ new Map();
11925
+ /** Subagent messages already counted. A message arrives as one line per content block, each repeating its usage. */
11926
+ countedSubagentMessages = /* @__PURE__ */ new Set();
11668
11927
  spawnSpec(opts) {
11669
11928
  const args = [
11670
11929
  "-p",
@@ -11685,7 +11944,10 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
11685
11944
  ];
11686
11945
  if (opts.model) args.push("--model", opts.model);
11687
11946
  if (opts.effort && opts.effort !== "adaptive") args.push("--effort", opts.effort);
11688
- if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId);
11947
+ if (opts.resumeSessionId) {
11948
+ args.push("--resume", opts.resumeSessionId);
11949
+ this.reportedCostUsd = void 0;
11950
+ }
11689
11951
  return { command: "claude", args };
11690
11952
  }
11691
11953
  turnPayload(message) {
@@ -11700,7 +11962,7 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
11700
11962
  const msg = StdioAgentAdapter.parse(line);
11701
11963
  if (!msg) return;
11702
11964
  if (msg.session_id) this.sessionId = msg.session_id;
11703
- if (msg.parent_tool_use_id) return;
11965
+ const subagentId = msg.parent_tool_use_id ?? void 0;
11704
11966
  switch (msg.type) {
11705
11967
  // The control channel: Claude asks whether a tool may run when its mode
11706
11968
  // cannot decide alone. A read-only planner answers "deny", every time —
@@ -11724,7 +11986,11 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
11724
11986
  return;
11725
11987
  }
11726
11988
  case "assistant":
11727
- for (const block of Array.isArray(msg.message?.content) ? msg.message.content : []) {
11989
+ if (subagentId) {
11990
+ this.handleSubagentMessage(msg, subagentId, emit);
11991
+ return;
11992
+ }
11993
+ for (const block of blocksOf(msg)) {
11728
11994
  if (block.type === "text" && block.text) {
11729
11995
  emit({ type: "assistant_text", text: this.turnHasText ? `
11730
11996
 
@@ -11732,40 +11998,122 @@ ${block.text}` : block.text });
11732
11998
  this.turnHasText = true;
11733
11999
  } else if (block.type === "thinking" && block.thinking) emit({ type: "thinking", text: block.thinking });
11734
12000
  else if (block.type === "tool_use" && block.name) {
11735
- emit({ type: "tool_call", id: block.id ?? block.name, name: block.name, args: block.input ?? {} });
12001
+ const id = block.id ?? block.name;
12002
+ emit({ type: "tool_call", id, name: block.name, args: block.input ?? {} });
12003
+ if (SUBAGENT_TOOLS.has(block.name) && block.id) this.startSubagent(block.id, block.input ?? {}, emit);
11736
12004
  }
11737
12005
  }
11738
12006
  return;
11739
12007
  case "user":
11740
- for (const block of Array.isArray(msg.message?.content) ? msg.message.content : []) {
12008
+ for (const block of blocksOf(msg)) {
11741
12009
  if (block.type !== "tool_result") continue;
12010
+ const id = block.tool_use_id ?? "";
11742
12011
  const output = flattenContent(block.content);
11743
- if (output.includes(ASYNC_LAUNCH_MARKER)) {
11744
- emit({ type: "background_agent", id: block.tool_use_id ?? "" });
12012
+ const subagent = subagentId ? void 0 : this.openSubagents.get(id);
12013
+ if (!subagentId && output.includes(ASYNC_LAUNCH_MARKER)) {
12014
+ emit({ type: "background_agent", id });
12015
+ if (subagent) subagent.background = true;
12016
+ } else if (subagent) {
12017
+ this.finishForegroundSubagent(id, msg.tool_use_result, output, block.is_error === true, emit);
11745
12018
  }
11746
- emit({
11747
- type: "tool_result",
11748
- id: block.tool_use_id ?? "",
11749
- name: "",
11750
- output,
11751
- success: block.is_error !== true
11752
- });
12019
+ emit({ type: "tool_result", id, name: "", output, success: block.is_error !== true, subagentId });
12020
+ }
12021
+ return;
12022
+ case "system":
12023
+ if (msg.subtype === "task_notification" && msg.tool_use_id && this.openSubagents.get(msg.tool_use_id)?.background) {
12024
+ this.openSubagents.delete(msg.tool_use_id);
12025
+ emit({ type: "subagent_finished", subagentId: msg.tool_use_id, outcome: notificationOutcome(msg.status), digest: msg.summary ?? "" });
11753
12026
  }
11754
12027
  return;
11755
12028
  case "result":
12029
+ this.reportSessionCost(msg, emit);
11756
12030
  if (msg.is_error || msg.subtype && msg.subtype !== "success") {
11757
12031
  emit({ type: "error", message: msg.result?.trim() || `Claude Code ended the turn: ${msg.subtype ?? "error"}` });
11758
12032
  } else {
11759
12033
  emit({ type: "turn_end" });
11760
12034
  }
11761
12035
  return;
11762
- // `system`/`stream_event` carry init metadata and partial deltas. The
11763
- // session id is picked up above; partials are ignored because the
11764
- // complete blocks follow and would otherwise be counted twice.
12036
+ case "stream_event":
12037
+ if (msg.event && !subagentId) this.handleStreamEvent(msg.event, emit);
12038
+ return;
11765
12039
  default:
11766
12040
  return;
11767
12041
  }
11768
12042
  }
12043
+ startSubagent(id, input, emit) {
12044
+ this.openSubagents.set(id, { background: false });
12045
+ const brief = typeof input.description === "string" ? input.description : typeof input.prompt === "string" ? input.prompt : "";
12046
+ emit({ type: "subagent_started", subagentId: id, brief, model: typeof input.model === "string" ? input.model : void 0 });
12047
+ }
12048
+ /**
12049
+ * A subagent's messages do not stream, so each line's usage is the snapshot
12050
+ * taken before generation: the prompt is real, the output a placeholder.
12051
+ * Only the prompt side is reported — an absent output count reads as "not
12052
+ * reported", a placeholder would read as a measurement.
12053
+ */
12054
+ handleSubagentMessage(msg, subagentId, emit) {
12055
+ const messageId = msg.message?.id;
12056
+ if (msg.message?.usage && messageId && !this.countedSubagentMessages.has(messageId)) {
12057
+ this.countedSubagentMessages.add(messageId);
12058
+ emit({ type: "usage", record: usageRecord({ ...msg.message.usage, output_tokens: void 0 }, msg.message.model, subagentId) });
12059
+ }
12060
+ for (const block of blocksOf(msg)) {
12061
+ if (block.type === "thinking" && block.thinking) emit({ type: "thinking", text: block.thinking, subagentId });
12062
+ else if (block.type === "tool_use" && block.name) {
12063
+ emit({ type: "tool_call", id: block.id ?? block.name, name: block.name, args: block.input ?? {}, subagentId });
12064
+ }
12065
+ }
12066
+ }
12067
+ /**
12068
+ * The `Agent` call returned, so the subagent is done. Its last call — the
12069
+ * report — never appears as a line of its own; the result carries its
12070
+ * complete usage instead.
12071
+ */
12072
+ finishForegroundSubagent(id, result, output, failed, emit) {
12073
+ this.openSubagents.delete(id);
12074
+ const finalCall = subagentFinalCall(result);
12075
+ if (finalCall) emit({ type: "usage", record: usageRecord(finalCall.usage, finalCall.model, id) });
12076
+ emit({ type: "subagent_finished", subagentId: id, outcome: failed ? "failed" : "done", digest: output });
12077
+ }
12078
+ /**
12079
+ * Partial output of the planner's own message. The complete `assistant` line
12080
+ * for each block follows its deltas and is authoritative for that block (see
12081
+ * {@link AgentEvent}), so nothing here has to reconcile with it.
12082
+ */
12083
+ handleStreamEvent(event, emit) {
12084
+ if (event.type === "message_start") this.plannerModel = event.message?.model;
12085
+ else if (event.type === "message_delta" && event.usage) emit({ type: "usage", record: usageRecord(event.usage, this.plannerModel) });
12086
+ else if (event.type === "content_block_start" && event.content_block?.type === "text") {
12087
+ this.pendingBreak = this.turnHasText;
12088
+ } else if (event.type === "content_block_delta" && event.delta?.type === "text_delta" && event.delta.text) {
12089
+ if (this.pendingBreak) emit({ type: "assistant_text_delta", text: "\n\n" });
12090
+ this.pendingBreak = false;
12091
+ emit({ type: "assistant_text_delta", text: event.delta.text });
12092
+ } else if (event.type === "content_block_delta" && event.delta?.type === "thinking_delta" && event.delta.thinking) {
12093
+ emit({ type: "thinking_delta", text: event.delta.thinking });
12094
+ }
12095
+ }
12096
+ /**
12097
+ * What only the result line knows: the cost, which covers every call the
12098
+ * session made — subagents included, since none of their lines carries one —
12099
+ * and the planner model's window. `total_cost_usd` is a running total, so a
12100
+ * turn reports its growth. The first turn after a resume only sets the
12101
+ * baseline: its total includes turns counted before, and a turn's own share
12102
+ * cannot be told apart from them.
12103
+ */
12104
+ reportSessionCost(msg, emit) {
12105
+ const record = { source: "claude-code" };
12106
+ const total = msg.total_cost_usd;
12107
+ if (typeof total === "number") {
12108
+ if (this.reportedCostUsd !== void 0 && total > this.reportedCostUsd) {
12109
+ record.reportedCost = { amount: total - this.reportedCostUsd, currency: "USD" };
12110
+ }
12111
+ this.reportedCostUsd = total;
12112
+ }
12113
+ const contextWindow = this.plannerModel ? msg.modelUsage?.[this.plannerModel]?.contextWindow : void 0;
12114
+ if (contextWindow !== void 0) record.contextWindow = contextWindow;
12115
+ if (record.reportedCost || record.contextWindow !== void 0) emit({ type: "usage", record });
12116
+ }
11769
12117
  };
11770
12118
 
11771
12119
  // src/services/harness/codexSandbox.ts
@@ -11827,6 +12175,20 @@ function runProbe(deps, cwd, env, flags) {
11827
12175
 
11828
12176
  // src/services/harness/CodexAdapter.ts
11829
12177
  var HANDSHAKE_TIMEOUT_MS = 3e4;
12178
+ function subagentOutcome(status) {
12179
+ switch (status) {
12180
+ case "completed":
12181
+ return "done";
12182
+ case "errored":
12183
+ case "notFound":
12184
+ return "failed";
12185
+ case "interrupted":
12186
+ case "shutdown":
12187
+ return "stopped";
12188
+ default:
12189
+ return void 0;
12190
+ }
12191
+ }
11830
12192
  function flattenText(value) {
11831
12193
  if (typeof value === "string") return value;
11832
12194
  if (Array.isArray(value)) return value.map(flattenText).filter(Boolean).join("\n");
@@ -11860,6 +12222,8 @@ var ANNOUNCED_REQUESTS = /* @__PURE__ */ new Set([
11860
12222
  var CodexAdapter = class extends StdioAgentAdapter {
11861
12223
  agentId = "codex";
11862
12224
  threadId = null;
12225
+ /** The model Codex opened the thread with — usage records name it. */
12226
+ threadModel = null;
11863
12227
  nextRequestId = 100;
11864
12228
  settleHandshake = null;
11865
12229
  handshakeError = null;
@@ -11869,6 +12233,12 @@ var CodexAdapter = class extends StdioAgentAdapter {
11869
12233
  resumeAttempted = false;
11870
12234
  resumeFallbackSent = false;
11871
12235
  sandbox = "default";
12236
+ /**
12237
+ * Subagent threads this session has spawned, keyed by the child thread id.
12238
+ * Codex runs a subagent in its own thread and replays both threads' events on
12239
+ * one stream; the thread id is what tells them apart (see {@link subagentOf}).
12240
+ */
12241
+ subagents = /* @__PURE__ */ new Map();
11872
12242
  spawnSpec(opts) {
11873
12243
  this.startOpts = opts;
11874
12244
  return { command: "codex", args: ["app-server"] };
@@ -11979,6 +12349,8 @@ ${this.exitMessage()}`);
11979
12349
  }
11980
12350
  this.threadId = thread.id;
11981
12351
  this.sessionId = thread.id;
12352
+ const model = msg.result?.model;
12353
+ this.threadModel = typeof model === "string" ? model : null;
11982
12354
  this.settleHandshake?.(true);
11983
12355
  return;
11984
12356
  }
@@ -11988,13 +12360,40 @@ ${this.exitMessage()}`);
11988
12360
  }
11989
12361
  switch (msg.method) {
11990
12362
  case "item/started":
11991
- this.emitItemStart(msg.params?.item, emit);
12363
+ this.emitItemStart(msg.params?.item, emit, this.subagentOf(msg.params?.threadId));
11992
12364
  return;
11993
12365
  case "item/completed":
11994
- this.emitItemDone(msg.params?.item, emit);
12366
+ this.emitItemDone(msg.params?.item, emit, this.subagentOf(msg.params?.threadId));
12367
+ return;
12368
+ // Reply text streams before its completed item. The completed item is
12369
+ // authoritative — the service replaces the deltas with it — so both are
12370
+ // forwarded and no run is counted twice.
12371
+ case "item/agentMessage/delta": {
12372
+ const params = msg.params;
12373
+ if (!params?.delta || this.subagentOf(params.threadId)) return;
12374
+ emit({ type: "assistant_text_delta", text: params.delta });
12375
+ return;
12376
+ }
12377
+ // `summaryTextDelta` streams a reasoning summary part, `textDelta` the raw
12378
+ // reasoning. Both are thinking; the completed item repeats whichever it
12379
+ // carries and is superseded by what streamed.
12380
+ case "item/reasoning/summaryTextDelta":
12381
+ case "item/reasoning/textDelta": {
12382
+ const params = msg.params;
12383
+ if (!params?.delta) return;
12384
+ const subagentId = this.subagentOf(params.threadId);
12385
+ emit({ type: "thinking_delta", text: params.delta, ...subagentId ? { subagentId } : {} });
12386
+ return;
12387
+ }
12388
+ // Marks where one summary part ends and the next begins. The text arrives
12389
+ // as deltas; the boundary carries none of its own.
12390
+ case "item/reasoning/summaryPartAdded":
12391
+ return;
12392
+ case "thread/tokenUsage/updated":
12393
+ this.emitUsage(msg.params, emit);
11995
12394
  return;
11996
12395
  case "turn/started":
11997
- this.turnHasText = false;
12396
+ if (!this.subagentOf(msg.params?.threadId)) this.turnHasText = false;
11998
12397
  return;
11999
12398
  // Codex reports a setup problem once, at startup, and then plans anyway.
12000
12399
  // Surfacing it is enough: the warning is not always fatal, and refusing to
@@ -12013,12 +12412,14 @@ ${this.exitMessage()}`);
12013
12412
  // rate limit or an exhausted context window would otherwise hang until
12014
12413
  // the process died.
12015
12414
  case "error": {
12415
+ if (this.subagentOf(msg.params?.threadId)) return;
12016
12416
  const failure = msg.params;
12017
12417
  if (failure?.willRetry) return;
12018
12418
  emit({ type: "error", message: failure?.error?.message || "Codex ended the turn with an error." });
12019
12419
  return;
12020
12420
  }
12021
12421
  case "turn/completed": {
12422
+ if (this.subagentOf(msg.params?.threadId)) return;
12022
12423
  const turn = msg.params?.turn;
12023
12424
  if (turn?.status === "failed") {
12024
12425
  emit({ type: "error", message: turn.error?.message || "Codex ended the turn with an error." });
@@ -12027,12 +12428,64 @@ ${this.exitMessage()}`);
12027
12428
  }
12028
12429
  return;
12029
12430
  }
12030
- // Deltas (`item/agentMessage/delta`, `item/reasoning/*Delta`) are skipped:
12031
- // the completed item follows and would otherwise be counted twice.
12431
+ // Deltas for command output and MCP progress are skipped: the completed
12432
+ // item follows and would otherwise be counted twice.
12032
12433
  default:
12033
12434
  return;
12034
12435
  }
12035
12436
  }
12437
+ /**
12438
+ * The child thread id when `threadId` names a subagent's thread, or undefined
12439
+ * for the planner's own. Codex replays both threads on one stream and only
12440
+ * the thread id separates them; the first time a thread is seen it is
12441
+ * registered so its usage and steps can be tagged with it as a subagent id.
12442
+ */
12443
+ subagentOf(threadId) {
12444
+ if (typeof threadId !== "string" || !this.threadId || threadId === this.threadId) return void 0;
12445
+ this.subagents.set(threadId, this.subagents.get(threadId) ?? {});
12446
+ return threadId;
12447
+ }
12448
+ /**
12449
+ * One usage record per model call, from `thread/tokenUsage/updated`.
12450
+ *
12451
+ * The notification carries two breakdowns: `total` is cumulative for the
12452
+ * thread and `last` is the model call that just finished (verified against
12453
+ * the installed binary — a two-call turn ends with `last` equal to the second
12454
+ * call and `total` equal to both summed). Emitting `total` on each update
12455
+ * would count every earlier call again, so `last` is the record. Mapping
12456
+ * `last.outputTokens` already includes `reasoningOutputTokens` — the thread
12457
+ * total is `inputTokens + outputTokens`, not a sum of three — so reasoning is
12458
+ * not added a second time. Codex reports no price.
12459
+ */
12460
+ emitUsage(params, emit) {
12461
+ const last = params?.tokenUsage?.last;
12462
+ if (!last) return;
12463
+ const subagentId = this.subagentOf(params?.threadId);
12464
+ const record = { source: this.agentId };
12465
+ const model = subagentId ? this.subagents.get(subagentId)?.model : this.threadModel ?? this.startOpts?.model;
12466
+ if (model) record.model = model;
12467
+ let hasMeasure = false;
12468
+ if (last.inputTokens !== void 0) {
12469
+ record.inputTokens = last.inputTokens;
12470
+ hasMeasure = true;
12471
+ }
12472
+ if (last.outputTokens !== void 0) {
12473
+ record.outputTokens = last.outputTokens;
12474
+ hasMeasure = true;
12475
+ }
12476
+ if (last.cachedInputTokens !== void 0) {
12477
+ record.cachedInputTokens = last.cachedInputTokens;
12478
+ hasMeasure = true;
12479
+ }
12480
+ if (!hasMeasure) return;
12481
+ if (subagentId) {
12482
+ record.subagentId = subagentId;
12483
+ } else {
12484
+ const window = params?.tokenUsage?.modelContextWindow;
12485
+ if (typeof window === "number" && window > 0) record.contextWindow = window;
12486
+ }
12487
+ emit({ type: "usage", record });
12488
+ }
12036
12489
  /**
12037
12490
  * Refuse one server→client request. Requests whose result schema can express
12038
12491
  * a refusal get that payload; everything else — a permission grant, a
@@ -12064,35 +12517,49 @@ ${this.exitMessage()}`);
12064
12517
  });
12065
12518
  }
12066
12519
  /** A tool item entering `inProgress` — announce the call so the timeline moves. */
12067
- emitItemStart(item, emit) {
12520
+ emitItemStart(item, emit, subagentId) {
12068
12521
  if (!item?.id) return;
12069
12522
  switch (item.type) {
12070
12523
  case "commandExecution":
12071
- emit({ type: "tool_call", id: item.id, name: "shell", args: { command: item.command, cwd: item.cwd } });
12524
+ emit({ type: "tool_call", id: item.id, name: "shell", args: { command: item.command, cwd: item.cwd }, ...subagentId ? { subagentId } : {} });
12072
12525
  return;
12073
12526
  case "mcpToolCall":
12074
- emit({ type: "tool_call", id: item.id, name: item.tool ?? "mcp_tool", args: item.arguments ?? {} });
12527
+ emit({ type: "tool_call", id: item.id, name: item.tool ?? "mcp_tool", args: item.arguments ?? {}, ...subagentId ? { subagentId } : {} });
12075
12528
  return;
12076
12529
  case "dynamicToolCall":
12077
- emit({ type: "tool_call", id: item.id, name: item.tool ?? "tool", args: item.arguments ?? {} });
12530
+ emit({ type: "tool_call", id: item.id, name: item.tool ?? "tool", args: item.arguments ?? {}, ...subagentId ? { subagentId } : {} });
12078
12531
  return;
12079
12532
  case "webSearch":
12080
- emit({ type: "tool_call", id: item.id, name: "web_search", args: { query: item.query } });
12533
+ emit({ type: "tool_call", id: item.id, name: "web_search", args: { query: item.query }, ...subagentId ? { subagentId } : {} });
12534
+ return;
12535
+ // Delegation is a tool call like any other: the planner's own call is
12536
+ // unparented, and shows in the timeline as the agent tool it really is.
12537
+ case "collabAgentToolCall":
12538
+ emit({
12539
+ type: "tool_call",
12540
+ id: item.id,
12541
+ name: item.tool ?? "collab",
12542
+ args: { prompt: item.prompt, model: item.model, receiverThreadIds: item.receiverThreadIds }
12543
+ });
12081
12544
  return;
12082
12545
  default:
12083
12546
  return;
12084
12547
  }
12085
12548
  }
12086
- emitItemDone(item, emit) {
12549
+ emitItemDone(item, emit, subagentId) {
12087
12550
  if (!item?.type) return;
12088
12551
  const id = item.id ?? "";
12552
+ if (item.type === "collabAgentToolCall") {
12553
+ this.emitCollabItem(item, id, emit);
12554
+ return;
12555
+ }
12089
12556
  switch (item.type) {
12090
12557
  // A Codex turn is several whole messages — progress commentary, then the
12091
12558
  // final answer — not a token stream. Concatenated raw they run together
12092
12559
  // ("…as requested.`head` failed because…"), so each one after the first
12093
12560
  // opens a paragraph.
12094
12561
  case "agentMessage":
12095
- if (!item.text) return;
12562
+ if (subagentId || !item.text) return;
12096
12563
  emit({ type: "assistant_text", text: this.turnHasText ? `
12097
12564
 
12098
12565
  ${item.text}` : item.text });
@@ -12100,7 +12567,7 @@ ${item.text}` : item.text });
12100
12567
  return;
12101
12568
  case "reasoning": {
12102
12569
  const text = flattenText(item.summary) || flattenText(item.content) || item.text || "";
12103
- if (text.trim()) emit({ type: "thinking", text });
12570
+ if (text.trim()) emit({ type: "thinking", text, ...subagentId ? { subagentId } : {} });
12104
12571
  return;
12105
12572
  }
12106
12573
  case "commandExecution":
@@ -12109,7 +12576,8 @@ ${item.text}` : item.text });
12109
12576
  id,
12110
12577
  name: "shell",
12111
12578
  output: item.aggregatedOutput ?? "",
12112
- success: (item.exitCode ?? 0) === 0
12579
+ success: (item.exitCode ?? 0) === 0,
12580
+ ...subagentId ? { subagentId } : {}
12113
12581
  });
12114
12582
  return;
12115
12583
  case "mcpToolCall":
@@ -12119,23 +12587,60 @@ ${item.text}` : item.text });
12119
12587
  id,
12120
12588
  name: item.tool ?? "tool",
12121
12589
  output: item.error ?? flattenText(item.result) ?? "",
12122
- success: item.status !== "error" && item.success !== false && !item.error
12590
+ success: item.status !== "error" && item.success !== false && !item.error,
12591
+ ...subagentId ? { subagentId } : {}
12123
12592
  });
12124
12593
  return;
12125
12594
  // The query is empty when the search starts and filled when it lands, so
12126
12595
  // the result — not the call — is what carries what was actually searched.
12127
12596
  case "webSearch":
12128
- emit({ type: "tool_result", id, name: "web_search", output: item.query ?? "", success: true });
12597
+ emit({ type: "tool_result", id, name: "web_search", output: item.query ?? "", success: true, ...subagentId ? { subagentId } : {} });
12129
12598
  return;
12130
12599
  // `fileChange` can only appear if the read-only sandbox was bypassed;
12131
12600
  // reporting it keeps that visible rather than silent.
12132
12601
  case "fileChange":
12133
- emit({ type: "tool_result", id, name: "file_change", output: JSON.stringify(item), success: false });
12602
+ emit({ type: "tool_result", id, name: "file_change", output: JSON.stringify(item), success: false, ...subagentId ? { subagentId } : {} });
12134
12603
  return;
12135
12604
  default:
12136
12605
  return;
12137
12606
  }
12138
12607
  }
12608
+ /**
12609
+ * A `collabAgentToolCall` — the planner spawning, waiting on or messaging a
12610
+ * subagent. The call itself is planner-level tool activity; the lifecycle it
12611
+ * carries becomes `subagent_started` / `subagent_finished`, restated as
12612
+ * often as Codex restates it — the service reports each once. Codex tags
12613
+ * every collab item with the parent thread, so this one never runs for a
12614
+ * subagent.
12615
+ */
12616
+ emitCollabItem(item, id, emit) {
12617
+ if (item.tool === "spawnAgent") {
12618
+ for (const child of item.receiverThreadIds ?? []) {
12619
+ const model = item.model || void 0;
12620
+ this.subagents.set(child, { model });
12621
+ emit({ type: "subagent_started", subagentId: child, brief: item.prompt ?? "", ...model ? { model } : {} });
12622
+ }
12623
+ }
12624
+ for (const [child, state] of Object.entries(item.agentsStates ?? {})) {
12625
+ const outcome = subagentOutcome(state?.status);
12626
+ if (!outcome) continue;
12627
+ this.subagents.set(child, this.subagents.get(child) ?? {});
12628
+ emit({ type: "subagent_finished", subagentId: child, outcome, digest: state?.message ?? "" });
12629
+ }
12630
+ emit({
12631
+ type: "tool_result",
12632
+ id,
12633
+ name: item.tool ?? "collab",
12634
+ output: this.collabSummary(item),
12635
+ success: item.status !== "failed" && item.status !== "interrupted"
12636
+ });
12637
+ }
12638
+ /** The readable result of a collab call: the brief it sent, or what came back. */
12639
+ collabSummary(item) {
12640
+ const agents = Object.values(item.agentsStates ?? {}).map((state) => state?.message).filter((message) => !!message);
12641
+ if (agents.length) return agents.join("\n");
12642
+ return item.prompt ?? "";
12643
+ }
12139
12644
  };
12140
12645
 
12141
12646
  // src/services/harness/OpenCodeAdapter.ts
@@ -12173,6 +12678,30 @@ function describeError(err) {
12173
12678
  }
12174
12679
  return lines.join(": ");
12175
12680
  }
12681
+ function flatModelId(providerID, modelID) {
12682
+ return providerID && modelID ? `${providerID}/${modelID}` : void 0;
12683
+ }
12684
+ function usageRecord2(info, subagentId) {
12685
+ const tokens = info.tokens;
12686
+ if (!tokens) return null;
12687
+ const prompt = partedPromptUsage({ uncached: tokens.input, cacheRead: tokens.cache?.read, cacheWrite: tokens.cache?.write });
12688
+ const outputTokens = (tokens.output ?? 0) + (tokens.reasoning ?? 0);
12689
+ if ((prompt.inputTokens ?? 0) + outputTokens === 0) return null;
12690
+ const record = { source: "opencode", ...prompt, outputTokens };
12691
+ const model = flatModelId(info.providerID, info.modelID);
12692
+ if (model) record.model = model;
12693
+ if (typeof info.cost === "number" && info.cost > 0) record.reportedCost = { amount: info.cost, currency: "USD" };
12694
+ if (subagentId) record.subagentId = subagentId;
12695
+ return record;
12696
+ }
12697
+ function taskDigest(output) {
12698
+ const inner = output.match(/<task_result>\n?([\s\S]*?)\n?<\/task_result>/);
12699
+ return inner ? inner[1] : output;
12700
+ }
12701
+ function unwrapFileToolOutput(output) {
12702
+ const inner = output.match(/<content>\n?([\s\S]*?)\n?<\/content>/);
12703
+ return inner ? inner[1] : output;
12704
+ }
12176
12705
  function delay(ms, signal) {
12177
12706
  return new Promise((resolve7) => {
12178
12707
  const timer = setTimeout(resolve7, ms);
@@ -12268,7 +12797,14 @@ ${this.stderrTail.trim()}` : ""}`);
12268
12797
  onEvent({ type: "error", message: this.exitMessage() });
12269
12798
  return;
12270
12799
  }
12271
- const seen = /* @__PURE__ */ new Set();
12800
+ const turn = {
12801
+ seen: /* @__PURE__ */ new Set(),
12802
+ assistantMessages: /* @__PURE__ */ new Set(),
12803
+ partTypes: /* @__PURE__ */ new Map(),
12804
+ textRuns: /* @__PURE__ */ new Map(),
12805
+ children: /* @__PURE__ */ new Map(),
12806
+ heldFrames: /* @__PURE__ */ new Map()
12807
+ };
12272
12808
  this.turnHasText = false;
12273
12809
  const streamAbort = new AbortController();
12274
12810
  let connected = () => {
@@ -12276,15 +12812,7 @@ ${this.stderrTail.trim()}` : ""}`);
12276
12812
  const streamReady = new Promise((resolve7) => {
12277
12813
  connected = resolve7;
12278
12814
  });
12279
- const live = this.streamEvents(
12280
- streamAbort.signal,
12281
- (part) => {
12282
- if (part.type === "tool") this.emitPart(part, seen, onEvent);
12283
- },
12284
- (ask) => this.denyPermission(ask, seen, onEvent),
12285
- connected,
12286
- onActivity
12287
- );
12815
+ const live = this.streamEvents(streamAbort.signal, (frame) => this.onFrame(frame, turn, onEvent), connected, onActivity);
12288
12816
  await Promise.race([streamReady, new Promise((r) => {
12289
12817
  const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS);
12290
12818
  t.unref?.();
@@ -12305,7 +12833,7 @@ ${this.stderrTail.trim()}` : ""}`);
12305
12833
  this.dispose();
12306
12834
  return;
12307
12835
  }
12308
- this.settle(reply, seen, onEvent);
12836
+ this.settle(reply, turn, onEvent);
12309
12837
  } catch (err) {
12310
12838
  if (signal?.aborted) {
12311
12839
  this.dispose();
@@ -12313,7 +12841,7 @@ ${this.stderrTail.trim()}` : ""}`);
12313
12841
  }
12314
12842
  const recovered = this.exited ? null : await this.recoverReply(signal, onActivity);
12315
12843
  if (recovered) {
12316
- this.settle(recovered, seen, onEvent);
12844
+ this.settle(recovered, turn, onEvent);
12317
12845
  return;
12318
12846
  }
12319
12847
  onEvent({ type: "error", message: `The OpenCode planner turn failed: ${describeError(err)}` });
@@ -12326,11 +12854,16 @@ ${this.stderrTail.trim()}` : ""}`);
12326
12854
  /**
12327
12855
  * Turn one settled assistant message into events. The settled response is
12328
12856
  * authoritative: it names the assistant message, so its parts are the ones
12329
- * that make up the reply. Tool parts already seen live are deduplicated by
12330
- * call id; anything the stream missed (including a stream that never
12331
- * connected) arrives here.
12857
+ * that make up the reply. Parts already completed live are deduplicated;
12858
+ * anything the stream missed (including a stream that never connected)
12859
+ * arrives here.
12860
+ *
12861
+ * It is only the turn's *last* message, though. OpenCode writes one
12862
+ * assistant message per model call, so the calls before the final one —
12863
+ * their text, reasoning and usage — reach Ordewell over the stream or not at
12864
+ * all.
12332
12865
  */
12333
- settle(reply, seen, onEvent) {
12866
+ settle(reply, turn, onEvent) {
12334
12867
  const failure = typeof reply?.error === "string" ? reply.error : reply?.error?.message ?? reply?.info?.error?.data?.message;
12335
12868
  if (failure) {
12336
12869
  onEvent({ type: "error", message: failure });
@@ -12339,8 +12872,9 @@ ${this.stderrTail.trim()}` : ""}`);
12339
12872
  const assistantId = reply?.info?.id;
12340
12873
  for (const part of reply?.parts ?? []) {
12341
12874
  if (part.type !== "tool" && assistantId && part.messageID !== assistantId) continue;
12342
- this.emitPart(part, seen, onEvent);
12875
+ this.emitPart(part, turn, onEvent);
12343
12876
  }
12877
+ if (reply?.info) this.countUsage(reply.info, turn, onEvent);
12344
12878
  if (assistantId) this.lastAssistantId = assistantId;
12345
12879
  onEvent({ type: "turn_end" });
12346
12880
  }
@@ -12376,26 +12910,29 @@ ${this.stderrTail.trim()}` : ""}`);
12376
12910
  return null;
12377
12911
  }
12378
12912
  /**
12379
- * Emit one message part, once. OpenCode reports a tool part repeatedly as it
12380
- * moves through pending → running → completed, so parts are keyed by id and
12381
- * only the terminal state produces a result.
12913
+ * Emit one complete message part, once. OpenCode reports a tool part
12914
+ * repeatedly as it moves through pending → running → completed, so parts are
12915
+ * keyed by id and only the terminal state produces a result. A subagent's
12916
+ * text is its report to the planner, not the reply, so it is dropped; the
12917
+ * `task` call's result carries it.
12382
12918
  */
12383
- emitPart(part, seen, onEvent) {
12919
+ emitPart(part, turn, onEvent, subagentId) {
12384
12920
  if (!part?.type) return;
12921
+ const { seen } = turn;
12385
12922
  const id = part.id ?? part.callID ?? "";
12386
- if (part.type === "text" && part.text) {
12923
+ if (part.type === "text" && part.text && !subagentId) {
12387
12924
  if (seen.has(`text:${id}`)) return;
12388
12925
  seen.add(`text:${id}`);
12389
- onEvent({ type: "assistant_text", text: this.turnHasText ? `
12390
-
12391
- ${part.text}` : part.text });
12926
+ if (!part.text.trim()) return;
12927
+ const lead = turn.textRuns.get(id)?.lead ?? (this.turnHasText ? "\n\n" : "");
12928
+ onEvent({ type: "assistant_text", text: `${lead}${part.text}` });
12392
12929
  this.turnHasText = true;
12393
12930
  return;
12394
12931
  }
12395
12932
  if (part.type === "reasoning" && part.text) {
12396
12933
  if (seen.has(`reasoning:${id}`)) return;
12397
12934
  seen.add(`reasoning:${id}`);
12398
- onEvent({ type: "thinking", text: part.text });
12935
+ onEvent({ type: "thinking", text: part.text, subagentId });
12399
12936
  return;
12400
12937
  }
12401
12938
  if (part.type !== "tool") return;
@@ -12405,19 +12942,131 @@ ${part.text}` : part.text });
12405
12942
  const input = part.state?.input;
12406
12943
  if (!seen.has(`call:${callId}`) && (status !== "pending" || input && Object.keys(input).length > 0)) {
12407
12944
  seen.add(`call:${callId}`);
12408
- onEvent({ type: "tool_call", id: callId, name, args: input ?? {} });
12945
+ onEvent({ type: "tool_call", id: callId, name, args: input ?? {}, subagentId });
12409
12946
  }
12947
+ if (name === "task" && !subagentId) this.trackSubagent(part, callId, turn, onEvent);
12410
12948
  if ((status === "completed" || status === "error") && !seen.has(`result:${callId}`)) {
12411
12949
  seen.add(`result:${callId}`);
12412
12950
  onEvent({
12413
12951
  type: "tool_result",
12414
12952
  id: callId,
12415
12953
  name,
12416
- output: part.state?.output ?? part.state?.error ?? "",
12417
- success: status === "completed"
12954
+ output: unwrapFileToolOutput(part.state?.output ?? part.state?.error ?? ""),
12955
+ success: status === "completed",
12956
+ subagentId
12418
12957
  });
12419
12958
  }
12420
12959
  }
12960
+ /**
12961
+ * A `task` call runs a subagent in a child session. The call's part names
12962
+ * that session once it exists, which is what ties the child's frames to the
12963
+ * call; the subagent ends when the call does. The part is restated at every
12964
+ * status change, and so is what it says here — the service reports each
12965
+ * start and finish once, and no finish for a call that never had a child.
12966
+ */
12967
+ trackSubagent(part, callId, turn, onEvent) {
12968
+ const state = part.state;
12969
+ const child = state?.metadata?.sessionId;
12970
+ if (child && !turn.children.get(child)) {
12971
+ const input = state?.input ?? {};
12972
+ const brief = typeof input.description === "string" ? input.description : typeof input.prompt === "string" ? input.prompt : "";
12973
+ const model = flatModelId(state?.metadata?.model?.providerID, state?.metadata?.model?.modelID);
12974
+ onEvent({ type: "subagent_started", subagentId: callId, brief, ...model ? { model } : {} });
12975
+ turn.children.set(child, callId);
12976
+ const held = turn.heldFrames.get(child) ?? [];
12977
+ turn.heldFrames.delete(child);
12978
+ for (const frame of held) this.onFrame(frame, turn, onEvent);
12979
+ }
12980
+ const status = state?.status;
12981
+ if (status === "completed" || status === "error") {
12982
+ onEvent({
12983
+ type: "subagent_finished",
12984
+ subagentId: callId,
12985
+ outcome: status === "completed" ? "done" : "failed",
12986
+ digest: taskDigest(state?.output ?? state?.error ?? "")
12987
+ });
12988
+ }
12989
+ }
12990
+ /**
12991
+ * One message's usage, once, when it has completed. Every assistant message
12992
+ * is one model call; until it completes its counts are zeros.
12993
+ */
12994
+ countUsage(info, turn, onEvent, subagentId) {
12995
+ if (info.role !== "assistant" || !info.id || !info.time?.completed || turn.seen.has(`usage:${info.id}`)) return;
12996
+ turn.seen.add(`usage:${info.id}`);
12997
+ const record = usageRecord2(info, subagentId);
12998
+ if (record) onEvent({ type: "usage", record });
12999
+ }
13000
+ /**
13001
+ * One `/event` frame. Only the planner's session and its children are
13002
+ * followed: the server's stream is global, and another client's session is
13003
+ * none of this turn's business.
13004
+ */
13005
+ onFrame(frame, turn, onEvent) {
13006
+ const props = frame.properties;
13007
+ if (!props) return;
13008
+ if (frame.type === "session.created") {
13009
+ if (props.info?.parentID === this.sessionId && props.info.id && !turn.children.has(props.info.id)) turn.children.set(props.info.id, null);
13010
+ return;
13011
+ }
13012
+ const session = props.sessionID;
13013
+ if (session && session !== this.sessionId && !turn.children.has(session)) return;
13014
+ if (frame.type === "permission.asked" || frame.type === "permission.v2.asked") {
13015
+ this.denyPermission(props, turn.seen, onEvent);
13016
+ return;
13017
+ }
13018
+ let subagentId;
13019
+ if (session && session !== this.sessionId) {
13020
+ const owner = turn.children.get(session);
13021
+ if (!owner) {
13022
+ turn.heldFrames.set(session, [...turn.heldFrames.get(session) ?? [], frame]);
13023
+ return;
13024
+ }
13025
+ subagentId = owner;
13026
+ }
13027
+ if (frame.type === "message.updated" && props.info) {
13028
+ if (props.info.role === "assistant" && props.info.id) turn.assistantMessages.add(props.info.id);
13029
+ this.countUsage(props.info, turn, onEvent, subagentId);
13030
+ return;
13031
+ }
13032
+ if (frame.type === "message.part.delta") {
13033
+ if (props.field !== "text" || !props.partID || !props.delta) return;
13034
+ if (!props.messageID || !turn.assistantMessages.has(props.messageID)) return;
13035
+ const type = turn.partTypes.get(props.partID);
13036
+ if (type === "reasoning") onEvent({ type: "thinking_delta", text: props.delta, subagentId });
13037
+ else if (type === "text" && !subagentId) this.onTextDelta(props.partID, props.delta, turn, onEvent);
13038
+ return;
13039
+ }
13040
+ const part = props.part;
13041
+ if (!part) return;
13042
+ if (part.type === "tool") {
13043
+ this.emitPart(part, turn, onEvent, subagentId);
13044
+ return;
13045
+ }
13046
+ if (!part.id || !part.messageID || !turn.assistantMessages.has(part.messageID)) return;
13047
+ if (part.type === "text" || part.type === "reasoning") turn.partTypes.set(part.id, part.type);
13048
+ if (part.time?.end) this.emitPart(part, turn, onEvent, subagentId);
13049
+ }
13050
+ /**
13051
+ * Stream one piece of a reply text part. The part's paragraph break goes out
13052
+ * with its first visible delta, so the deltas add up to exactly the text the
13053
+ * completed part then re-sends; a part that is only whitespace so far is
13054
+ * held back, for the reason {@link emitPart} drops one.
13055
+ */
13056
+ onTextDelta(partId, delta, turn, onEvent) {
13057
+ if (turn.seen.has(`text:${partId}`)) return;
13058
+ const run = turn.textRuns.get(partId) ?? { held: "", lead: null };
13059
+ turn.textRuns.set(partId, run);
13060
+ if (run.lead !== null) {
13061
+ onEvent({ type: "assistant_text_delta", text: delta });
13062
+ return;
13063
+ }
13064
+ run.held += delta;
13065
+ if (!run.held.trim()) return;
13066
+ run.lead = this.turnHasText ? "\n\n" : "";
13067
+ this.turnHasText = true;
13068
+ onEvent({ type: "assistant_text_delta", text: `${run.lead}${run.held}` });
13069
+ }
12421
13070
  /**
12422
13071
  * Deny one permission request (T1). OpenCode blocks the turn until the
12423
13072
  * request is answered, so this must answer — `reject` rather than a silent
@@ -12441,11 +13090,11 @@ ${part.text}` : part.text });
12441
13090
  });
12442
13091
  }
12443
13092
  /**
12444
- * Server-sent events from `/event`. Tool activity on it is liveness only —
12445
- * the settled POST repeats it — but permission requests arrive nowhere else,
13093
+ * Server-sent events from `/event`: the turn's live text, reasoning, tool
13094
+ * activity and usage, and the only channel permission requests arrive on —
12446
13095
  * so the stream is load-bearing for {@link denyPermission}.
12447
13096
  */
12448
- async streamEvents(signal, onPart, onPermission, onConnected, onActivity) {
13097
+ async streamEvents(signal, onFrame, onConnected, onActivity) {
12449
13098
  const response = await this.deps.fetch(`${this.baseUrl}/event`, { signal }).catch(() => null);
12450
13099
  const body = response?.body;
12451
13100
  if (!body) {
@@ -12467,12 +13116,7 @@ ${part.text}` : part.text });
12467
13116
  buffer = buffer.slice(newline + 1);
12468
13117
  if (!line.startsWith("data:")) continue;
12469
13118
  try {
12470
- const event = JSON.parse(line.slice(5).trim());
12471
- const props = event.properties;
12472
- if (!props) continue;
12473
- if (props.sessionID && props.sessionID !== this.sessionId) continue;
12474
- if (event.type === "permission.asked" || event.type === "permission.v2.asked") onPermission(props);
12475
- else if (props.part) onPart(props.part);
13119
+ onFrame(JSON.parse(line.slice(5).trim()));
12476
13120
  } catch {
12477
13121
  }
12478
13122
  }
@@ -12556,8 +13200,16 @@ function normalizeAgentArgs(tool, args) {
12556
13200
  }
12557
13201
 
12558
13202
  // src/services/harness/CliAgentAiService.ts
12559
- var MAX_JSON_REPAIRS = 2;
12560
13203
  var MAX_AGENT_WAITS = 2;
13204
+ function waitForAgentsPrompt(running) {
13205
+ return [
13206
+ `You ended your turn with ${running} subagent(s) still running in the background.`,
13207
+ "Ordewell hands the conversation back to the user when your turn ends, so anything you say after it never reaches them \u2014",
13208
+ "the results you promised to report would be lost.",
13209
+ "Wait for those agents to finish NOW, in this reply, and do not end your turn until you have their results.",
13210
+ "Then give the user your synthesis. In future replies, await your agents inside the turn rather than backgrounding them."
13211
+ ].join(" ");
13212
+ }
12561
13213
  var LOG_MAX_CHARS = 1e4;
12562
13214
  function defaultAdapter(runner, deps) {
12563
13215
  switch (runner) {
@@ -12602,6 +13254,12 @@ var CliAgentAiService = class {
12602
13254
  lastNativeSessionId = null;
12603
13255
  conversation = null;
12604
13256
  activeAbort = null;
13257
+ /**
13258
+ * Every subagent this conversation has reported starting, and finishing.
13259
+ * Agents restate a subagent's state as it changes, and one can finish a turn
13260
+ * or a process restart after it started; surfaces get each once, in order.
13261
+ */
13262
+ subagents = { started: /* @__PURE__ */ new Set(), finished: /* @__PURE__ */ new Set() };
12605
13263
  hasActiveConversation() {
12606
13264
  return this.conversation !== null;
12607
13265
  }
@@ -12628,6 +13286,8 @@ var CliAgentAiService = class {
12628
13286
  this.adapter = null;
12629
13287
  this.lastNativeSessionId = null;
12630
13288
  this.conversation = null;
13289
+ this.subagents.started.clear();
13290
+ this.subagents.finished.clear();
12631
13291
  }
12632
13292
  // --- Conversation (ADR-0002) ---
12633
13293
  async startConversation(req) {
@@ -12680,98 +13340,50 @@ var CliAgentAiService = class {
12680
13340
  ].join("\n");
12681
13341
  }
12682
13342
  /**
12683
- * Drive one user message to a settled planner turn. Same shape as the API
12684
- * backend's conversation loop minus the tool rounds — those belong to the
12685
- * agent now — and with the same two policies layered on top: the
12686
- * empty-reply nudge, and a bounded corrective re-emit for botched JSON.
13343
+ * Drive one user message to a settled planner turn through
13344
+ * {@link settleReply}, the loop the API backend settles through too. The
13345
+ * tool rounds belong to the agent now, so one call is one agent turn —
13346
+ * continued while it left subagents running in the background.
12687
13347
  */
12688
13348
  async runConversation(message, onProgress, signal) {
12689
13349
  const conversation = this.conversation;
12690
- const combined = this.startAbortScope(signal);
12691
- const researchLog = [];
12692
- let pending = message;
12693
- let emptyNudgeSent = false;
12694
- let jsonRepairAttempts = 0;
13350
+ this.activeAbort = abortScope(signal);
13351
+ const combined = this.activeAbort.signal;
12695
13352
  let agentWaits = 0;
12696
13353
  const carried = [];
12697
- const replyText = (text) => [...carried, text].filter((part) => part.trim()).join("\n\n");
12698
- try {
12699
- for (; ; ) {
12700
- const turn = await this.runTurn(pending, onProgress, combined);
13354
+ const send = async (text) => {
13355
+ let turn = await this.runTurn(text, onProgress, combined);
13356
+ const researchLog = [...turn.researchLog];
13357
+ while (turn.backgroundAgents > 0 && agentWaits < MAX_AGENT_WAITS && turn.text.trim() && !turn.error && !turn.aborted && !combined?.aborted) {
13358
+ agentWaits++;
13359
+ carried.push(turn.text);
13360
+ turn = await this.runTurn(waitForAgentsPrompt(turn.backgroundAgents), onProgress, combined);
12701
13361
  researchLog.push(...turn.researchLog);
12702
- if (turn.aborted || combined?.aborted) {
12703
- onProgress({ type: "interrupted" });
12704
- return { kind: "message", text: replyText(turn.text), researchLog };
12705
- }
12706
- if (turn.error) {
12707
- return { kind: "message", text: turn.error, researchLog };
12708
- }
12709
- if (!turn.text.trim()) {
12710
- const refused2 = turn.researchLog.find((step) => step.outcome === "denied");
12711
- if (!emptyNudgeSent) {
12712
- emptyNudgeSent = true;
12713
- pending = refused2 ? `Your last reply was empty because "${refused2.toolLabel ?? refused2.tool}" was refused: you are planning read-only and confined to this workspace. Do not retry it. Answer the user now with what you already know, or ask your next question.` : "Your last reply was empty. Respond to the user now: answer their last message directly, ask your next question, or emit the plan JSON.";
12714
- continue;
12715
- }
12716
- return {
12717
- kind: "message",
12718
- text: refused2 ? `The planner stopped without replying: "${refused2.toolLabel ?? refused2.tool}" was refused because planning is read-only and confined to this workspace.` : "The planner returned an empty reply twice. Please rephrase or try again.",
12719
- researchLog
12720
- };
12721
- }
12722
- if (turn.backgroundAgents > 0 && agentWaits < MAX_AGENT_WAITS && !combined?.aborted) {
12723
- agentWaits++;
12724
- carried.push(turn.text);
12725
- pending = [
12726
- `You ended your turn with ${turn.backgroundAgents} subagent(s) still running in the background.`,
12727
- "Ordewell hands the conversation back to the user when your turn ends, so anything you say after it never reaches them \u2014",
12728
- "the results you promised to report would be lost.",
12729
- "Wait for those agents to finish NOW, in this reply, and do not end your turn until you have their results.",
12730
- "Then give the user your synthesis. In future replies, await your agents inside the turn rather than backgrounding them."
12731
- ].join(" ");
12732
- continue;
12733
- }
12734
- const reply = classifyPlannerReply(turn.text, {
12735
- runners: conversation.runners,
12736
- runnerModes: conversation.runnerModes,
12737
- autonomousDefault: conversation.autonomousDefault
12738
- });
12739
- switch (reply.kind) {
12740
- case "task_ops":
12741
- return { kind: "task_ops", ops: reply.ops, text: replyText(turn.text), researchLog };
12742
- // The read channel is a text envelope precisely so it reaches here
12743
- // too: a harness planner has no Ordewell tool loop to call into.
12744
- case "task_query":
12745
- return { kind: "task_query", query: reply.query, text: replyText(turn.text), researchLog };
12746
- case "plan":
12747
- this.conversation = null;
12748
- return { kind: "plan", tasks: reply.tasks, text: replyText(turn.text), researchLog };
12749
- case "broken_task_ops":
12750
- if (jsonRepairAttempts < MAX_JSON_REPAIRS && !combined?.aborted) {
12751
- jsonRepairAttempts++;
12752
- pending = reEmitTaskOpsPrompt(reply.error.message);
12753
- continue;
12754
- }
12755
- break;
12756
- case "broken_task_query":
12757
- if (jsonRepairAttempts < MAX_JSON_REPAIRS && !combined?.aborted) {
12758
- jsonRepairAttempts++;
12759
- pending = reEmitTaskQueryPrompt(reply.error.message);
12760
- continue;
12761
- }
12762
- break;
12763
- case "broken_plan":
12764
- if (jsonRepairAttempts < MAX_JSON_REPAIRS && !combined?.aborted) {
12765
- jsonRepairAttempts++;
12766
- pending = reEmitPlanPrompt(reply.error.message);
12767
- continue;
12768
- }
12769
- break;
12770
- case "prose":
12771
- break;
12772
- }
12773
- return { kind: "message", text: replyText(turn.text), researchLog };
12774
13362
  }
13363
+ return {
13364
+ text: turn.text,
13365
+ researchLog,
13366
+ aborted: turn.aborted || combined?.aborted,
13367
+ // Fail visibly, per the repo's fail-safe contract: an agent that died,
13368
+ // hit its rate limit, or lost its login must say so in the chat rather
13369
+ // than leave an empty planner bubble.
13370
+ failure: turn.error,
13371
+ fullText: [...carried, turn.text].filter((part) => part.trim()).join("\n\n")
13372
+ };
13373
+ };
13374
+ try {
13375
+ const turn = await settleReply({
13376
+ message,
13377
+ send,
13378
+ classify: { runners: conversation.runners, runnerModes: conversation.runnerModes, autonomousDefault: conversation.autonomousDefault },
13379
+ onProgress,
13380
+ signal: combined,
13381
+ // `runTurn` folds every run of text the agent's turn produced into its
13382
+ // reply, a tool call's earlier segment included.
13383
+ replyJoinsSegments: true
13384
+ });
13385
+ if (turn.kind === "plan") this.conversation = null;
13386
+ return turn;
12775
13387
  } finally {
12776
13388
  this.activeAbort = null;
12777
13389
  }
@@ -12788,15 +13400,25 @@ var CliAgentAiService = class {
12788
13400
  const pendingCalls = /* @__PURE__ */ new Map();
12789
13401
  const researchLog = [];
12790
13402
  let text = "";
13403
+ let streamedRun = "";
13404
+ let segmentId = uuidv44();
13405
+ const commitRun = () => {
13406
+ text += streamedRun;
13407
+ streamedRun = "";
13408
+ segmentId = uuidv44();
13409
+ };
13410
+ const streamedThinking = /* @__PURE__ */ new Set();
13411
+ const subagentUsage = /* @__PURE__ */ new Map();
12791
13412
  let error;
12792
13413
  let stepIndex = 0;
12793
13414
  let backgroundAgents = 0;
12794
13415
  const truncate = (value) => value.length > LOG_MAX_CHARS ? `${value.slice(0, LOG_MAX_CHARS)}
12795
13416
  [... truncated, total ${value.length} chars]` : value;
12796
- const settle = (id, rawOutput, success, outcome) => {
13417
+ const settle = (id, rawOutput, success, outcome, reportedBy) => {
12797
13418
  const output = redactSecrets(rawOutput);
12798
13419
  const call = pendingCalls.get(id);
12799
13420
  pendingCalls.delete(id);
13421
+ const subagentId = call?.subagentId ?? reportedBy;
12800
13422
  const step = {
12801
13423
  id: `rs-${Date.now()}-${stepIndex++}`,
12802
13424
  tool: call?.tool ?? "agent_tool",
@@ -12806,29 +13428,63 @@ var CliAgentAiService = class {
12806
13428
  success,
12807
13429
  outcome,
12808
13430
  toolCallId: id,
13431
+ subagentId,
12809
13432
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
12810
13433
  };
12811
13434
  researchLog.push(step);
12812
- onProgress({ type: "tool_result", toolResult: output, step, toolCallId: id });
13435
+ onProgress({ type: "tool_result", toolResult: output, step, toolCallId: id, subagentId });
12813
13436
  };
12814
13437
  await adapter.send(message, (event) => {
12815
13438
  switch (event.type) {
13439
+ case "assistant_text_delta":
13440
+ streamedRun += event.text;
13441
+ onProgress({ type: "text_delta", text: event.text, segmentId });
13442
+ return;
12816
13443
  case "assistant_text":
12817
13444
  text += event.text;
12818
- onProgress({ type: "plan_token", planToken: event.text });
13445
+ if (streamedRun) streamedRun = "";
13446
+ else onProgress({ type: "text_delta", text: event.text, segmentId });
13447
+ return;
13448
+ case "thinking_delta":
13449
+ streamedThinking.add(event.subagentId ?? "");
13450
+ onProgress({ type: "thinking", text: event.text, subagentId: event.subagentId });
12819
13451
  return;
12820
13452
  case "thinking":
12821
- onProgress({ type: "thinking", text: event.text });
13453
+ if (!streamedThinking.delete(event.subagentId ?? "")) onProgress({ type: "thinking", text: event.text, subagentId: event.subagentId });
12822
13454
  return;
12823
13455
  case "tool_call": {
13456
+ if (!event.subagentId) commitRun();
12824
13457
  const mapped = mapAgentTool(event.name);
12825
13458
  const args = JSON.stringify(normalizeAgentArgs(mapped.tool, event.args));
12826
- pendingCalls.set(event.id, { tool: mapped.tool, toolLabel: mapped.toolLabel, args });
12827
- onProgress({ type: "tool_call", tool: mapped.tool, toolLabel: mapped.toolLabel, toolArgs: args, toolCallId: event.id });
13459
+ const subagentId = event.subagentId;
13460
+ pendingCalls.set(event.id, { tool: mapped.tool, toolLabel: mapped.toolLabel, args, subagentId });
13461
+ onProgress({ type: "tool_call", tool: mapped.tool, toolLabel: mapped.toolLabel, toolArgs: args, toolCallId: event.id, subagentId });
12828
13462
  return;
12829
13463
  }
12830
13464
  case "tool_result":
12831
- settle(event.id, event.output, event.success, event.success ? "success" : "failure");
13465
+ settle(event.id, event.output, event.success, event.success ? "success" : "failure", event.subagentId);
13466
+ return;
13467
+ case "usage": {
13468
+ const { subagentId } = event.record;
13469
+ if (subagentId) subagentUsage.set(subagentId, addUsage(subagentUsage.get(subagentId) ?? {}, event.record));
13470
+ onProgress({ type: "usage", record: event.record });
13471
+ return;
13472
+ }
13473
+ case "subagent_started":
13474
+ if (this.subagents.started.has(event.subagentId)) return;
13475
+ this.subagents.started.add(event.subagentId);
13476
+ onProgress({ type: "subagent_started", subagentId: event.subagentId, brief: event.brief, model: event.model });
13477
+ return;
13478
+ case "subagent_finished":
13479
+ if (!this.subagents.started.has(event.subagentId) || this.subagents.finished.has(event.subagentId)) return;
13480
+ this.subagents.finished.add(event.subagentId);
13481
+ onProgress({
13482
+ type: "subagent_finished",
13483
+ subagentId: event.subagentId,
13484
+ outcome: event.outcome,
13485
+ digest: event.digest,
13486
+ usage: subagentUsage.get(event.subagentId)
13487
+ });
12832
13488
  return;
12833
13489
  case "background_agent":
12834
13490
  backgroundAgents++;
@@ -12855,6 +13511,7 @@ var CliAgentAiService = class {
12855
13511
  return;
12856
13512
  }
12857
13513
  }, signal, () => onProgress({ type: "liveness" }));
13514
+ commitRun();
12858
13515
  for (const id of [...pendingCalls.keys()]) {
12859
13516
  settle(id, "The agent ended the turn without reporting this call's result.", false, "not_executed");
12860
13517
  }
@@ -12886,16 +13543,6 @@ var CliAgentAiService = class {
12886
13543
  await this.startAdapter({ ...conversation.startOptions, resumeSessionId: this.lastNativeSessionId ?? void 0 });
12887
13544
  return this.adapter;
12888
13545
  }
12889
- startAbortScope(callerSignal) {
12890
- this.activeAbort = new AbortController();
12891
- if (!callerSignal) return this.activeAbort.signal;
12892
- if (callerSignal.aborted) {
12893
- this.activeAbort.abort();
12894
- return this.activeAbort.signal;
12895
- }
12896
- callerSignal.addEventListener("abort", () => this.activeAbort?.abort(), { once: true });
12897
- return this.activeAbort.signal;
12898
- }
12899
13546
  plannerModel() {
12900
13547
  const id = (this.config.orchestratorModel ?? "").trim();
12901
13548
  return id || void 0;
@@ -12904,7 +13551,9 @@ var CliAgentAiService = class {
12904
13551
  /**
12905
13552
  * A single agent session that answers one prompt and exits. Used by every
12906
13553
  * non-conversational entry point; the plan is parsed from the reply text by
12907
- * the same extractor the conversational path uses.
13554
+ * the same extractor the conversational path uses. Its envelope streams to
13555
+ * the plan display, as a vendor planner's one-shot does, and the prose
13556
+ * around it is not streamed at all, since no turn is open to show it in.
12908
13557
  */
12909
13558
  async oneShot(prompt, onProgress, signal) {
12910
13559
  const previous = this.adapter;
@@ -12924,10 +13573,14 @@ var CliAgentAiService = class {
12924
13573
  startOptions,
12925
13574
  runners: []
12926
13575
  };
13576
+ const splitter = new ReplySplitter();
12927
13577
  const turn = await this.runTurn(
12928
13578
  "Follow the instructions in your system prompt and produce the plan now.",
12929
- onProgress ?? (() => {
12930
- }),
13579
+ (p) => {
13580
+ if (p.type !== "text_delta") return onProgress?.(p);
13581
+ const routed = p.segmentId && p.text ? splitter.push(p.segmentId, p.text) : null;
13582
+ if (routed?.route === "plan") onProgress?.({ type: "plan_token", planToken: routed.text, segmentId: p.segmentId });
13583
+ },
12931
13584
  signal
12932
13585
  );
12933
13586
  if (turn.error) throw new Error(turn.error);
@@ -13030,6 +13683,9 @@ function createAiService(config, deps) {
13030
13683
  return new OpenAiService(config);
13031
13684
  }
13032
13685
 
13686
+ // src/services/PlannerConversation.ts
13687
+ import { v4 as uuidv45 } from "uuid";
13688
+
13033
13689
  // src/services/conversationSummary.ts
13034
13690
  var KEPT_USER_MESSAGES = 2;
13035
13691
  var SUMMARY_OPEN = "<conversation_summary>";
@@ -13090,6 +13746,7 @@ var PlannerConversation = class {
13090
13746
  persisted = 0;
13091
13747
  turnsInFlight = 0;
13092
13748
  compacting = false;
13749
+ openTurnId = null;
13093
13750
  get transcript() {
13094
13751
  return this.host.plan()?.conversationHistory ?? [];
13095
13752
  }
@@ -13097,6 +13754,10 @@ var PlannerConversation = class {
13097
13754
  get isTurnInFlight() {
13098
13755
  return this.turnsInFlight > 0;
13099
13756
  }
13757
+ /** The user turn being answered, for what the host raises during it — an approval the turn's research asks for. */
13758
+ get currentTurnId() {
13759
+ return this.openTurnId ?? void 0;
13760
+ }
13100
13761
  /** Whether the model still holds this conversation in memory. */
13101
13762
  get isActive() {
13102
13763
  return this.host.aiService().hasActiveConversation();
@@ -13272,17 +13933,29 @@ var PlannerConversation = class {
13272
13933
  `The plan now has ${taskCount} task${taskCount === 1 ? "" : "s"}.`
13273
13934
  ].join("\n"), { kind: "system" });
13274
13935
  }
13936
+ /**
13937
+ * Queued edits the between-batches drain could not apply. Recorded so the
13938
+ * transcript does not go on promising a change that never landed. Call
13939
+ * inside the host's mutation ritual.
13940
+ */
13941
+ recordQueuedEditsFailed(messages, reason) {
13942
+ this.append("assistant", [
13943
+ "Queued change NOT applied \u2014 the plan is unchanged:",
13944
+ ...messages.map((m) => `- ${m}`),
13945
+ `Reason: ${reason}`
13946
+ ].join("\n"), { kind: "system" });
13947
+ }
13275
13948
  /** Open the conversation on a fresh plan: the goal is its first message. */
13276
13949
  async start(goal, opening, signal) {
13277
- return this.inTurn(async () => {
13950
+ return this.userTurn(goal, signal, async (userTurn) => {
13278
13951
  this.recordUser(goal, (/* @__PURE__ */ new Date()).toISOString());
13279
13952
  const turn = await this.host.aiService().startConversation({
13280
13953
  ...opening,
13281
13954
  goal,
13282
- onProgress: (p) => this.host.onProgress(p),
13955
+ onProgress: userTurn.stream.sink(),
13283
13956
  signal
13284
13957
  });
13285
- return this.settle(turn, signal);
13958
+ return this.settle(turn, userTurn);
13286
13959
  });
13287
13960
  }
13288
13961
  /**
@@ -13292,9 +13965,9 @@ var PlannerConversation = class {
13292
13965
  */
13293
13966
  async reply(message, options = {}) {
13294
13967
  if (this.compacting) throw new ConversationBusyError("send a message");
13295
- return this.inTurn(() => this.replyTurn(message, options));
13968
+ return this.userTurn(options.verbatim ?? message, options.signal, (userTurn) => this.replyTurn(message, options, userTurn));
13296
13969
  }
13297
- async replyTurn(message, options) {
13970
+ async replyTurn(message, options, userTurn) {
13298
13971
  const { signal } = options;
13299
13972
  const plan = this.requirePlan();
13300
13973
  const priorHistory = plan.conversationHistory ?? [];
@@ -13307,10 +13980,9 @@ ${message}` : message;
13307
13980
  const ai = this.host.aiService();
13308
13981
  const canContinueLive = ai.hasActiveConversation() && (ai.conversationMatchesConfig?.() ?? true);
13309
13982
  try {
13310
- const turn = canContinueLive ? await ai.continueConversation(outgoing, (p) => this.host.onProgress(p), signal) : await this.resume(outgoing, priorHistory, signal);
13311
- const reads = freshReadBudget();
13312
- let settleable = await this.drainTaskQueries(turn, reads, signal);
13313
- if ((settleable.kind === "task_ops" || settleable.kind === "plan") && this.host.hasLiveWork()) {
13983
+ const turn = canContinueLive ? await ai.continueConversation(outgoing, userTurn.stream.sink(), signal) : await this.resume(outgoing, priorHistory, signal, userTurn.stream.sink());
13984
+ let settleable = await this.drainTaskQueries(turn, userTurn);
13985
+ if (this.editTouchesLiveWork(settleable)) {
13314
13986
  const queued = this.host.queueEdit(options.verbatim ?? message);
13315
13987
  settleable = {
13316
13988
  kind: "message",
@@ -13318,12 +13990,26 @@ ${message}` : message;
13318
13990
  researchLog: settleable.researchLog
13319
13991
  };
13320
13992
  }
13321
- return await this.settle(settleable, signal, reads);
13993
+ return await this.settle(settleable, userTurn);
13322
13994
  } catch (err) {
13323
13995
  if (checkpoint) this.restore(checkpoint);
13324
13996
  throw err;
13325
13997
  }
13326
13998
  }
13999
+ /**
14000
+ * Whether a settled structural edit reaches work a runner is executing. Only
14001
+ * these are queued: a whole-plan commit replaces the plan and would reset the
14002
+ * run, and a task-ops batch that names an in-progress task changes it under
14003
+ * the runner. An add, or an edit to any other task, is reconciled into the
14004
+ * plan in place while the running batch keeps going.
14005
+ */
14006
+ editTouchesLiveWork(turn) {
14007
+ if (turn.kind === "plan") return this.host.hasLiveWork();
14008
+ if (turn.kind !== "task_ops") return false;
14009
+ const running = flattenTasks(this.host.tasks()).filter((t) => t.status === "in_progress");
14010
+ if (running.length === 0) return false;
14011
+ return turn.ops.some((op) => taskOpRefs(op).some((ref) => running.some((task) => refMatchesTask(ref, task))));
14012
+ }
13327
14013
  assertIdle(operation) {
13328
14014
  if (this.isTurnInFlight) throw new ConversationBusyError(operation);
13329
14015
  }
@@ -13335,6 +14021,26 @@ ${message}` : message;
13335
14021
  this.turnsInFlight--;
13336
14022
  }
13337
14023
  }
14024
+ /**
14025
+ * Bracket one user turn with its start and end, under an id minted here:
14026
+ * the turn is where the stream a surface draws begins and ends, and only the
14027
+ * conversation sees all of it — every backend call, read and retry.
14028
+ */
14029
+ async userTurn(prompt, signal, run) {
14030
+ const turnId = uuidv45();
14031
+ const stream = new TurnStream(turnId, (p) => this.host.onProgress(p));
14032
+ this.openTurnId = turnId;
14033
+ this.host.broadcast({ type: "planner_turn_started", turnId, prompt });
14034
+ let outcome = "error";
14035
+ try {
14036
+ const settled = await this.inTurn(() => run({ stream, signal, reads: freshReadBudget() }));
14037
+ outcome = settled.outcome;
14038
+ return settled.plan;
14039
+ } finally {
14040
+ if (this.openTurnId === turnId) this.openTurnId = null;
14041
+ this.host.broadcast({ type: "planner_turn_ended", turnId, outcome: signal?.aborted ? "stopped" : outcome });
14042
+ }
14043
+ }
13338
14044
  requirePlan() {
13339
14045
  const plan = this.host.plan();
13340
14046
  if (!plan) throw new Error("No active plan state");
@@ -13354,7 +14060,7 @@ ${message}` : message;
13354
14060
  * against the planner config now in effect. No LLM call happens for the
13355
14061
  * replayed turns; the first call is the one the user's message opens.
13356
14062
  */
13357
- async resume(message, priorHistory, signal, onProgress = (p) => this.host.onProgress(p)) {
14063
+ async resume(message, priorHistory, signal, onProgress) {
13358
14064
  const runners = this.requirePlan().runners;
13359
14065
  const opening = await this.host.opening(runners);
13360
14066
  const goal = this.host.goal() || priorHistory.find((m) => m.role === "user")?.content || message;
@@ -13382,7 +14088,7 @@ ${message}` : message;
13382
14088
  * ops JSON still gets its two corrective retries; charging it for the read
13383
14089
  * would cost it the chance to fix the edit.
13384
14090
  */
13385
- async drainTaskQueries(turn, reads, signal) {
14091
+ async drainTaskQueries(turn, { reads, signal, stream }) {
13386
14092
  const ai = this.host.aiService();
13387
14093
  const carried = [];
13388
14094
  let current = turn;
@@ -13404,7 +14110,7 @@ ${message}` : message;
13404
14110
  insist ? `${answer}
13405
14111
 
13406
14112
  ${TASK_QUERY_ANSWER_OR_OPS}` : answer,
13407
- (p) => this.host.onProgress(p),
14113
+ stream.sink(),
13408
14114
  signal
13409
14115
  );
13410
14116
  }
@@ -13424,7 +14130,8 @@ ${TASK_QUERY_ANSWER_OR_OPS}` : answer,
13424
14130
  * silent retries, then surfaced as a message with the plan untouched. The
13425
14131
  * first turn and every later turn route through here — one path, not two.
13426
14132
  */
13427
- async settle(turn, signal, reads = freshReadBudget()) {
14133
+ async settle(turn, userTurn) {
14134
+ const { signal, stream } = userTurn;
13428
14135
  const ai = this.host.aiService();
13429
14136
  const invalidOps = (errors, researchLog) => ({
13430
14137
  turn: {
@@ -13437,15 +14144,14 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
13437
14144
  }
13438
14145
  });
13439
14146
  const settled = await repairLoop({
13440
- first: () => this.drainTaskQueries(turn, reads, signal),
13441
- resend: async (corrective) => this.drainTaskQueries(
13442
- await ai.continueConversation(corrective, (p) => this.host.onProgress(p), signal),
13443
- reads,
13444
- signal
13445
- ),
14147
+ first: () => this.drainTaskQueries(turn, userTurn),
14148
+ resend: async (corrective) => {
14149
+ stream.retract();
14150
+ return this.drainTaskQueries(await ai.continueConversation(corrective, stream.sink(), signal), userTurn);
14151
+ },
13446
14152
  interpret: (t) => {
13447
14153
  if (t.kind !== "task_ops") return { done: { turn: t } };
13448
- const applied = this.applyTaskOps(t);
14154
+ const applied = this.applyTaskOps(t, stream.turnId);
13449
14155
  if ("plan" in applied) return { done: { plan: applied.plan } };
13450
14156
  if (!ai.hasActiveConversation() || signal?.aborted) {
13451
14157
  return { done: invalidOps(applied.errors, t.researchLog) };
@@ -13457,12 +14163,12 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
13457
14163
  });
13458
14164
  if ("plan" in settled) {
13459
14165
  await this.host.afterEdit();
13460
- return settled.plan;
14166
+ return { plan: settled.plan, outcome: "task_ops" };
13461
14167
  }
13462
- return this.commit(settled.turn);
14168
+ return { plan: this.commit(settled.turn, stream.turnId), outcome: settled.turn.kind };
13463
14169
  }
13464
14170
  /** Validate + commit a task_ops turn atomically. Returns the errors on rejection (plan untouched). */
13465
- applyTaskOps(turn) {
14171
+ applyTaskOps(turn, turnId) {
13466
14172
  this.requirePlan();
13467
14173
  const result = this.host.validateOps(turn.ops);
13468
14174
  if (!result.ok) return { errors: result.errors };
@@ -13477,14 +14183,14 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
13477
14183
  return true;
13478
14184
  },
13479
14185
  () => {
13480
- this.host.broadcast({ type: "planner_message", content, timestamp: now });
14186
+ this.host.broadcast({ type: "planner_message", content, timestamp: now, turnId });
13481
14187
  this.host.broadcastPlan();
13482
14188
  }
13483
14189
  );
13484
14190
  return { plan };
13485
14191
  }
13486
14192
  /** Commit a settled (non-task_ops) turn through the host's mutation ritual. */
13487
- commit(turn) {
14193
+ commit(turn, turnId) {
13488
14194
  this.requirePlan();
13489
14195
  const now = (/* @__PURE__ */ new Date()).toISOString();
13490
14196
  if (turn.kind === "plan") {
@@ -13494,7 +14200,7 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
13494
14200
  const count = this.host.adoptTasks(turn.tasks, "commit");
13495
14201
  this.append("assistant", `Plan generated with ${count} task${count === 1 ? "" : "s"}.`, { timestamp: now, kind: "plan_generated" });
13496
14202
  return true;
13497
- });
14203
+ }, () => this.host.broadcastPlan(turnId));
13498
14204
  }
13499
14205
  const text = turn.text.trim() ? turn.text : "(The planner returned an empty response. Reply to continue, or rephrase your goal.)";
13500
14206
  return this.host.mutate(
@@ -13504,7 +14210,7 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
13504
14210
  this.host.capturePrd(text);
13505
14211
  return true;
13506
14212
  },
13507
- () => this.host.broadcast({ type: "planner_message", content: text, timestamp: now })
14213
+ () => this.host.broadcast({ type: "planner_message", content: text, timestamp: now, turnId })
13508
14214
  );
13509
14215
  }
13510
14216
  /**
@@ -14162,6 +14868,36 @@ function deleteSession(sessionId, baseDir, logger = defaultLogger) {
14162
14868
  return false;
14163
14869
  }
14164
14870
 
14871
+ // src/services/PlannerUsage.ts
14872
+ var PlannerUsageLedger = class {
14873
+ usage = { totals: {} };
14874
+ /** Fold one model call's record into the totals; returns the running ledger. */
14875
+ record(record) {
14876
+ this.usage = addPlannerUsage(this.usage, record);
14877
+ return this.usage;
14878
+ }
14879
+ /** Adopt a persisted ledger (a reopened session) or start from zero. */
14880
+ restore(usage) {
14881
+ this.usage = usage ?? { totals: {} };
14882
+ }
14883
+ /** Start a fresh session's ledger from zero. */
14884
+ clear() {
14885
+ this.usage = { totals: {} };
14886
+ }
14887
+ /** Whether anything has been recorded — a plan with no usage says nothing. */
14888
+ get hasUsage() {
14889
+ return isMeasured(this.usage.totals);
14890
+ }
14891
+ /** The value persisted onto the plan state. */
14892
+ snapshot() {
14893
+ return { ...this.usage };
14894
+ }
14895
+ /** The broadcast message for the totals as they stand now. */
14896
+ message(turnId) {
14897
+ return { type: "planner_usage", ...turnId ? { turnId } : {}, ...usageLine(this.usage) };
14898
+ }
14899
+ };
14900
+
14165
14901
  // src/services/createSession.ts
14166
14902
  var PlanEditError = class extends Error {
14167
14903
  constructor(message) {
@@ -14198,6 +14934,13 @@ var Session = class {
14198
14934
  /** Injected by a test; when present it is the service, forever. */
14199
14935
  pinnedAiService;
14200
14936
  liveAiService = null;
14937
+ usageLedger = new PlannerUsageLedger();
14938
+ /**
14939
+ * The in-flight turn's subagent activity, grouped one run per subagent so a
14940
+ * replay nests each step under its own brief/result. Flushed into the plan's
14941
+ * researchLog at persist — see {@link flushSubagentRuns}.
14942
+ */
14943
+ pendingSubagents = [];
14201
14944
  liveAiProvider = null;
14202
14945
  workspaceRootFn;
14203
14946
  planner;
@@ -14250,7 +14993,8 @@ var Session = class {
14250
14993
  kind: request.kind,
14251
14994
  subject: request.subject,
14252
14995
  scope: request.scope,
14253
- detail: request.detail
14996
+ detail: request.detail,
14997
+ turnId: this.conversation.currentTurnId
14254
14998
  }),
14255
14999
  onSettled: (id, granted) => this.broadcast({ type: "approval_settled", id, granted })
14256
15000
  });
@@ -14298,7 +15042,7 @@ var Session = class {
14298
15042
  hasLiveWork: () => this.hasLiveWork,
14299
15043
  mutate: (op, notify) => this.mutatePlan(op, notify),
14300
15044
  broadcast: (msg) => this.broadcast(msg),
14301
- broadcastPlan: () => this.broadcastPlan(),
15045
+ broadcastPlan: (turnId) => this.broadcastPlan(turnId),
14302
15046
  validateOps: (ops) => applyTaskOps(this.store.planTasks, ops, this.plan.runners, this.editCatalog()),
14303
15047
  adoptTasks: (tasks, how) => this.adoptPlannerTasks(tasks, how),
14304
15048
  capturePrd: (text) => this.capturePrd(text),
@@ -14418,28 +15162,110 @@ var Session = class {
14418
15162
  });
14419
15163
  }
14420
15164
  translateProgress(progress) {
14421
- if (progress.type === "liveness") {
14422
- this.broadcast({ type: "planner_liveness" });
14423
- }
14424
- if (progress.type === "thinking" && progress.text) {
14425
- this.broadcast({ type: "plan_thinking", text: progress.text });
14426
- }
14427
- if (progress.type === "tool_call" && progress.tool) {
14428
- this.broadcast({ type: "research_step", tool: progress.tool, toolLabel: progress.toolLabel, args: progress.toolArgs || "", subagentId: progress.subagentId, toolCallId: progress.toolCallId });
14429
- }
14430
- if (progress.type === "plan_token" && progress.planToken) {
14431
- this.broadcast({ type: "plan_token", token: progress.planToken });
15165
+ const { turnId, subagentId, segmentId } = progress;
15166
+ switch (progress.type) {
15167
+ case "liveness":
15168
+ this.broadcast({ type: "planner_liveness" });
15169
+ return;
15170
+ case "thinking":
15171
+ if (!progress.text) return;
15172
+ this.broadcast({ type: "planner_thinking_delta", turnId, segmentId, subagentId, text: progress.text });
15173
+ return;
15174
+ case "tool_call":
15175
+ if (progress.tool) this.broadcast({ type: "research_step", tool: progress.tool, toolLabel: progress.toolLabel, args: progress.toolArgs || "", subagentId, toolCallId: progress.toolCallId, turnId });
15176
+ return;
15177
+ case "plan_token":
15178
+ if (progress.planToken) this.broadcast({ type: "plan_token", token: progress.planToken, turnId, segmentId });
15179
+ return;
15180
+ case "tool_result":
15181
+ if (progress.step) {
15182
+ if (progress.step.subagentId) this.subagentRun(progress.step.subagentId).steps.push(progress.step);
15183
+ this.broadcast({ type: "research_step_done", step: progress.step, subagentId, turnId });
15184
+ }
15185
+ return;
15186
+ case "text_delta":
15187
+ if (!progress.text) return;
15188
+ if (turnId && segmentId) this.broadcast({ type: "planner_text_delta", turnId, segmentId, text: progress.text });
15189
+ return;
15190
+ case "text_retracted":
15191
+ if (turnId) this.broadcast({ type: "planner_text_retracted", turnId, segmentId });
15192
+ return;
15193
+ case "subagent_started":
15194
+ if (subagentId) {
15195
+ const run = this.subagentRun(subagentId);
15196
+ run.entry.brief = progress.brief ?? run.entry.brief;
15197
+ if (progress.model) run.entry.model = progress.model;
15198
+ this.broadcast({ type: "subagent_started", turnId, subagentId, brief: progress.brief ?? "", model: progress.model });
15199
+ }
15200
+ return;
15201
+ case "subagent_finished":
15202
+ if (subagentId) {
15203
+ const run = this.subagentRun(subagentId);
15204
+ run.entry.outcome = progress.outcome ?? "failed";
15205
+ run.entry.digest = progress.digest ?? "";
15206
+ if (progress.usage) run.entry.usage = progress.usage;
15207
+ this.broadcast({
15208
+ type: "subagent_finished",
15209
+ turnId,
15210
+ subagentId,
15211
+ outcome: progress.outcome ?? "failed",
15212
+ digest: progress.digest ?? "",
15213
+ usage: progress.usage
15214
+ });
15215
+ }
15216
+ return;
15217
+ case "usage": {
15218
+ if (!progress.record) return;
15219
+ this.usageLedger.record(progress.record);
15220
+ this.broadcast(this.usageLedger.message(turnId));
15221
+ return;
15222
+ }
15223
+ case "interrupted":
15224
+ return;
14432
15225
  }
14433
- if (progress.type === "tool_result" && progress.step) {
14434
- this.broadcast({ type: "research_step_done", step: progress.step, subagentId: progress.subagentId });
15226
+ }
15227
+ /** The run for `subagentId`, created on first sighting so a step arriving
15228
+ * before (or without) its started event still gets a home. */
15229
+ subagentRun(subagentId) {
15230
+ let run = this.pendingSubagents.find((r) => r.entry.subagentId === subagentId);
15231
+ if (!run) {
15232
+ run = {
15233
+ entry: { id: `sa-${subagentId}`, type: "subagent", subagentId, brief: "", outcome: "failed", digest: "", timestamp: (/* @__PURE__ */ new Date()).toISOString() },
15234
+ steps: []
15235
+ };
15236
+ this.pendingSubagents.push(run);
14435
15237
  }
15238
+ return run;
15239
+ }
15240
+ /**
15241
+ * Fold the turn's subagent runs into the plan's researchLog as one contiguous
15242
+ * group per subagent — its entry then its steps, in the order they started —
15243
+ * so a replay nests each step under its own subagent however the live stream
15244
+ * interleaved. A harness planner already logs its child steps through the
15245
+ * turn's researchLog; they are pulled out of that position and re-grouped
15246
+ * rather than duplicated.
15247
+ */
15248
+ flushSubagentRuns() {
15249
+ if (!this.plan || this.pendingSubagents.length === 0) return;
15250
+ const childStepIds = new Set(this.pendingSubagents.flatMap((r) => r.steps.map((s) => s.id)));
15251
+ const additions = [];
15252
+ for (const run of this.pendingSubagents) {
15253
+ additions.push(run.entry, ...run.steps);
15254
+ }
15255
+ this.plan.researchLog = [
15256
+ ...(this.plan.researchLog ?? []).filter((e) => !childStepIds.has(e.id)),
15257
+ ...additions
15258
+ ];
15259
+ this.pendingSubagents = [];
14436
15260
  }
14437
15261
  /** Persists PlanStore state to disk. PlanStore is the single authority;
14438
15262
  * LegacyPlanState.tasks is populated only here, at persist time. */
14439
15263
  persist() {
14440
15264
  if (!this.plan) return;
15265
+ this.flushSubagentRuns();
14441
15266
  this.plan.tasks = this.store.planTasks;
14442
15267
  this.plan.isolation = this.orchestrator.isolationRecord ?? void 0;
15268
+ this.plan.plannerUsage = this.usageLedger.snapshot();
14443
15269
  this.plan.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
14444
15270
  saveSession(this.plan, this.goal, this.workspace, this.currentSessionId);
14445
15271
  this.conversation.markPersisted();
@@ -14457,7 +15283,9 @@ var Session = class {
14457
15283
  */
14458
15284
  beginFreshPlan() {
14459
15285
  if (this.isExecuting) this.stopExecution();
15286
+ this.pendingSubagents = [];
14460
15287
  this.conversation.reset();
15288
+ this.usageLedger.clear();
14461
15289
  this.approvals.clear();
14462
15290
  this.orchestrator.clearQueuedMessages();
14463
15291
  this.store.clearLog();
@@ -14555,8 +15383,14 @@ var Session = class {
14555
15383
  get isPlanning() {
14556
15384
  return !this.orchestrator.isRunning;
14557
15385
  }
15386
+ /**
15387
+ * A task is running right now. Deliberately live work, not the scheduler's
15388
+ * armed flag: a run paused on a user task, a hold or a cancellation has
15389
+ * nothing executing, and reporting it as executing is what left the plan
15390
+ * unstartable after its last live task was cancelled.
15391
+ */
14558
15392
  get isExecuting() {
14559
- return this.orchestrator.isRunning;
15393
+ return this.orchestrator.hasLiveWork;
14560
15394
  }
14561
15395
  /** See {@link TaskOrchestrator.hasLiveWork} — a spawned runner, not merely an armed scheduler. */
14562
15396
  get hasLiveWork() {
@@ -14666,6 +15500,7 @@ var Session = class {
14666
15500
  */
14667
15501
  async continueConversation(userMessage, options) {
14668
15502
  if (!this.plan) throw new Error("No planning conversation to continue");
15503
+ this.pendingSubagents = [];
14669
15504
  const releaseAbort = this.denyApprovalsOnAbort(options?.signal);
14670
15505
  try {
14671
15506
  return await this.conversation.reply(this.resolveSkillInvocation(userMessage), {
@@ -14765,25 +15600,35 @@ var Session = class {
14765
15600
  autonomousDefault: this.config.autonomousMode,
14766
15601
  verificationEnabled: settings.verificationEnabled ?? false,
14767
15602
  isolatedExecution: await this.orchestrator.plannerIsolation(),
15603
+ // The planner's own model window, when a cached catalog knows it, so the
15604
+ // usage line can show context fill (#49). Unknown stays absent.
15605
+ contextWindow: this.modelResolver.contextWindowFor?.(this.config.orchestratorModel),
14768
15606
  fs: this.fsAdapter,
14769
15607
  fetcher: this.fetcher
14770
15608
  };
14771
15609
  }
14772
15610
  /**
14773
15611
  * Load planner-produced tasks, coerced to the allowlist read live — it may
14774
- * have changed since planning started. An edit on an armed scheduler is
15612
+ * have changed since planning started. Tasks adopted on an armed scheduler are
14775
15613
  * reconciled rather than reloaded: `loadPlan` clears the on-hold set and the
14776
15614
  * review approval, so a task the user cancelled would be re-armed and
14777
15615
  * re-spawned by the re-tick that follows.
15616
+ *
15617
+ * A whole-plan commit is the planner restating every task, so it is laid
15618
+ * over the plan's execution state rather than replacing it: a planner that
15619
+ * answers "add a task" with the full plan must not undo the work already
15620
+ * done. Task ops need no overlay — their applier refuses to touch settled
15621
+ * tasks, and `rearm`, the one op meant to change a status, must stand.
14778
15622
  */
14779
15623
  adoptPlannerTasks(tasks, how) {
14780
15624
  const runners = this.plan.runners;
14781
- const coerced = coerceAssignments(tasks, this.allowlist(), runners, this.models());
14782
- if (how === "edit" && this.orchestrator.isRunning) {
15625
+ let coerced = coerceAssignments(tasks, this.allowlist(), runners, this.models());
15626
+ if (how === "commit") coerced = keepExecutionState(this.store.planTasks, coerced);
15627
+ if (this.orchestrator.isRunning) {
14783
15628
  this.orchestrator.reconcilePlan(coerced, runners);
14784
15629
  } else {
14785
15630
  this.orchestrator.loadPlan(coerced, runners);
14786
- this.store.resetForRun(how === "commit" ? { preserveCompleted: false } : void 0);
15631
+ this.store.resetForRun();
14787
15632
  }
14788
15633
  return coerced.length;
14789
15634
  }
@@ -14803,7 +15648,8 @@ var Session = class {
14803
15648
  }
14804
15649
  async executePlan() {
14805
15650
  if (!this.plan || !this.store.planTasks.length) throw new Error("No plan to execute");
14806
- if (this.orchestrator.isRunning) throw new Error("Session already executing");
15651
+ if (this.orchestrator.hasLiveWork) throw new Error("Session already executing");
15652
+ this.orchestrator.stop();
14807
15653
  this.plan.status = "approved";
14808
15654
  this.store.clearLog();
14809
15655
  this.store.resetForRun();
@@ -14866,39 +15712,62 @@ var Session = class {
14866
15712
  const messages = this.orchestrator.getQueuedMessages();
14867
15713
  if (messages.length === 0) return;
14868
15714
  this.orchestrator.clearQueuedMessages();
15715
+ if (this.plan) this.plan.queuedMessages = [];
14869
15716
  const batchText = messages.map((m) => m.text).join("\n");
15717
+ const texts = messages.map((m) => m.text);
14870
15718
  const activeSessions = new Map(
14871
15719
  [...this.orchestrator.activeSessionMap.entries()].map(([taskId, sessionId]) => [
14872
15720
  taskId,
14873
15721
  { id: sessionId, taskId }
14874
15722
  ])
14875
15723
  );
14876
- const modelsByRunner = await this.modelResolver.modelsForRunners(this.config.enabledRunners);
14877
- const runnerModes = this.runnerModesFor(this.plan?.runners ?? ["claude-code"]);
14878
- const { modelAllowlist } = this.settingsFn();
14879
- const result = await this.planner.modifyDuringExecution({
14880
- executionLog: this.store.getExecutionLog(),
14881
- pendingTasks: this.store.planTasks,
14882
- activeSessions,
14883
- userMessage: batchText,
14884
- modelsByRunner,
14885
- runners: this.plan?.runners ?? ["claude-code"],
14886
- runnerModes,
14887
- autonomousDefault: this.config.autonomousMode,
14888
- perRunnerAllowlist: modelAllowlist,
14889
- isolatedExecution: await this.orchestrator.plannerIsolation()
14890
- });
14891
- this.mutatePlan(() => {
14892
- this.orchestrator.reconcilePlan(result.pendingTasks, this.plan.runners);
14893
- this.conversation.recordQueuedEdits(messages.map((m) => m.text), result.pendingTasks.length);
14894
- return true;
14895
- });
15724
+ try {
15725
+ const modelsByRunner = await this.modelResolver.modelsForRunners(this.config.enabledRunners);
15726
+ const runnerModes = this.runnerModesFor(this.plan?.runners ?? ["claude-code"]);
15727
+ const { modelAllowlist } = this.settingsFn();
15728
+ const result = await this.planner.modifyDuringExecution({
15729
+ executionLog: this.finishedWork(),
15730
+ pendingTasks: this.store.planTasks.filter((t) => t.status !== "completed"),
15731
+ activeSessions,
15732
+ userMessage: batchText,
15733
+ modelsByRunner,
15734
+ runners: this.plan?.runners ?? ["claude-code"],
15735
+ runnerModes,
15736
+ autonomousDefault: this.config.autonomousMode,
15737
+ perRunnerAllowlist: modelAllowlist,
15738
+ isolatedExecution: await this.orchestrator.plannerIsolation()
15739
+ });
15740
+ this.mutatePlan(() => {
15741
+ const tasks = keepExecutionState(this.store.planTasks, result.pendingTasks);
15742
+ this.orchestrator.reconcilePlan(tasks, this.plan.runners);
15743
+ this.conversation.recordQueuedEdits(texts, tasks.length);
15744
+ return true;
15745
+ });
15746
+ } catch (err) {
15747
+ const reason = err instanceof Error ? err.message : String(err);
15748
+ this.mutatePlan(() => {
15749
+ this.conversation.recordQueuedEditsFailed(texts, reason);
15750
+ return true;
15751
+ });
15752
+ this.onNotice?.({ type: "notice", level: "error", message: `Your queued change could not be applied, so the plan is unchanged: ${reason}. Send it again to retry.` });
15753
+ }
14896
15754
  if (this.orchestrator.isRunning) {
14897
15755
  await this.orchestrator.tick();
14898
15756
  } else {
14899
15757
  await this.orchestrator.start();
14900
15758
  }
14901
15759
  }
15760
+ /**
15761
+ * Every finished task as the log records it: this run's own entries, plus
15762
+ * the tasks earlier runs completed, which the log dropped when this run
15763
+ * began but which dependents still count on.
15764
+ */
15765
+ finishedWork() {
15766
+ const log = this.store.getExecutionLog();
15767
+ const logged = new Set(log.map((s) => s.id));
15768
+ const earlier = flattenTasks(this.store.planTasks).filter((t) => t.status === "completed" && !logged.has(t.id)).map((t) => ({ ...t, completedAt: 0, retryCount: 0, finalized: true }));
15769
+ return [...log, ...earlier];
15770
+ }
14902
15771
  approveCheckpoint(taskId) {
14903
15772
  this.orchestrator.approveCheckpoint(taskId);
14904
15773
  }
@@ -14911,6 +15780,12 @@ var Session = class {
14911
15780
  getQueuedMessages() {
14912
15781
  return this.orchestrator.getQueuedMessages();
14913
15782
  }
15783
+ /** Take back one unsent message; the plan's persisted queue follows so a reload cannot resurrect it. */
15784
+ removeQueuedMessage(id) {
15785
+ const removed = this.orchestrator.removeQueuedMessage(id);
15786
+ if (removed && this.plan) this.plan.queuedMessages = this.getQueuedMessages();
15787
+ return removed;
15788
+ }
14914
15789
  setQueuedMessages(msgs) {
14915
15790
  this.orchestrator.setQueuedMessages(msgs);
14916
15791
  }
@@ -15233,11 +16108,13 @@ var Session = class {
15233
16108
  this.plan = plan;
15234
16109
  this.goal = goal;
15235
16110
  this.workspace = workspace;
16111
+ this.usageLedger.restore(plan.plannerUsage);
15236
16112
  if (opts?.sessionId) this.currentSessionId = opts.sessionId;
15237
16113
  this.orchestrator.loadPlan(plan.tasks, plan.runners);
15238
16114
  migratePlanStateIsolation(plan);
15239
16115
  if (adopting) void this.orchestrator.adoptIsolation(plan.isolation ?? null);
15240
16116
  if (opts?.persist !== false) this.persist();
16117
+ if (this.usageLedger.hasUsage) this.broadcast(this.usageLedger.message());
15241
16118
  }
15242
16119
  async modifyPlan(userRequest) {
15243
16120
  if (!this.plan) throw new Error("No plan to modify");
@@ -15268,13 +16145,14 @@ var Session = class {
15268
16145
  this.unsubObserver?.();
15269
16146
  this.unsubObserver = null;
15270
16147
  }
15271
- broadcastPlan() {
16148
+ broadcastPlan(turnId) {
15272
16149
  if (!this.plan) return;
15273
16150
  this.broadcast({
15274
16151
  type: "plan_generated",
15275
16152
  plan: serializePlan(this.plan),
15276
16153
  goal: this.goal,
15277
- runners: this.plan.runners
16154
+ runners: this.plan.runners,
16155
+ ...turnId ? { turnId } : {}
15278
16156
  });
15279
16157
  }
15280
16158
  get aiServiceInstance() {
@@ -15792,6 +16670,8 @@ function clipboardCopyCommand(hasBin = defaultHasBin, platform = process.platfor
15792
16670
 
15793
16671
  // src/services/TmuxRunner.ts
15794
16672
  var EXIT_RE = /<<<ORDEWELL_TMUX_EXIT:(\d+)>>>/;
16673
+ var LIVENESS_INTERVAL_MS = 5e3;
16674
+ var LIVENESS_MISSES = 2;
15795
16675
  var execFileAsync2 = promisify5(execFile4);
15796
16676
  var defaultExecFile3 = async (command, args) => {
15797
16677
  const { stdout, stderr } = await execFileAsync2(command, args);
@@ -15818,6 +16698,9 @@ var TmuxSession = class extends AbstractTerminalSession {
15818
16698
  outputBuffer = "";
15819
16699
  offset = 0;
15820
16700
  timer = null;
16701
+ quietPolls = 0;
16702
+ misses = 0;
16703
+ looking = false;
15821
16704
  get target() {
15822
16705
  return `${this.tmuxSession}:${this.windowName}`;
15823
16706
  }
@@ -15846,19 +16729,57 @@ var TmuxSession = class extends AbstractTerminalSession {
15846
16729
  }
15847
16730
  poll() {
15848
16731
  if (this.exited) return;
16732
+ if (this.readLog()) {
16733
+ this.quietPolls = 0;
16734
+ this.misses = 0;
16735
+ return;
16736
+ }
16737
+ if (++this.quietPolls % Math.max(1, Math.round(LIVENESS_INTERVAL_MS / this.pollIntervalMs)) === 0) void this.checkWindow();
16738
+ }
16739
+ /** Emit what the log gained since the last read; false when it gained nothing. */
16740
+ readLog() {
15849
16741
  let content;
15850
16742
  try {
15851
16743
  content = existsSync11(this.logPath) ? readFileSync8(this.logPath, "utf8") : "";
15852
16744
  } catch {
15853
- return;
16745
+ return false;
15854
16746
  }
15855
- if (content.length <= this.offset) return;
16747
+ if (content.length <= this.offset) return false;
15856
16748
  const diff = content.slice(this.offset);
15857
16749
  this.offset = content.length;
15858
16750
  this.outputBuffer += stripAnsi(diff);
15859
16751
  this.outputEmitter.emit("output", diff);
15860
16752
  const match = this.outputBuffer.slice(-4096).match(EXIT_RE);
15861
16753
  if (match) this.finish(Number(match[1]));
16754
+ return true;
16755
+ }
16756
+ /**
16757
+ * The sentinel is printed by the wrapper shell, so a window closed from
16758
+ * outside — killed by the user, or with the whole tmux server — never prints
16759
+ * it, and the session would count as running forever. A silent window is
16760
+ * therefore looked up by exact name (a `-t` target falls back to another
16761
+ * window once its own is gone), and once it is confirmed missing the session
16762
+ * ends as a kill does. Whatever the log still held is read first, so a
16763
+ * completion marker printed just before the close still counts.
16764
+ */
16765
+ async checkWindow() {
16766
+ if (this.looking) return;
16767
+ this.looking = true;
16768
+ let listed = false;
16769
+ try {
16770
+ const { stdout } = await this.tmux(["list-windows", "-t", this.tmuxSession, "-F", "#{window_name}"]);
16771
+ listed = stdout.split("\n").includes(this.windowName);
16772
+ } catch {
16773
+ }
16774
+ this.looking = false;
16775
+ if (this.exited) return;
16776
+ if (listed) {
16777
+ this.misses = 0;
16778
+ return;
16779
+ }
16780
+ if (++this.misses < LIVENESS_MISSES) return;
16781
+ this.readLog();
16782
+ if (!this.exited) this.finish(-1);
15862
16783
  }
15863
16784
  /**
15864
16785
  * A task finishing (or being killed) stops observation, but never the
@@ -17071,6 +17992,8 @@ export {
17071
17992
  DAEMON_TOKEN_SUBPROTOCOL_PREFIX,
17072
17993
  DEFAULT_MAX_PARALLEL,
17073
17994
  DENY_ALL,
17995
+ EMPTY_CONVERSATION,
17996
+ EMPTY_HOLD,
17074
17997
  EmbeddedNewlineError,
17075
17998
  EnvConfig,
17076
17999
  ExecutableNotFoundError,
@@ -17085,6 +18008,7 @@ export {
17085
18008
  LineBuffer,
17086
18009
  ModelCatalog,
17087
18010
  ModelResolver,
18011
+ NO_TURN,
17088
18012
  OPENCODE_MANIFEST,
17089
18013
  ORCHESTRATOR_SHORTCUTS,
17090
18014
  ORDEWELL_SETTABLE_ENV,
@@ -17132,8 +18056,11 @@ export {
17132
18056
  WINDOWS_MAX_COMMAND_LINE,
17133
18057
  WorkspaceNotAProjectError,
17134
18058
  WorkspaceNotFoundError,
18059
+ addPlannerUsage,
17135
18060
  addTaskToPlan,
18061
+ addUsage,
17136
18062
  admitSettingsEnv,
18063
+ aheadOfDraft,
17137
18064
  applyHeadLimit,
17138
18065
  applyTaskOps,
17139
18066
  assertInstallablePluginUrl,
@@ -17197,6 +18124,7 @@ export {
17197
18124
  dependentsOf,
17198
18125
  describeMergeResult,
17199
18126
  discoverGeminiModels,
18127
+ drainNext,
17200
18128
  effectiveAllowlist,
17201
18129
  emptyWarnings,
17202
18130
  enabledRunners,
@@ -17216,7 +18144,9 @@ export {
17216
18144
  filteredBuildModes,
17217
18145
  flattenTasks,
17218
18146
  flattenTasksWithParents,
18147
+ followTurn,
17219
18148
  formatSearchOutput,
18149
+ fromTranscript,
17220
18150
  generatePlanWithRepair,
17221
18151
  getLatestSession,
17222
18152
  getProviderMeta,
@@ -17224,14 +18154,18 @@ export {
17224
18154
  getStateDir,
17225
18155
  globalDataDir,
17226
18156
  grantScopeFor,
18157
+ hasHiddenDetail,
17227
18158
  hasTmux,
18159
+ holdPrompt,
17228
18160
  includeGlobFor,
17229
18161
  isCliProvider,
17230
18162
  isExecutableResolved,
18163
+ isMeasured,
17231
18164
  isOpenAiProvider,
17232
18165
  isPlainPluginName,
17233
18166
  isReservedRunnerName,
17234
18167
  isValidManifest,
18168
+ keepExecutionState,
17235
18169
  killTree,
17236
18170
  knownModelId,
17237
18171
  languageForId,
@@ -17252,14 +18186,19 @@ export {
17252
18186
  modifyValidationFeedback,
17253
18187
  normalizeAgentArgs,
17254
18188
  normalizeGeminiModel,
18189
+ opensWithJsonObject,
18190
+ outputLines,
18191
+ outputPreview,
17255
18192
  parseMaxParallel,
17256
18193
  parsePartialPlan,
17257
18194
  parsePlanJson,
17258
18195
  parseTaskOpsJson,
17259
18196
  parseTaskQueryJson,
18197
+ partedPromptUsage,
17260
18198
  pendingEditRulesBlock,
17261
18199
  planDirectLaunch,
17262
18200
  planShellLaunch,
18201
+ plannerContextFill,
17263
18202
  posixShellQuote,
17264
18203
  prefixModelId,
17265
18204
  providerForRunner,
@@ -17267,6 +18206,7 @@ export {
17267
18206
  reEmitTaskOpsPrompt,
17268
18207
  reEmitTaskQueryPrompt,
17269
18208
  readDaemonToken,
18209
+ reduceConversation,
17270
18210
  referencePattern,
17271
18211
  removeTaskFromPlan,
17272
18212
  renderPlanMap,
@@ -17300,6 +18240,7 @@ export {
17300
18240
  serializeTaskStatus,
17301
18241
  sessionRuntimeSettings,
17302
18242
  stateExists,
18243
+ stopTurn,
17303
18244
  stripAnsi,
17304
18245
  stripModelNoise,
17305
18246
  stripModelPrefix,
@@ -17309,6 +18250,7 @@ export {
17309
18250
  taskOpsRejectedPrompt,
17310
18251
  taskOrderLabel,
17311
18252
  taskQuerySignature,
18253
+ taskStartedNotice,
17312
18254
  textHasTaskOps,
17313
18255
  textHasTaskQuery,
17314
18256
  tmuxSessionName,
@@ -17317,9 +18259,13 @@ export {
17317
18259
  toOrchestratorOptions,
17318
18260
  tokenSubprotocols,
17319
18261
  tokensMatch,
18262
+ toolHeadline,
17320
18263
  truncateCheckpointSummary,
17321
18264
  truncatedPlanReEmitPrompt,
18265
+ unsendAll,
18266
+ unsendLatest,
17322
18267
  updateTaskInPlan,
18268
+ usageLine,
17323
18269
  validateModifiedPlan,
17324
18270
  validatePlanModification,
17325
18271
  warningsText,