@ordewell/core 0.5.4 → 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.
- package/dist/{ITerminalRunner-Bd-vAJnw.d.ts → ITerminalRunner-BV9Rd2o9.d.ts} +3 -1
- package/dist/{ITerminalRunner-ByeoLF57.d.mts → ITerminalRunner-C77ZNZS9.d.mts} +3 -1
- package/dist/{ModeResolver-DVJ7HV3k.d.mts → ModeResolver-D-SUFRNF.d.mts} +1 -1
- package/dist/{ModeResolver-Dkig8ghQ.d.ts → ModeResolver-D3XO0fT9.d.ts} +1 -1
- package/dist/{Task-BxQkPlXO.d.mts → Task-Vl5Zq_D-.d.mts} +168 -7
- package/dist/{Task-BxQkPlXO.d.ts → Task-Vl5Zq_D-.d.ts} +168 -7
- package/dist/{chunk-JVMDEHRQ.mjs → chunk-HD2FWPRV.mjs} +72 -37
- package/dist/chunk-HD2FWPRV.mjs.map +1 -0
- package/dist/{chunk-GWPIYDQW.mjs → chunk-KLN7ELXO.mjs} +897 -18
- package/dist/chunk-KLN7ELXO.mjs.map +1 -0
- package/dist/{chunk-XWOUIA6A.mjs → chunk-UUBGVCGJ.mjs} +2 -2
- package/dist/index.d.mts +320 -60
- package/dist/index.d.ts +320 -60
- package/dist/index.js +2497 -672
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1605 -659
- package/dist/index.mjs.map +1 -1
- package/dist/order-labels.d.mts +1 -1
- package/dist/order-labels.d.ts +1 -1
- package/dist/{parsing-CF_grC29.d.ts → parsing-BTP4bwkk.d.ts} +11 -3
- package/dist/{parsing-DRp4dPC0.d.mts → parsing-CDtRSxBY.d.mts} +11 -3
- package/dist/parsing.d.mts +3 -3
- package/dist/parsing.d.ts +3 -3
- package/dist/parsing.js.map +1 -1
- package/dist/parsing.mjs +2 -2
- package/dist/plan-utils-BFaPo-IT.d.ts +708 -0
- package/dist/plan-utils-pE4TBwxl.d.mts +708 -0
- package/dist/plan-utils.d.mts +3 -3
- package/dist/plan-utils.d.ts +3 -3
- package/dist/plan-utils.js +668 -7
- package/dist/plan-utils.js.map +1 -1
- package/dist/plan-utils.mjs +38 -4
- package/dist/testing.d.mts +5 -3
- package/dist/testing.d.ts +5 -3
- package/dist/testing.js +3 -0
- package/dist/testing.js.map +1 -1
- package/dist/testing.mjs +3 -0
- package/dist/testing.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-GWPIYDQW.mjs.map +0 -1
- package/dist/chunk-JVMDEHRQ.mjs.map +0 -1
- package/dist/plan-utils-CkNbqAmS.d.ts +0 -329
- package/dist/plan-utils-CtB3_Ovf.d.mts +0 -329
- /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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
4487
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
6543
|
-
*
|
|
6544
|
-
*
|
|
6545
|
-
* a `{tasks:[...]}`
|
|
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
|
-
|
|
6552
|
-
let
|
|
6553
|
-
let
|
|
6554
|
-
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
|
|
6563
|
-
|
|
6564
|
-
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
|
|
6569
|
-
|
|
6570
|
-
|
|
6571
|
-
|
|
6572
|
-
|
|
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
|
-
|
|
6590
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6752
|
-
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
|
|
6756
|
-
|
|
6757
|
-
|
|
6758
|
-
const
|
|
6759
|
-
|
|
6760
|
-
|
|
6761
|
-
|
|
6762
|
-
|
|
6763
|
-
}
|
|
6764
|
-
|
|
6765
|
-
|
|
6766
|
-
|
|
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,
|
|
6929
|
+
constructor(chat, hooks = {}) {
|
|
6775
6930
|
this.chat = chat;
|
|
6776
|
-
this.
|
|
6777
|
-
this.onContent = onContent;
|
|
6931
|
+
this.hooks = hooks;
|
|
6778
6932
|
}
|
|
6779
6933
|
chat;
|
|
6780
|
-
|
|
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.
|
|
6785
|
-
return this.
|
|
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.
|
|
6796
|
-
return this.
|
|
6948
|
+
const result = await this.chat.sendMessageStream(funcResponses);
|
|
6949
|
+
return this.consume(result.stream);
|
|
6797
6950
|
}
|
|
6798
|
-
|
|
6799
|
-
|
|
6800
|
-
|
|
6801
|
-
|
|
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
|
-
|
|
6875
|
-
(text) => currentProgress({ type: "
|
|
6876
|
-
(
|
|
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
|
-
|
|
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: "
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
9327
|
-
this.
|
|
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,
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
9548
|
-
* disables Execute Plan, but onVerdict cannot auto-schedule other
|
|
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.
|
|
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.
|
|
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
|
|
9834
|
-
*
|
|
9835
|
-
* predecessors' work, and
|
|
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
|
|
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
|
|
9843
|
-
* One that cannot be continued for another reason — it ran from a
|
|
9844
|
-
* workspace path — keeps its integration branch in each repo that
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
11744
|
-
|
|
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
|
-
|
|
11748
|
-
|
|
11749
|
-
|
|
11750
|
-
|
|
11751
|
-
|
|
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
|
-
|
|
11763
|
-
|
|
11764
|
-
|
|
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
|
|
12031
|
-
//
|
|
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
|
|
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,
|
|
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,
|
|
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.
|
|
12330
|
-
*
|
|
12331
|
-
*
|
|
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,
|
|
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,
|
|
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
|
|
12380
|
-
* moves through pending → running → completed, so parts are
|
|
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,
|
|
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
|
-
|
|
12390
|
-
|
|
12391
|
-
${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
|
|
12445
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
|
12684
|
-
*
|
|
12685
|
-
*
|
|
12686
|
-
*
|
|
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
|
-
|
|
12691
|
-
const
|
|
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
|
|
12698
|
-
|
|
12699
|
-
|
|
12700
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12827
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
13955
|
+
onProgress: userTurn.stream.sink(),
|
|
13283
13956
|
signal
|
|
13284
13957
|
});
|
|
13285
|
-
return this.settle(turn,
|
|
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.
|
|
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,
|
|
13311
|
-
|
|
13312
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
13441
|
-
resend: async (corrective) =>
|
|
13442
|
-
|
|
13443
|
-
|
|
13444
|
-
|
|
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
|
-
|
|
14422
|
-
|
|
14423
|
-
|
|
14424
|
-
|
|
14425
|
-
|
|
14426
|
-
|
|
14427
|
-
|
|
14428
|
-
|
|
14429
|
-
|
|
14430
|
-
|
|
14431
|
-
|
|
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
|
-
|
|
14434
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
14782
|
-
if (how === "
|
|
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(
|
|
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.
|
|
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
|
-
|
|
14877
|
-
|
|
14878
|
-
|
|
14879
|
-
|
|
14880
|
-
|
|
14881
|
-
|
|
14882
|
-
|
|
14883
|
-
|
|
14884
|
-
|
|
14885
|
-
|
|
14886
|
-
|
|
14887
|
-
|
|
14888
|
-
|
|
14889
|
-
|
|
14890
|
-
|
|
14891
|
-
|
|
14892
|
-
this.
|
|
14893
|
-
|
|
14894
|
-
|
|
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,
|