@ordewell/core 0.6.0 → 0.6.2
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/{IFileSystem-D4dAl2r8.d.ts → IFileSystem-DuEiG_kA.d.ts} +1 -1
- package/dist/{IFileSystem-CFYf4yR2.d.mts → IFileSystem-d_SV7jAH.d.mts} +1 -1
- package/dist/{ModeResolver-a4xJFlEp.d.mts → ModeResolver-DIReYAOq.d.mts} +1 -1
- package/dist/{ModeResolver-DVYVRbJp.d.ts → ModeResolver-DXxV4n_V.d.ts} +1 -1
- package/dist/{Task-CZJr6rmz.d.mts → Task-ib_U0YP7.d.mts} +56 -5
- package/dist/{Task-CZJr6rmz.d.ts → Task-ib_U0YP7.d.ts} +56 -5
- package/dist/{chunk-2SOVKO7P.mjs → chunk-I6U3PVHL.mjs} +39 -16
- package/dist/chunk-I6U3PVHL.mjs.map +1 -0
- package/dist/{chunk-EDGUFCIR.mjs → chunk-JRZPSNRO.mjs} +7 -3
- package/dist/chunk-JRZPSNRO.mjs.map +1 -0
- package/dist/{chunk-OM4D274O.mjs → chunk-M4KXBEFJ.mjs} +5 -3
- package/dist/chunk-M4KXBEFJ.mjs.map +1 -0
- package/dist/{chunk-C44UWIAD.mjs → chunk-N66ZHFGN.mjs} +2 -2
- package/dist/chunk-N66ZHFGN.mjs.map +1 -0
- package/dist/index.d.mts +181 -48
- package/dist/index.d.ts +181 -48
- package/dist/index.js +1207 -127
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1153 -105
- 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-Bd6-b644.d.ts → parsing-C8Z22twN.d.ts} +2 -2
- package/dist/{parsing-DIaGZmiQ.d.mts → parsing-DjRJJX-y.d.mts} +2 -2
- package/dist/parsing.d.mts +3 -3
- package/dist/parsing.d.ts +3 -3
- package/dist/parsing.js +5 -2
- package/dist/parsing.js.map +1 -1
- package/dist/parsing.mjs +2 -2
- package/dist/{plan-utils-_T5S7Mnk.d.ts → plan-utils-2K_N5ZJe.d.ts} +30 -4
- package/dist/{plan-utils-Ba-l8EZS.d.mts → plan-utils-D94CJ7CA.d.mts} +30 -4
- package/dist/plan-utils.d.mts +3 -3
- package/dist/plan-utils.d.ts +3 -3
- package/dist/plan-utils.js.map +1 -1
- package/dist/plan-utils.mjs +2 -2
- package/dist/testing.d.mts +14 -5
- package/dist/testing.d.ts +14 -5
- package/dist/testing.js +30 -3
- package/dist/testing.js.map +1 -1
- package/dist/testing.mjs +30 -3
- package/dist/testing.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-2SOVKO7P.mjs.map +0 -1
- package/dist/chunk-C44UWIAD.mjs.map +0 -1
- package/dist/chunk-EDGUFCIR.mjs.map +0 -1
- package/dist/chunk-OM4D274O.mjs.map +0 -1
package/dist/index.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
resolveTaskMode,
|
|
16
16
|
runnerModesFrom,
|
|
17
17
|
validatePlanModification
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-M4KXBEFJ.mjs";
|
|
19
19
|
import {
|
|
20
20
|
CHECKPOINT_TRUNCATE_LENGTH,
|
|
21
21
|
EMPTY_CONVERSATION,
|
|
@@ -76,7 +76,7 @@ import {
|
|
|
76
76
|
unsendLatest,
|
|
77
77
|
usageLine,
|
|
78
78
|
validateTaskEdit
|
|
79
|
-
} from "./chunk-
|
|
79
|
+
} from "./chunk-I6U3PVHL.mjs";
|
|
80
80
|
import {
|
|
81
81
|
PLAN_ENVELOPE_KEY,
|
|
82
82
|
PlanParseError,
|
|
@@ -105,7 +105,7 @@ import {
|
|
|
105
105
|
updateTaskInPlan,
|
|
106
106
|
validateModifiedPlan,
|
|
107
107
|
warningsText
|
|
108
|
-
} from "./chunk-
|
|
108
|
+
} from "./chunk-JRZPSNRO.mjs";
|
|
109
109
|
import {
|
|
110
110
|
resolveOrderLabel,
|
|
111
111
|
taskOrderLabel
|
|
@@ -121,12 +121,14 @@ import {
|
|
|
121
121
|
migratePlanStateIsolation,
|
|
122
122
|
repoRootOf,
|
|
123
123
|
taskIsolationOf
|
|
124
|
-
} from "./chunk-
|
|
124
|
+
} from "./chunk-N66ZHFGN.mjs";
|
|
125
125
|
|
|
126
126
|
// src/models/TaskLog.ts
|
|
127
127
|
var HEAD_LINES = 60;
|
|
128
128
|
var TAIL_LINES = 40;
|
|
129
129
|
var MAX_LINE_CHARS = 2e3;
|
|
130
|
+
var DIGEST_ARGS_CHARS = 120;
|
|
131
|
+
var DIGEST_MESSAGE_CHARS = 1e3;
|
|
130
132
|
function capLine(line) {
|
|
131
133
|
return line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}\u2026` : line;
|
|
132
134
|
}
|
|
@@ -209,6 +211,33 @@ function coalesceTaskLog(events) {
|
|
|
209
211
|
}
|
|
210
212
|
return out;
|
|
211
213
|
}
|
|
214
|
+
function digestTaskLog(events, maxCalls) {
|
|
215
|
+
const outcomes = /* @__PURE__ */ new Map();
|
|
216
|
+
let lastText;
|
|
217
|
+
let lastError;
|
|
218
|
+
for (const event of events) {
|
|
219
|
+
if (event.type === "tool_result") outcomes.set(event.id, event.success);
|
|
220
|
+
else if (event.type === "text") lastText = event.text;
|
|
221
|
+
else if (event.type === "error") lastError = event.message;
|
|
222
|
+
}
|
|
223
|
+
const lines = [];
|
|
224
|
+
for (const event of events) {
|
|
225
|
+
if (event.type !== "tool_call") continue;
|
|
226
|
+
const outcome = outcomes.get(event.id);
|
|
227
|
+
const args = event.args.length > DIGEST_ARGS_CHARS ? `${event.args.slice(0, DIGEST_ARGS_CHARS)}\u2026` : event.args;
|
|
228
|
+
lines.push(`- ${event.name} ${args} \u2192 ${outcome === void 0 ? "no result" : outcome ? "ok" : "failed"}`);
|
|
229
|
+
}
|
|
230
|
+
const omitted = lines.length - maxCalls;
|
|
231
|
+
const out = omitted > 0 ? [`\u2026 ${omitted} earlier call${omitted === 1 ? "" : "s"} omitted \u2026`, ...lines.slice(omitted)] : lines;
|
|
232
|
+
const gap = () => out.length > 0 ? [""] : [];
|
|
233
|
+
const said2 = lastText?.trim();
|
|
234
|
+
if (said2) {
|
|
235
|
+
const end = said2.length > DIGEST_MESSAGE_CHARS ? `\u2026${said2.slice(-DIGEST_MESSAGE_CHARS)}` : said2;
|
|
236
|
+
out.push(...gap(), "Its last message:", ...end.split("\n").map((l) => ` ${l}`));
|
|
237
|
+
}
|
|
238
|
+
if (lastError) out.push(...gap(), `It stopped with an error: ${lastError}`);
|
|
239
|
+
return out.join("\n");
|
|
240
|
+
}
|
|
212
241
|
|
|
213
242
|
// src/interfaces/IFileSystem.ts
|
|
214
243
|
var GREP_DEFAULT_HEAD_LIMIT = 100;
|
|
@@ -2446,6 +2475,8 @@ function lex(command, nested, dialect) {
|
|
|
2446
2475
|
let unsafeRedirect;
|
|
2447
2476
|
let processSubstitution = false;
|
|
2448
2477
|
let unbalanced = false;
|
|
2478
|
+
let grouped = false;
|
|
2479
|
+
let joinedBy;
|
|
2449
2480
|
let tokens = [];
|
|
2450
2481
|
let expandable = false;
|
|
2451
2482
|
let current = "";
|
|
@@ -2475,13 +2506,14 @@ function lex(command, nested, dialect) {
|
|
|
2475
2506
|
current = "";
|
|
2476
2507
|
started = false;
|
|
2477
2508
|
};
|
|
2478
|
-
const endSegment = (nextPiped) => {
|
|
2509
|
+
const endSegment = (nextPiped, next = "other") => {
|
|
2479
2510
|
endToken();
|
|
2480
|
-
if (tokens.length > 0) segments.push({ ...toSegment(tokens, piped, dialect), expandable, inputs });
|
|
2511
|
+
if (tokens.length > 0) segments.push({ ...toSegment(tokens, piped, dialect), expandable, inputs, joinedBy });
|
|
2481
2512
|
tokens = [];
|
|
2482
2513
|
inputs = [];
|
|
2483
2514
|
expandable = false;
|
|
2484
2515
|
piped = nextPiped;
|
|
2516
|
+
joinedBy = next;
|
|
2485
2517
|
};
|
|
2486
2518
|
let i = 0;
|
|
2487
2519
|
while (i < command.length) {
|
|
@@ -2603,17 +2635,18 @@ function lex(command, nested, dialect) {
|
|
|
2603
2635
|
}
|
|
2604
2636
|
if (c === "(" || c === ")") {
|
|
2605
2637
|
endToken();
|
|
2638
|
+
grouped = true;
|
|
2606
2639
|
i++;
|
|
2607
2640
|
continue;
|
|
2608
2641
|
}
|
|
2609
2642
|
if (c === "|") {
|
|
2610
2643
|
const double = command[i + 1] === "|";
|
|
2611
|
-
endSegment(!double);
|
|
2644
|
+
endSegment(!double, double ? "other" : "pipe");
|
|
2612
2645
|
i += double || command[i + 1] === "&" ? 2 : 1;
|
|
2613
2646
|
continue;
|
|
2614
2647
|
}
|
|
2615
2648
|
if (c === "&" || c === ";" || c === "\n") {
|
|
2616
|
-
endSegment(false);
|
|
2649
|
+
endSegment(false, c === "&" && command[i + 1] === "&" ? "and" : "other");
|
|
2617
2650
|
i += command[i + 1] === c ? 2 : 1;
|
|
2618
2651
|
continue;
|
|
2619
2652
|
}
|
|
@@ -2631,7 +2664,7 @@ function lex(command, nested, dialect) {
|
|
|
2631
2664
|
}
|
|
2632
2665
|
if (quote !== "") unbalanced = true;
|
|
2633
2666
|
endSegment(false);
|
|
2634
|
-
return { segments, unsafeRedirect, processSubstitution, unbalanced };
|
|
2667
|
+
return { segments, unsafeRedirect, processSubstitution, unbalanced, grouped };
|
|
2635
2668
|
}
|
|
2636
2669
|
function binaryName(token, dialect) {
|
|
2637
2670
|
const base = token.split(/[/\\]/).pop() ?? "";
|
|
@@ -2771,10 +2804,11 @@ function unwrap(seg, dialect) {
|
|
|
2771
2804
|
}
|
|
2772
2805
|
return { seg: { ...current, assignments }, wrappers, runner };
|
|
2773
2806
|
}
|
|
2774
|
-
function lexAll(command, dialect) {
|
|
2807
|
+
function lexAll(command, dialect, cdpathSet = false) {
|
|
2775
2808
|
const queue = [];
|
|
2776
2809
|
const top = lex(command, queue, dialect);
|
|
2777
2810
|
const all = { ...top, segments: top.segments.filter((s) => s.binary) };
|
|
2811
|
+
if (!top.grouped && queue.length === 0 && !cdpathSet) followCd(all.segments);
|
|
2778
2812
|
for (let depth = 0; depth < 32 && queue.length > 0; depth++) {
|
|
2779
2813
|
const inner = lex(queue.shift(), queue, dialect);
|
|
2780
2814
|
all.segments.push(...inner.segments.filter((s) => s.binary));
|
|
@@ -2784,15 +2818,32 @@ function lexAll(command, dialect) {
|
|
|
2784
2818
|
}
|
|
2785
2819
|
return all;
|
|
2786
2820
|
}
|
|
2821
|
+
function literalCdTarget(seg) {
|
|
2822
|
+
if (seg.binary !== "cd" || seg.assignments.length > 0 || seg.expandable || seg.inputs.length > 0) return void 0;
|
|
2823
|
+
if (seg.args.length !== 1) return void 0;
|
|
2824
|
+
const [target] = seg.args;
|
|
2825
|
+
return target === "" || target.startsWith("-") || target.startsWith("~") || /[*?[]/.test(target) ? void 0 : target;
|
|
2826
|
+
}
|
|
2827
|
+
function followCd(segments) {
|
|
2828
|
+
if (segments.some((s) => s.joinedBy === "other")) return;
|
|
2829
|
+
let chain = [];
|
|
2830
|
+
segments.forEach((seg, i) => {
|
|
2831
|
+
seg.cwd = chain;
|
|
2832
|
+
const inPipeline = seg.joinedBy === "pipe" || segments[i + 1]?.joinedBy === "pipe";
|
|
2833
|
+
const target = inPipeline ? void 0 : literalCdTarget(seg);
|
|
2834
|
+
if (target !== void 0) chain = [...chain, target];
|
|
2835
|
+
});
|
|
2836
|
+
}
|
|
2787
2837
|
function looksLikePath(arg) {
|
|
2788
2838
|
if (arg.startsWith("--") && arg.includes("=")) return looksLikePath(arg.slice(arg.indexOf("=") + 1));
|
|
2789
2839
|
return arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("../") || arg === ".." || arg.startsWith("./") || /^[A-Za-z]:/.test(arg) || arg.startsWith("\\") || arg.startsWith("..\\") || arg.startsWith(".\\") || /(^|[\\/])\.\.([\\/]|$)/.test(arg);
|
|
2790
2840
|
}
|
|
2791
|
-
function
|
|
2841
|
+
function pathRefs(command, opts = {}) {
|
|
2792
2842
|
const dialect = dialectFor(opts.dialect);
|
|
2793
|
-
return lexAll(command, dialect).segments.flatMap((seg) => {
|
|
2843
|
+
return lexAll(command, dialect, opts.cdpathSet).segments.flatMap((seg) => {
|
|
2794
2844
|
const patterns = patternArgs(seg);
|
|
2795
|
-
|
|
2845
|
+
const cwd = seg.cwd ?? [];
|
|
2846
|
+
return [...seg.args.filter((_, i) => !patterns.has(i)), ...seg.inputs].flatMap(pathIn).map((path26) => ({ path: path26, cwd }));
|
|
2796
2847
|
});
|
|
2797
2848
|
}
|
|
2798
2849
|
function pathIn(a) {
|
|
@@ -2956,7 +3007,8 @@ function scopeFor(seg) {
|
|
|
2956
3007
|
}
|
|
2957
3008
|
return [seg.binary, ...lead].join(" ");
|
|
2958
3009
|
}
|
|
2959
|
-
function isAuto(seg) {
|
|
3010
|
+
function isAuto(seg, opts) {
|
|
3011
|
+
if (seg.binary === "cd") return !opts.cdpathSet && literalCdTarget(seg) !== void 0;
|
|
2960
3012
|
if (!AUTO_COMMANDS.includes(seg.binary)) return false;
|
|
2961
3013
|
if (seg.expandable) return false;
|
|
2962
3014
|
if (seg.binary !== "git") return true;
|
|
@@ -3051,7 +3103,7 @@ function classifyCommand(command, opts = {}) {
|
|
|
3051
3103
|
const trimmed = command.trim();
|
|
3052
3104
|
if (!trimmed) return { tier: "refuse", scope: "", reason: "Empty command." };
|
|
3053
3105
|
const dialect = dialectFor(opts.dialect);
|
|
3054
|
-
const { segments, unsafeRedirect, processSubstitution, unbalanced } = lexAll(trimmed, dialect);
|
|
3106
|
+
const { segments, unsafeRedirect, processSubstitution, unbalanced } = lexAll(trimmed, dialect, opts.cdpathSet);
|
|
3055
3107
|
if (unbalanced) {
|
|
3056
3108
|
return {
|
|
3057
3109
|
tier: "refuse",
|
|
@@ -3088,7 +3140,7 @@ function classifyCommand(command, opts = {}) {
|
|
|
3088
3140
|
};
|
|
3089
3141
|
}
|
|
3090
3142
|
}
|
|
3091
|
-
const nonAuto = unwrapped.filter((u) => u.runner !== void 0 || !isAuto(u.seg));
|
|
3143
|
+
const nonAuto = unwrapped.filter((u) => u.runner !== void 0 || !isAuto(u.seg, opts));
|
|
3092
3144
|
if (nonAuto.length === 0) return { tier: "auto", scope: "" };
|
|
3093
3145
|
const scope = [...new Set(nonAuto.map((u) => u.runner ? `${u.runner} ${scopeFor(u.seg)}` : scopeFor(u.seg)))].sort().join(" + ");
|
|
3094
3146
|
return { tier: "ask", scope };
|
|
@@ -3168,11 +3220,15 @@ function expandHome(target) {
|
|
|
3168
3220
|
if (target === "~" || target.startsWith("~/")) return path2.join(home, target.slice(1));
|
|
3169
3221
|
return path2.join(path2.dirname(home), target.slice(1));
|
|
3170
3222
|
}
|
|
3171
|
-
function resolveWithin(root, target) {
|
|
3172
|
-
const abs = path2.resolve(
|
|
3223
|
+
function resolveWithin(root, target, from = root) {
|
|
3224
|
+
const abs = path2.resolve(from, expandHome(target));
|
|
3173
3225
|
const rel = path2.relative(root, abs);
|
|
3174
3226
|
return { abs, inside: rel === "" || !rel.startsWith("..") && !path2.isAbsolute(rel) };
|
|
3175
3227
|
}
|
|
3228
|
+
function resolveRef(root, ref) {
|
|
3229
|
+
const from = ref.cwd.reduce((dir, target) => resolveWithin(root, target, dir).abs, root);
|
|
3230
|
+
return resolveWithin(root, ref.path, from);
|
|
3231
|
+
}
|
|
3176
3232
|
function grantScopeFor(abs, kind) {
|
|
3177
3233
|
if (kind === "directory") return path2.join(abs, "*");
|
|
3178
3234
|
const parent = path2.dirname(abs);
|
|
@@ -3378,14 +3434,17 @@ ${refs.output.trim()}`);
|
|
|
3378
3434
|
outsidePaths(command) {
|
|
3379
3435
|
const root = this.getWorkspaceRoot();
|
|
3380
3436
|
const found = /* @__PURE__ */ new Map();
|
|
3381
|
-
for (const
|
|
3382
|
-
const { abs, inside } =
|
|
3437
|
+
for (const ref of pathRefs(command, this.policyOptions())) {
|
|
3438
|
+
const { abs, inside } = resolveRef(root, ref);
|
|
3383
3439
|
if (inside || isInertDevice(abs)) continue;
|
|
3384
3440
|
const scope = grantScopeFor(abs, "file");
|
|
3385
3441
|
if (!found.has(scope)) found.set(scope, abs);
|
|
3386
3442
|
}
|
|
3387
3443
|
return [...found].map(([scope, abs]) => ({ abs, scope }));
|
|
3388
3444
|
}
|
|
3445
|
+
policyOptions() {
|
|
3446
|
+
return { dialect: this.researchShell.dialect, cdpathSet: !!process.env.CDPATH };
|
|
3447
|
+
}
|
|
3389
3448
|
/**
|
|
3390
3449
|
* Three tiers (see `commandPolicy.ts`): read-only inspection runs silently,
|
|
3391
3450
|
* anything else asks once and is remembered, and writes are refused outright
|
|
@@ -3397,7 +3456,7 @@ ${refs.output.trim()}`);
|
|
|
3397
3456
|
* directory the next command names.
|
|
3398
3457
|
*/
|
|
3399
3458
|
async bash(command, signal) {
|
|
3400
|
-
const { tier, scope, reason } = classifyCommand(command,
|
|
3459
|
+
const { tier, scope, reason } = classifyCommand(command, this.policyOptions());
|
|
3401
3460
|
if (tier === "refuse") {
|
|
3402
3461
|
return { success: false, output: `Command refused: ${reason}`, truncated: false };
|
|
3403
3462
|
}
|
|
@@ -3896,10 +3955,78 @@ function buildGrepArgs(pattern, opts, root) {
|
|
|
3896
3955
|
function buildGlobArgs(pattern, root) {
|
|
3897
3956
|
return ["--files", ...SORT_BY_RECENCY, "--glob", pattern, ...exclusionArgs(), "--", root];
|
|
3898
3957
|
}
|
|
3899
|
-
|
|
3958
|
+
var GREP_PCRE_PROBE_ARGS = ["-P", "-e", "x", "/dev/null"];
|
|
3959
|
+
function grepRegexFromProbe(exitCode) {
|
|
3960
|
+
return exitCode === 0 || exitCode === 1 ? "pcre" : "ere";
|
|
3961
|
+
}
|
|
3962
|
+
function toPosixClasses(pattern) {
|
|
3963
|
+
const CLASS_ESCAPES = { s: "[:space:]", w: "[:alnum:]_", d: "[:digit:]", t: " " };
|
|
3964
|
+
let out = "";
|
|
3965
|
+
let i = 0;
|
|
3966
|
+
while (i < pattern.length) {
|
|
3967
|
+
const c = pattern[i];
|
|
3968
|
+
if (c === "\\") {
|
|
3969
|
+
out += pattern.slice(i, i + 2);
|
|
3970
|
+
i += 2;
|
|
3971
|
+
continue;
|
|
3972
|
+
}
|
|
3973
|
+
if (c !== "[") {
|
|
3974
|
+
out += c;
|
|
3975
|
+
i++;
|
|
3976
|
+
continue;
|
|
3977
|
+
}
|
|
3978
|
+
const start = i;
|
|
3979
|
+
i++;
|
|
3980
|
+
let negate = false;
|
|
3981
|
+
if (pattern[i] === "^") {
|
|
3982
|
+
negate = true;
|
|
3983
|
+
i++;
|
|
3984
|
+
}
|
|
3985
|
+
let body = "";
|
|
3986
|
+
let closeBracket = false;
|
|
3987
|
+
let openBracket = false;
|
|
3988
|
+
let dash = false;
|
|
3989
|
+
let closed = false;
|
|
3990
|
+
while (i < pattern.length) {
|
|
3991
|
+
const ch = pattern[i];
|
|
3992
|
+
if (ch === "]") {
|
|
3993
|
+
closed = true;
|
|
3994
|
+
i++;
|
|
3995
|
+
break;
|
|
3996
|
+
}
|
|
3997
|
+
if (ch === "[" && pattern[i + 1] === ":") {
|
|
3998
|
+
const end = pattern.indexOf(":]", i + 2);
|
|
3999
|
+
if (end !== -1) {
|
|
4000
|
+
body += pattern.slice(i, end + 2);
|
|
4001
|
+
i = end + 2;
|
|
4002
|
+
continue;
|
|
4003
|
+
}
|
|
4004
|
+
}
|
|
4005
|
+
if (ch === "\\" && i + 1 < pattern.length) {
|
|
4006
|
+
const e = pattern[i + 1];
|
|
4007
|
+
i += 2;
|
|
4008
|
+
if (e in CLASS_ESCAPES) body += CLASS_ESCAPES[e];
|
|
4009
|
+
else if (e === "]") closeBracket = true;
|
|
4010
|
+
else if (e === "[") openBracket = true;
|
|
4011
|
+
else if (e === "-") dash = true;
|
|
4012
|
+
else body += e;
|
|
4013
|
+
continue;
|
|
4014
|
+
}
|
|
4015
|
+
body += ch;
|
|
4016
|
+
i++;
|
|
4017
|
+
}
|
|
4018
|
+
if (!closed) {
|
|
4019
|
+
out += pattern.slice(start);
|
|
4020
|
+
break;
|
|
4021
|
+
}
|
|
4022
|
+
out += `[${negate ? "^" : ""}${closeBracket ? "]" : ""}${body}${openBracket ? "[" : ""}${dash ? "-" : ""}]`;
|
|
4023
|
+
}
|
|
4024
|
+
return out;
|
|
4025
|
+
}
|
|
4026
|
+
function buildFallbackGrepArgs(pattern, opts, root, regex = "pcre") {
|
|
3900
4027
|
const args = ["-r", "-n"];
|
|
3901
4028
|
if (opts.literal) args.push("-F");
|
|
3902
|
-
else args.push("-P");
|
|
4029
|
+
else args.push(regex === "pcre" ? "-P" : "-E");
|
|
3903
4030
|
if (opts.caseInsensitive) args.push("-i");
|
|
3904
4031
|
if (opts.outputMode === "files") args.push("-l");
|
|
3905
4032
|
if (opts.outputMode === "count") args.push("-c");
|
|
@@ -3909,7 +4036,7 @@ function buildFallbackGrepArgs(pattern, opts, root) {
|
|
|
3909
4036
|
}
|
|
3910
4037
|
if (opts.include) args.push(`--include=${opts.include}`);
|
|
3911
4038
|
for (const dir of SEARCH_EXCLUSIONS) args.push(`--exclude-dir=${dir}`);
|
|
3912
|
-
args.push("-e", pattern, root);
|
|
4039
|
+
args.push("-e", !opts.literal && regex === "ere" ? toPosixClasses(pattern) : pattern, root);
|
|
3913
4040
|
return args;
|
|
3914
4041
|
}
|
|
3915
4042
|
function globSegmentToRegex(segment) {
|
|
@@ -5143,6 +5270,18 @@ function leftoverMarkerFiles(check) {
|
|
|
5143
5270
|
const files = check.split(/\r?\n/).flatMap((line) => /^(.+):\d+: leftover conflict marker$/.exec(line)?.[1] ?? []);
|
|
5144
5271
|
return [...new Set(files)];
|
|
5145
5272
|
}
|
|
5273
|
+
function rawDiffStates(raw) {
|
|
5274
|
+
const parts = raw.split("\0");
|
|
5275
|
+
const states = {};
|
|
5276
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
5277
|
+
const meta = parts[i].trim();
|
|
5278
|
+
const file = parts[i + 1];
|
|
5279
|
+
if (!meta.startsWith(":") || !file) continue;
|
|
5280
|
+
const [, mode, , blob] = meta.slice(1).split(" ");
|
|
5281
|
+
states[file] = `${mode}:${blob}`;
|
|
5282
|
+
}
|
|
5283
|
+
return states;
|
|
5284
|
+
}
|
|
5146
5285
|
function settleStatus(record, status) {
|
|
5147
5286
|
record.status = status;
|
|
5148
5287
|
delete record.repairBase;
|
|
@@ -5242,6 +5381,8 @@ var GitWorktreeIsolation = class {
|
|
|
5242
5381
|
adminChains = /* @__PURE__ */ new Map();
|
|
5243
5382
|
waiting = [];
|
|
5244
5383
|
draining = false;
|
|
5384
|
+
/** Landings and Merge all take turns on this, so a merge never reads an integration branch mid-landing. */
|
|
5385
|
+
turns = Promise.resolve();
|
|
5245
5386
|
async isActive(workspaceRoot) {
|
|
5246
5387
|
if (!this.deps.config.worktreeIsolation) return { active: false, reason: "disabled" };
|
|
5247
5388
|
const version = await this.tryGit(void 0, ["--version"]);
|
|
@@ -5598,6 +5739,62 @@ var GitWorktreeIsolation = class {
|
|
|
5598
5739
|
return !linked.has(rel) && !linked.has(rel.split("/")[0]);
|
|
5599
5740
|
});
|
|
5600
5741
|
}
|
|
5742
|
+
async findInHead(run) {
|
|
5743
|
+
const found = [];
|
|
5744
|
+
for (const record of Object.values(run.tasks)) {
|
|
5745
|
+
if (record.status !== "merged" || record.inHead) continue;
|
|
5746
|
+
if (!await this.headContains(run, record)) continue;
|
|
5747
|
+
record.inHead = true;
|
|
5748
|
+
found.push(record.taskId);
|
|
5749
|
+
}
|
|
5750
|
+
return found;
|
|
5751
|
+
}
|
|
5752
|
+
async headContains(run, record) {
|
|
5753
|
+
for (const repo of run.repos) {
|
|
5754
|
+
const entry = record.repos[repo.path];
|
|
5755
|
+
if (!entry?.changed) continue;
|
|
5756
|
+
const merged = await this.tryGit(repo.root, ["merge-base", "--is-ancestor", entry.landedTip ?? repo.integrationBranch, "HEAD"]);
|
|
5757
|
+
if (!merged.ok) return false;
|
|
5758
|
+
}
|
|
5759
|
+
return true;
|
|
5760
|
+
}
|
|
5761
|
+
async snapshotTree(workspaceRoot, exclude) {
|
|
5762
|
+
const scan = await this.scanGroup(workspaceRoot);
|
|
5763
|
+
if ("refused" in scan) return null;
|
|
5764
|
+
const repos = (await this.committedRepos(workspaceRoot, scan.paths)).filter((repoPath) => !exclude.includes(repoPath));
|
|
5765
|
+
if (repos.length === 0) return null;
|
|
5766
|
+
const snapshot = {};
|
|
5767
|
+
for (const repoPath of repos) {
|
|
5768
|
+
const states = await this.trackedStates(repoRootOf(workspaceRoot, repoPath));
|
|
5769
|
+
if (states) snapshot[repoPath] = states;
|
|
5770
|
+
}
|
|
5771
|
+
return snapshot;
|
|
5772
|
+
}
|
|
5773
|
+
async changedSince(workspaceRoot, snapshot) {
|
|
5774
|
+
const changed = [];
|
|
5775
|
+
for (const [repoPath, before] of Object.entries(snapshot)) {
|
|
5776
|
+
const now = await this.trackedStates(repoRootOf(workspaceRoot, repoPath));
|
|
5777
|
+
if (!now) continue;
|
|
5778
|
+
for (const [file, state] of Object.entries(now)) {
|
|
5779
|
+
if (before[file] !== state) changed.push(repoPath === SELF_REPO ? file : `${repoPath}/${file}`);
|
|
5780
|
+
}
|
|
5781
|
+
}
|
|
5782
|
+
return changed.sort();
|
|
5783
|
+
}
|
|
5784
|
+
/**
|
|
5785
|
+
* Each tracked file that differs from HEAD, with what it holds. `git stash
|
|
5786
|
+
* create` records the working tree as a commit without touching the tree,
|
|
5787
|
+
* the index or the stash, so a file changed again is told apart from one
|
|
5788
|
+
* left as it was. Null when git cannot tell — a merge in progress, say.
|
|
5789
|
+
*/
|
|
5790
|
+
async trackedStates(root) {
|
|
5791
|
+
const stash = await this.tryGit(root, ["stash", "create"]);
|
|
5792
|
+
if (!stash.ok) return null;
|
|
5793
|
+
const commit = stash.stdout.trim();
|
|
5794
|
+
if (!commit) return {};
|
|
5795
|
+
const diff = await this.tryGit(root, ["diff", "--raw", "--no-abbrev", "--no-renames", "-z", "HEAD", commit]);
|
|
5796
|
+
return diff.ok ? rawDiffStates(diff.stdout) : null;
|
|
5797
|
+
}
|
|
5601
5798
|
/**
|
|
5602
5799
|
* One section per repo with changes. A repo below the workspace root gets a
|
|
5603
5800
|
* header and paths prefixed with its own, so the whole reads as one patch
|
|
@@ -5615,7 +5812,10 @@ var GitWorktreeIsolation = class {
|
|
|
5615
5812
|
}
|
|
5616
5813
|
return diff;
|
|
5617
5814
|
}
|
|
5618
|
-
|
|
5815
|
+
mergeIntoCheckedOut(run) {
|
|
5816
|
+
return this.inTurn(() => this.mergeAll(run));
|
|
5817
|
+
}
|
|
5818
|
+
async mergeAll(run) {
|
|
5619
5819
|
const partial = await this.settleLanding(run);
|
|
5620
5820
|
if (partial.length > 0) return { outcome: "blocked", blocked: partial.map((repo) => ({ repo, reason: "partial-landing", files: [] })) };
|
|
5621
5821
|
const repos = await this.reposWithWork(run);
|
|
@@ -5749,7 +5949,7 @@ var GitWorktreeIsolation = class {
|
|
|
5749
5949
|
if (entry2.run.id === runId && (pick < 0 || entry2.task.order < this.waiting[pick].task.order)) pick = i;
|
|
5750
5950
|
});
|
|
5751
5951
|
const [entry] = this.waiting.splice(pick, 1);
|
|
5752
|
-
entry.settle(await this.land(entry.task, entry.run, entry.persist).catch(() => "failed"));
|
|
5952
|
+
entry.settle(await this.inTurn(() => this.land(entry.task, entry.run, entry.persist)).catch(() => "failed"));
|
|
5753
5953
|
}
|
|
5754
5954
|
} finally {
|
|
5755
5955
|
this.draining = false;
|
|
@@ -5801,6 +6001,10 @@ var GitWorktreeIsolation = class {
|
|
|
5801
6001
|
return (await this.settleLanding(run)).length === 0 ? this.stopLanding(record, outcome, repo, files, error) : this.stopLanding(record, "failed", repo);
|
|
5802
6002
|
}
|
|
5803
6003
|
}
|
|
6004
|
+
for (const repo of changed) {
|
|
6005
|
+
const tip = await this.tryGit(repo.root, ["rev-parse", "--verify", "-q", repo.integrationBranch]);
|
|
6006
|
+
if (tip.ok) record.repos[repo.path].landedTip = tip.stdout.trim();
|
|
6007
|
+
}
|
|
5804
6008
|
delete run.landing;
|
|
5805
6009
|
settleStatus(record, "merged");
|
|
5806
6010
|
delete record.conflictRepo;
|
|
@@ -6075,6 +6279,11 @@ var GitWorktreeIsolation = class {
|
|
|
6075
6279
|
async abortMerge(cwd) {
|
|
6076
6280
|
if (!(await this.tryGit(cwd, ["merge", "--abort"])).ok) await this.tryGit(cwd, ["reset", "--hard"]);
|
|
6077
6281
|
}
|
|
6282
|
+
inTurn(fn) {
|
|
6283
|
+
const turn = this.turns.then(fn, fn);
|
|
6284
|
+
this.turns = turn.catch(() => void 0);
|
|
6285
|
+
return turn;
|
|
6286
|
+
}
|
|
6078
6287
|
admin(repoKey, fn) {
|
|
6079
6288
|
const prior = this.adminChains.get(repoKey) ?? Promise.resolve();
|
|
6080
6289
|
const next = prior.then(fn, fn);
|
|
@@ -6491,8 +6700,22 @@ function researchPhaseBlock(harnessMode) {
|
|
|
6491
6700
|
"- Research and questions can interleave \u2014 you can explore a file, then ask a question grounded in it, in one response"
|
|
6492
6701
|
].join("\n");
|
|
6493
6702
|
}
|
|
6494
|
-
var ISOLATED_PARALLELISM_RULE = "- Each
|
|
6703
|
+
var ISOLATED_PARALLELISM_RULE = "- Each change task runs in its own git worktree, and its work is merged in afterwards, so tasks that edit the same files can still run in parallel. Never add a dependency just because two tasks touch the same file \u2014 keep dependencies for genuine logical ordering.";
|
|
6495
6704
|
var OVERLAP_AVOIDANCE_RULE = "- For parallel tasks, specify different target files to avoid merge conflicts.";
|
|
6705
|
+
function opsTaskSection(conversation) {
|
|
6706
|
+
return [
|
|
6707
|
+
"",
|
|
6708
|
+
"CHANGE AND OPS TASKS:",
|
|
6709
|
+
"- Every AI task is either a change task or an ops task. Decide by one test: is the task's result a change to files in the repository?",
|
|
6710
|
+
'- A change task (the default) edits repository files. Omit "ops".',
|
|
6711
|
+
`- An ops task changes no repository files: it acts on systems outside the repository (a cloud CLI, a deployment, a pipeline, a provisioned resource) or on the git refs and history of the user's branch (push, tag, reword commits). Set "ops": true. Only top-level "ai" tasks can be ops; a subtask runs with its parent.`,
|
|
6712
|
+
`- Ops tasks run in the user's own checkout, after the user has merged the change tasks they depend on. Give an ops task a dependency on every change task whose result it acts on \u2014 "deploy the fix" depends on the fix.`,
|
|
6713
|
+
'- Split a request that mixes the two into a change task and an ops task that depends on it: "bump the version and redeploy" is a change task that bumps and commits the version, then an ops task that pushes and watches the pipeline.',
|
|
6714
|
+
"- Ops tasks run in parallel as their dependencies allow. Two ops tasks whose git operations would collide (a push and a reword of the same branch) must not run in parallel \u2014 make one depend on the other.",
|
|
6715
|
+
"- An ops task runs until what it acts on has finished: one that watches a pipeline reports done only when the pipeline has ended.",
|
|
6716
|
+
...conversation ? [`- A question is not a task. "What is the frontend's URL?" is answered in this conversation, from your research; never make a task of it.`] : []
|
|
6717
|
+
];
|
|
6718
|
+
}
|
|
6496
6719
|
var SHARED_APPEND_FILE_RULE = "- Tasks that run in parallel must not each append to the same shared file (changelogs, registries, index lists) \u2014 give that edit to the final task, or to one task the others depend on.";
|
|
6497
6720
|
function repoGroupSection(isolated) {
|
|
6498
6721
|
if (!isolated) return [];
|
|
@@ -6572,6 +6795,7 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
|
|
|
6572
6795
|
' "taskMode": "' + modeExamples + '",',
|
|
6573
6796
|
' "autonomy": "AFK|HITL",',
|
|
6574
6797
|
' "sliceType": "AFK|HITL",',
|
|
6798
|
+
' "ops": true,',
|
|
6575
6799
|
' "userStoriesCovered": ["user story text"],',
|
|
6576
6800
|
' "subtasks": [{ "id": "sub-id-string", "order": 1, "title": "Sub-step title", "description": "What it accomplishes", "type": "ai", "dependencies": [], "prompt": "Detailed instructions", "autonomy": "AFK|HITL", "sliceType": "AFK|HITL", "subtasks": [] }]',
|
|
6577
6801
|
" }",
|
|
@@ -6597,6 +6821,7 @@ function buildConversationBody(goal, context, modelsJson, runners, modeGuide, mo
|
|
|
6597
6821
|
SHARED_APPEND_FILE_RULE,
|
|
6598
6822
|
...variant.isolatedExecution ? [ISOLATED_PARALLELISM_RULE] : [],
|
|
6599
6823
|
...repoGroupSection(variant.isolatedExecution),
|
|
6824
|
+
...opsTaskSection(true),
|
|
6600
6825
|
"",
|
|
6601
6826
|
"RULES:",
|
|
6602
6827
|
"- Do NOT wrap the JSON in markdown code blocks. Output ONLY the JSON object when committing the plan.",
|
|
@@ -6672,6 +6897,7 @@ function corePlannerPrompt(isolatedExecution) {
|
|
|
6672
6897
|
' "taskMode": "{{MODE_EXAMPLES}}",',
|
|
6673
6898
|
' "autonomy": "AFK|HITL",',
|
|
6674
6899
|
' "sliceType": "AFK|HITL",',
|
|
6900
|
+
' "ops": true,',
|
|
6675
6901
|
' "userStoriesCovered": ["user story text"],',
|
|
6676
6902
|
' "subtasks": [{ "id": "sub-id-string", "order": 1, "title": "Sub-step title", "description": "What it accomplishes", "type": "ai", "dependencies": [], "prompt": "Detailed instructions", "autonomy": "AFK|HITL", "sliceType": "AFK|HITL", "subtasks": [] }]',
|
|
6677
6903
|
" }",
|
|
@@ -6699,6 +6925,7 @@ function corePlannerPrompt(isolatedExecution) {
|
|
|
6699
6925
|
SHARED_APPEND_FILE_RULE,
|
|
6700
6926
|
isolatedExecution ? ISOLATED_PARALLELISM_RULE : "- Slices that touch different areas of the codebase are naturally parallel.",
|
|
6701
6927
|
...repoGroupSection(isolatedExecution),
|
|
6928
|
+
...opsTaskSection(false),
|
|
6702
6929
|
"",
|
|
6703
6930
|
"RULES:",
|
|
6704
6931
|
'- Mark as type "ai" any task the coding assistant can do autonomously.',
|
|
@@ -7500,14 +7727,15 @@ function nonPromptingFs(fs16) {
|
|
|
7500
7727
|
listDir: (p, depth) => withinWorkspace(p) ? fs16.listDir(p, depth) : Promise.resolve(outsideRefusal(p)),
|
|
7501
7728
|
bash: async (command, signal) => {
|
|
7502
7729
|
const dialect = resolveResearchShell().dialect;
|
|
7503
|
-
const
|
|
7730
|
+
const policy = { dialect, cdpathSet: !!process.env.CDPATH };
|
|
7731
|
+
const { tier, reason } = classifyCommand(command, policy);
|
|
7504
7732
|
if (tier !== "auto") {
|
|
7505
7733
|
return refused2(
|
|
7506
7734
|
tier === "refuse" ? `Command refused: ${reason}` : `"${command}" needs user approval, and research agents run in the background where nothing can be approved. Use the read-only research tools, and note in your digest that this command would answer the question.`
|
|
7507
7735
|
);
|
|
7508
7736
|
}
|
|
7509
|
-
const escaping =
|
|
7510
|
-
if (escaping) return outsideRefusal(escaping);
|
|
7737
|
+
const escaping = pathRefs(command, policy).find((ref) => !resolveRef(fs16.getWorkspaceRoot(), ref).inside);
|
|
7738
|
+
if (escaping) return outsideRefusal(escaping.path);
|
|
7511
7739
|
return fs16.bash(command, signal);
|
|
7512
7740
|
}
|
|
7513
7741
|
};
|
|
@@ -8702,6 +8930,17 @@ You are running as: ${currentTask.assignedRunner}` : "";
|
|
|
8702
8930
|
lines.join("\n") + footer + runnerNote
|
|
8703
8931
|
].join("\n");
|
|
8704
8932
|
}
|
|
8933
|
+
function renderPreviousAttempt(output) {
|
|
8934
|
+
const tail = output.trim() ? defuseMarkers(output.trim()).split("\n").map((l) => " " + l).join("\n") : " (no output captured)";
|
|
8935
|
+
return [
|
|
8936
|
+
"## Previous attempt",
|
|
8937
|
+
"",
|
|
8938
|
+
"This task ran before and did not finish. What it did outside the repository was not undone: before acting, check what already exists \u2014 resources, deployments, pushed refs \u2014 and do not repeat a step that already took effect.",
|
|
8939
|
+
"",
|
|
8940
|
+
"The end of its output:",
|
|
8941
|
+
tail
|
|
8942
|
+
].join("\n");
|
|
8943
|
+
}
|
|
8705
8944
|
function renderCompletionMarker(task) {
|
|
8706
8945
|
return `
|
|
8707
8946
|
|
|
@@ -8753,6 +8992,7 @@ function composeAugmentedPrompt(task, allTasks, opts) {
|
|
|
8753
8992
|
const flat = flattenTasks(allTasks);
|
|
8754
8993
|
const outputs = collectDirectDependencyOutputs(task, flat);
|
|
8755
8994
|
if (outputs.length > 0) blocks.push(renderPriorOutputs(outputs));
|
|
8995
|
+
if (opts?.previousAttempt !== void 0) blocks.push(renderPreviousAttempt(opts.previousAttempt));
|
|
8756
8996
|
if (opts?.tddEnabled) {
|
|
8757
8997
|
blocks.push(renderTddInstruction());
|
|
8758
8998
|
}
|
|
@@ -8765,9 +9005,9 @@ function composeAugmentedPrompt(task, allTasks, opts) {
|
|
|
8765
9005
|
|
|
8766
9006
|
${basePrompt}${marker2}`;
|
|
8767
9007
|
}
|
|
8768
|
-
function composeContinuationPrompt(task, message) {
|
|
9008
|
+
function composeContinuationPrompt(task, message, opts = {}) {
|
|
8769
9009
|
const reminder = [
|
|
8770
|
-
"(Ordewell) You are continuing this task in the same session. Your working directory was recreated from the integration branch: work from your earlier attempt is there only if it landed, so check the files before relying on them."
|
|
9010
|
+
opts.ops ? "(Ordewell) You are continuing this task in the same session, in the same checkout. What your earlier attempt did outside the repository was not undone: check what already exists before acting again." : "(Ordewell) You are continuing this task in the same session. Your working directory was recreated from the integration branch: work from your earlier attempt is there only if it landed, so check the files before relying on them."
|
|
8771
9011
|
];
|
|
8772
9012
|
if (isHitlTask(task)) {
|
|
8773
9013
|
reminder.push(`If you reach a decision that needs human judgment, print one line holding only the checkpoint marker and wait for ORDEWELL_CONTINUE or ORDEWELL_REJECT. ${CHECKPOINT_MARKER_HOWTO}.`);
|
|
@@ -8811,6 +9051,12 @@ var VerdictEngine = class {
|
|
|
8811
9051
|
idleSince = /* @__PURE__ */ new Map();
|
|
8812
9052
|
/** Tasks waiting on the user, whose silence is expected rather than a sign of a stuck runner. */
|
|
8813
9053
|
idlePaused = /* @__PURE__ */ new Set();
|
|
9054
|
+
/**
|
|
9055
|
+
* Open tool approvals per structured task. They arrive mid-turn and leave the
|
|
9056
|
+
* status alone (ADR-0018, W1), but a task waiting on one is waiting on the
|
|
9057
|
+
* user, so it is not idle either.
|
|
9058
|
+
*/
|
|
9059
|
+
openApprovals = /* @__PURE__ */ new Map();
|
|
8814
9060
|
onVerdict(listener) {
|
|
8815
9061
|
this.listeners.push(listener);
|
|
8816
9062
|
}
|
|
@@ -8839,7 +9085,7 @@ var VerdictEngine = class {
|
|
|
8839
9085
|
}
|
|
8840
9086
|
/** Restart the silence timer on fresh output; broadcasts the null transition if it was idle. */
|
|
8841
9087
|
touchIdle(taskId, gen) {
|
|
8842
|
-
if (this.idlePaused.has(taskId)) return;
|
|
9088
|
+
if (this.idlePaused.has(taskId) || this.openApprovals.get(taskId)?.size) return;
|
|
8843
9089
|
const existing = this.idleTimers.get(taskId);
|
|
8844
9090
|
if (existing) clearTimeout(existing);
|
|
8845
9091
|
if (this.idleSince.get(taskId)) {
|
|
@@ -8921,7 +9167,10 @@ ${line}
|
|
|
8921
9167
|
});
|
|
8922
9168
|
if (isStructuredSession(session)) {
|
|
8923
9169
|
session.onEvent((event) => {
|
|
8924
|
-
if (
|
|
9170
|
+
if (this.generations.get(task.id) !== gen) return;
|
|
9171
|
+
if (event.type === "turn_start") this.resumeIdle(task.id);
|
|
9172
|
+
else if (event.type === "permission_request" && !event.decided) this.approvalOpened(task.id, event.id);
|
|
9173
|
+
else if (event.type === "permission_decided" || event.type === "permission_withdrawn") this.approvalClosed(task.id, event.id, gen);
|
|
8925
9174
|
});
|
|
8926
9175
|
}
|
|
8927
9176
|
session.onExit((exitCode) => {
|
|
@@ -8932,6 +9181,18 @@ ${line}
|
|
|
8932
9181
|
for (const l of this.listeners) l(task.id, verdict);
|
|
8933
9182
|
});
|
|
8934
9183
|
}
|
|
9184
|
+
approvalOpened(taskId, approvalId) {
|
|
9185
|
+
const open = this.openApprovals.get(taskId) ?? /* @__PURE__ */ new Set();
|
|
9186
|
+
open.add(approvalId);
|
|
9187
|
+
this.openApprovals.set(taskId, open);
|
|
9188
|
+
this.clearIdle(taskId);
|
|
9189
|
+
}
|
|
9190
|
+
approvalClosed(taskId, approvalId, gen) {
|
|
9191
|
+
const open = this.openApprovals.get(taskId);
|
|
9192
|
+
if (!open?.delete(approvalId) || open.size) return;
|
|
9193
|
+
this.openApprovals.delete(taskId);
|
|
9194
|
+
this.touchIdle(taskId, gen);
|
|
9195
|
+
}
|
|
8935
9196
|
/** Scan only the new text plus the unmatched carry, so a long run stays linear. */
|
|
8936
9197
|
scanCheckpoints(taskId, session, text) {
|
|
8937
9198
|
const scan = (this.checkpointCarry.get(taskId) ?? "") + text;
|
|
@@ -8953,6 +9214,7 @@ ${line}
|
|
|
8953
9214
|
this.checkpointCarry.delete(taskId);
|
|
8954
9215
|
this.pausedSessions.delete(taskId);
|
|
8955
9216
|
this.idlePaused.delete(taskId);
|
|
9217
|
+
this.openApprovals.delete(taskId);
|
|
8956
9218
|
this.clearIdle(taskId);
|
|
8957
9219
|
}
|
|
8958
9220
|
/** Manual "Mark complete" override: a pass verdict that bypasses evidence. */
|
|
@@ -8990,6 +9252,7 @@ ${line}
|
|
|
8990
9252
|
this.idleTimers.clear();
|
|
8991
9253
|
this.idleSince.clear();
|
|
8992
9254
|
this.idlePaused.clear();
|
|
9255
|
+
this.openApprovals.clear();
|
|
8993
9256
|
this.generations.clear();
|
|
8994
9257
|
}
|
|
8995
9258
|
decide(task, exitCode) {
|
|
@@ -9507,6 +9770,8 @@ var PlanStore = class {
|
|
|
9507
9770
|
_planTasks = [];
|
|
9508
9771
|
_allTasks = [];
|
|
9509
9772
|
_taskMap = /* @__PURE__ */ new Map();
|
|
9773
|
+
/** Task id → the top-level task it hangs under, itself for a top-level task. */
|
|
9774
|
+
_rootMap = /* @__PURE__ */ new Map();
|
|
9510
9775
|
_planRunners = ["claude-code"];
|
|
9511
9776
|
_onMutate = null;
|
|
9512
9777
|
_executionLog = [];
|
|
@@ -9544,6 +9809,14 @@ var PlanStore = class {
|
|
|
9544
9809
|
get(taskId) {
|
|
9545
9810
|
return this._taskMap.get(taskId);
|
|
9546
9811
|
}
|
|
9812
|
+
/**
|
|
9813
|
+
* Whether a task runs as an ops task (ADR-0020). Only a top-level AI task
|
|
9814
|
+
* can be one; a subtask runs with the top-level task it hangs under.
|
|
9815
|
+
*/
|
|
9816
|
+
isOps(taskId) {
|
|
9817
|
+
const root = this._rootMap.get(taskId);
|
|
9818
|
+
return root?.type === "ai" && root.ops === true;
|
|
9819
|
+
}
|
|
9547
9820
|
/** A copy of the task tree, detached from the store: later status changes do not reach it. */
|
|
9548
9821
|
snapshot() {
|
|
9549
9822
|
return cloneTasks(this._planTasks);
|
|
@@ -9638,6 +9911,7 @@ ${taskB.description}`,
|
|
|
9638
9911
|
assignedRunner: taskA.assignedRunner,
|
|
9639
9912
|
assignedModel: taskA.assignedModel,
|
|
9640
9913
|
taskMode: taskA.taskMode,
|
|
9914
|
+
ops: taskA.ops === true && taskB.ops === true,
|
|
9641
9915
|
order: Math.min(taskA.order, taskB.order)
|
|
9642
9916
|
});
|
|
9643
9917
|
const mergedId = merged.id;
|
|
@@ -9667,7 +9941,8 @@ ${taskB.description}`,
|
|
|
9667
9941
|
taskMode: spec.taskMode ?? original.taskMode,
|
|
9668
9942
|
autonomy: spec.autonomy ?? original.autonomy,
|
|
9669
9943
|
sliceType: spec.sliceType ?? original.sliceType,
|
|
9670
|
-
userStoriesCovered: spec.userStoriesCovered ?? original.userStoriesCovered
|
|
9944
|
+
userStoriesCovered: spec.userStoriesCovered ?? original.userStoriesCovered,
|
|
9945
|
+
ops: spec.ops ?? original.ops
|
|
9671
9946
|
}));
|
|
9672
9947
|
});
|
|
9673
9948
|
const tailId = finalized[finalized.length - 1].id;
|
|
@@ -9721,8 +9996,14 @@ ${taskB.description}`,
|
|
|
9721
9996
|
this.setStatus(id, "pending");
|
|
9722
9997
|
task.verdict = void 0;
|
|
9723
9998
|
task.outputSummary = void 0;
|
|
9999
|
+
delete task.forcedPastGate;
|
|
9724
10000
|
}
|
|
9725
10001
|
}
|
|
10002
|
+
/** Kept on the task: it was force-started past its merge gate, before these dependencies' work was merged (ADR-0020). */
|
|
10003
|
+
setForcedPastGate(id, dependencies) {
|
|
10004
|
+
const task = this._taskMap.get(id);
|
|
10005
|
+
if (task) task.forcedPastGate = dependencies;
|
|
10006
|
+
}
|
|
9726
10007
|
/** A reason outlives nothing: any status but `awaiting_user` drops it. */
|
|
9727
10008
|
setStatus(id, status) {
|
|
9728
10009
|
const task = this._taskMap.get(id);
|
|
@@ -9805,6 +10086,10 @@ ${taskB.description}`,
|
|
|
9805
10086
|
for (const task of this._allTasks) {
|
|
9806
10087
|
this._taskMap.set(task.id, task);
|
|
9807
10088
|
}
|
|
10089
|
+
this._rootMap.clear();
|
|
10090
|
+
for (const root of this._planTasks) {
|
|
10091
|
+
for (const task of flattenTasks([root])) this._rootMap.set(task.id, root);
|
|
10092
|
+
}
|
|
9808
10093
|
}
|
|
9809
10094
|
validateAssignedRunners() {
|
|
9810
10095
|
for (const task of this._allTasks) {
|
|
@@ -9858,9 +10143,9 @@ var IsolationRunController = class {
|
|
|
9858
10143
|
/** Copied paths already reported for the current run: every task gets the same copies. */
|
|
9859
10144
|
reportedCopies = /* @__PURE__ */ new Set();
|
|
9860
10145
|
/**
|
|
9861
|
-
* How the open run executes; null while no run is open
|
|
9862
|
-
*
|
|
9863
|
-
* is stopped.
|
|
10146
|
+
* How the open run executes; null while no run is open, `undecided` until
|
|
10147
|
+
* its first change task asks. A run is one Execute-Plan or one manual task
|
|
10148
|
+
* run, from its start until it settles or is stopped.
|
|
9864
10149
|
*/
|
|
9865
10150
|
mode = null;
|
|
9866
10151
|
/** Resolver task id → the conflicted task it resolves; see {@link linkResolver}. */
|
|
@@ -9884,6 +10169,10 @@ var IsolationRunController = class {
|
|
|
9884
10169
|
get isOpen() {
|
|
9885
10170
|
return this.mode !== null;
|
|
9886
10171
|
}
|
|
10172
|
+
/** The open run has decided whether it isolates. */
|
|
10173
|
+
get decided() {
|
|
10174
|
+
return this.mode === "isolated" || this.mode === "shared";
|
|
10175
|
+
}
|
|
9887
10176
|
/** The open run executes in worktrees. */
|
|
9888
10177
|
get isolating() {
|
|
9889
10178
|
return this.mode === "isolated" && this.run !== null;
|
|
@@ -9940,13 +10229,22 @@ var IsolationRunController = class {
|
|
|
9940
10229
|
this.tell("warn", `Could not prune leftover worktrees: ${err instanceof Error ? err.message : String(err)}`);
|
|
9941
10230
|
}
|
|
9942
10231
|
}
|
|
10232
|
+
/** Open a run if none is, leaving whether it isolates to its first change task. */
|
|
10233
|
+
open() {
|
|
10234
|
+
if (this.mode) return;
|
|
10235
|
+
this.mode = "undecided";
|
|
10236
|
+
this.listener.opened();
|
|
10237
|
+
}
|
|
9943
10238
|
/**
|
|
9944
|
-
*
|
|
9945
|
-
* worktrees
|
|
9946
|
-
*
|
|
10239
|
+
* Decide once per run, as its first change task starts, whether it executes
|
|
10240
|
+
* in worktrees (ADR-0020): an ops task needs none, so a run of only ops
|
|
10241
|
+
* tasks never asks. `resume` is what a dirty tree parks until the user
|
|
10242
|
+
* chooses how to go on. Resolves false while the run waits on that choice.
|
|
9947
10243
|
*/
|
|
9948
|
-
|
|
9949
|
-
|
|
10244
|
+
decide(resume) {
|
|
10245
|
+
this.open();
|
|
10246
|
+
if (this.decided) return Promise.resolve(true);
|
|
10247
|
+
if (this.blockedStart) return Promise.resolve(false);
|
|
9950
10248
|
this.opening ??= this.activate(resume).finally(() => {
|
|
9951
10249
|
this.opening = null;
|
|
9952
10250
|
});
|
|
@@ -9961,8 +10259,8 @@ var IsolationRunController = class {
|
|
|
9961
10259
|
* the safe rule then.
|
|
9962
10260
|
*/
|
|
9963
10261
|
async plannerLayout() {
|
|
9964
|
-
if (this.
|
|
9965
|
-
const decision = await this.
|
|
10262
|
+
if (this.decided) return this.isolating && this.run ? layoutOf(this.run) : false;
|
|
10263
|
+
const decision = await this.assess(this.workspaceRoot());
|
|
9966
10264
|
return decision.mode === "isolated" ? decision.layout : false;
|
|
9967
10265
|
}
|
|
9968
10266
|
/**
|
|
@@ -9997,7 +10295,7 @@ var IsolationRunController = class {
|
|
|
9997
10295
|
const { cwd: cwd2 } = await this.isolation.reopen(task, this.run);
|
|
9998
10296
|
return { cwd: cwd2, worktree: true };
|
|
9999
10297
|
}
|
|
10000
|
-
if (!this.isolating || !this.run) {
|
|
10298
|
+
if (opts.ops || !this.isolating || !this.run) {
|
|
10001
10299
|
await this.release(task.id, { keep: false });
|
|
10002
10300
|
return { cwd: this.workspaceRoot(), worktree: false };
|
|
10003
10301
|
}
|
|
@@ -10042,18 +10340,71 @@ var IsolationRunController = class {
|
|
|
10042
10340
|
}
|
|
10043
10341
|
this.listener.changed();
|
|
10044
10342
|
}
|
|
10045
|
-
/**
|
|
10343
|
+
/**
|
|
10344
|
+
* Close the open run. An isolated one hands its integration branch over for
|
|
10345
|
+
* review — unless the user merged everything it landed at merge gates
|
|
10346
|
+
* already, which leaves nothing to hand over, so it is cleared up as a Merge
|
|
10347
|
+
* all would have.
|
|
10348
|
+
*/
|
|
10046
10349
|
async close() {
|
|
10047
10350
|
const mode = this.mode;
|
|
10048
10351
|
this.mode = null;
|
|
10049
|
-
|
|
10352
|
+
const run = this.run;
|
|
10353
|
+
if (mode !== "isolated" || !run) return;
|
|
10354
|
+
await this.refreshInHead();
|
|
10355
|
+
const records = Object.values(run.tasks);
|
|
10356
|
+
if (records.length > 0 && records.every((r) => r.status === "merged" && r.inHead)) {
|
|
10357
|
+
this.tell("info", "Everything this run landed is merged into your branch already.");
|
|
10358
|
+
await this.clearMerged(run);
|
|
10359
|
+
return;
|
|
10360
|
+
}
|
|
10050
10361
|
try {
|
|
10051
|
-
this.listener.handoff(await this.isolation.handoff(
|
|
10362
|
+
this.listener.handoff(await this.isolation.handoff(run));
|
|
10052
10363
|
} catch (err) {
|
|
10053
10364
|
this.tell("warn", `Could not hand the run's integration branch over: ${err instanceof Error ? err.message : String(err)}`);
|
|
10054
10365
|
}
|
|
10055
10366
|
this.listener.changed();
|
|
10056
10367
|
}
|
|
10368
|
+
/**
|
|
10369
|
+
* Whether a task's landed work still waits to be merged into the user's
|
|
10370
|
+
* branch — what holds an ops or user task at its merge gate (ADR-0020). A
|
|
10371
|
+
* task with no record never landed anything to wait for.
|
|
10372
|
+
*/
|
|
10373
|
+
awaitsMerge(taskId) {
|
|
10374
|
+
const record = this.run?.tasks[taskId];
|
|
10375
|
+
return !!record && record.status === "merged" && !record.inHead;
|
|
10376
|
+
}
|
|
10377
|
+
/**
|
|
10378
|
+
* Look again for landed work the user has merged — by Merge all, or by hand
|
|
10379
|
+
* with git. Resolves true when some was found.
|
|
10380
|
+
*/
|
|
10381
|
+
async refreshInHead() {
|
|
10382
|
+
const run = this.run;
|
|
10383
|
+
if (!run || !Object.values(run.tasks).some((r) => r.status === "merged" && !r.inHead)) return false;
|
|
10384
|
+
const found = await this.isolation.findInHead(run).catch(() => []);
|
|
10385
|
+
if (found.length > 0) this.listener.changed();
|
|
10386
|
+
return found.length > 0;
|
|
10387
|
+
}
|
|
10388
|
+
/**
|
|
10389
|
+
* The tracked state of the workspace as an ops task starts, for
|
|
10390
|
+
* {@link filesChangedSince} (ADR-0020). Null where the check does not run:
|
|
10391
|
+
* a run that shares the workspace root, where every task's edits land in
|
|
10392
|
+
* it, or a workspace git cannot isolate at all.
|
|
10393
|
+
*/
|
|
10394
|
+
async snapshotWorkspace() {
|
|
10395
|
+
if (this.mode === "shared") return null;
|
|
10396
|
+
const root = this.workspaceRoot();
|
|
10397
|
+
if (!this.isolating) {
|
|
10398
|
+
const availability = await this.isolation.isActive(root).catch(() => null);
|
|
10399
|
+
if (!availability || !availability.active && availability.reason !== "dirty") return null;
|
|
10400
|
+
}
|
|
10401
|
+
return this.isolation.snapshotTree(root, this.run?.sharedRepos ?? []).catch(() => null);
|
|
10402
|
+
}
|
|
10403
|
+
/** Tracked files changed since `snapshot`; none when the run has since gone on in the workspace root. */
|
|
10404
|
+
async filesChangedSince(snapshot) {
|
|
10405
|
+
if (!snapshot || this.mode === "shared") return [];
|
|
10406
|
+
return this.isolation.changedSince(this.workspaceRoot(), snapshot).catch(() => []);
|
|
10407
|
+
}
|
|
10057
10408
|
/**
|
|
10058
10409
|
* A stop or a plan load: the open run ends without a handoff and a parked
|
|
10059
10410
|
* start is dropped. `keepOpen` spares a run the scheduler is still driving.
|
|
@@ -10084,19 +10435,23 @@ var IsolationRunController = class {
|
|
|
10084
10435
|
}
|
|
10085
10436
|
/**
|
|
10086
10437
|
* "Merge all": the run's integration branches into whatever the user has
|
|
10087
|
-
* checked out, in every repo or none.
|
|
10088
|
-
*
|
|
10089
|
-
*
|
|
10438
|
+
* checked out, in every repo or none. During a run it merges what has
|
|
10439
|
+
* landed so far and the run goes on (ADR-0020): its branches stay for the
|
|
10440
|
+
* tasks still to land. Once a settled run merged everything, it has nothing
|
|
10441
|
+
* left to hand over, so it is cleared up and forgotten; a branch the user's
|
|
10442
|
+
* HEAD somehow does not contain stays for the next run's sweep.
|
|
10090
10443
|
*/
|
|
10091
10444
|
async merge() {
|
|
10092
10445
|
const run = this.requireRun();
|
|
10446
|
+
const repaired = handoffOf(run).landed.filter((t) => t.repairedFiles?.length);
|
|
10093
10447
|
const result = await this.isolation.mergeIntoCheckedOut(run);
|
|
10094
10448
|
const branch = integrationBranchNameOf(run);
|
|
10095
10449
|
const group = run.repos.some((r) => r.path !== SELF_REPO);
|
|
10096
|
-
const repaired = handoffOf(run).landed.filter((t) => t.repairedFiles?.length);
|
|
10097
10450
|
const { level, message } = describeMergeResult(result, branch, group, repaired);
|
|
10098
10451
|
this.tell(level, message);
|
|
10099
|
-
if (result.outcome
|
|
10452
|
+
if (result.outcome !== "merged") return result;
|
|
10453
|
+
await this.refreshInHead();
|
|
10454
|
+
if (!this.isOpen) await this.clearMerged(run);
|
|
10100
10455
|
return result;
|
|
10101
10456
|
}
|
|
10102
10457
|
/** Worktrees and task branches go; the integration branch and the record stay for review and merge. */
|
|
@@ -10128,7 +10483,7 @@ var IsolationRunController = class {
|
|
|
10128
10483
|
this.resolvers = {};
|
|
10129
10484
|
this.listener.changed();
|
|
10130
10485
|
}
|
|
10131
|
-
async
|
|
10486
|
+
async assess(root) {
|
|
10132
10487
|
const availability = await this.isolation.isActive(root);
|
|
10133
10488
|
const continued = this.continuableRun(root);
|
|
10134
10489
|
const continuing = continued !== null;
|
|
@@ -10143,7 +10498,7 @@ var IsolationRunController = class {
|
|
|
10143
10498
|
}
|
|
10144
10499
|
async activate(resume) {
|
|
10145
10500
|
const root = this.workspaceRoot();
|
|
10146
|
-
const decision = await this.
|
|
10501
|
+
const decision = await this.assess(root);
|
|
10147
10502
|
if (decision.mode === "blocked") {
|
|
10148
10503
|
this.blockedStart = resume;
|
|
10149
10504
|
this.blockedRepos = decision.repos;
|
|
@@ -10170,8 +10525,8 @@ var IsolationRunController = class {
|
|
|
10170
10525
|
return true;
|
|
10171
10526
|
}
|
|
10172
10527
|
begin(mode) {
|
|
10528
|
+
this.open();
|
|
10173
10529
|
this.mode = mode;
|
|
10174
|
-
this.listener.opened();
|
|
10175
10530
|
}
|
|
10176
10531
|
/** What earlier runs left merged in the group goes; a failure here is worth a word, never a stopped run. */
|
|
10177
10532
|
async sweep() {
|
|
@@ -10518,8 +10873,9 @@ var MessageQueue = class {
|
|
|
10518
10873
|
// src/services/readiness.ts
|
|
10519
10874
|
function selectReadyTasks(input) {
|
|
10520
10875
|
const { store, onHold, runs, active, maxParallel } = input;
|
|
10521
|
-
if (active >= maxParallel) return { ready: [], candidateCount: 0, excluded: [] };
|
|
10876
|
+
if (active >= maxParallel) return { ready: [], candidateCount: 0, excluded: [], gated: [] };
|
|
10522
10877
|
const availableSlots = maxParallel - active;
|
|
10878
|
+
const gated = [];
|
|
10523
10879
|
const candidates = store.allTasks.filter((t) => {
|
|
10524
10880
|
if (t.status !== "pending" && t.status !== "approved") return false;
|
|
10525
10881
|
if (t.type === "user") return false;
|
|
@@ -10527,13 +10883,21 @@ function selectReadyTasks(input) {
|
|
|
10527
10883
|
if (onHold.has(t.id)) return false;
|
|
10528
10884
|
if (isBlocked(t, store)) return false;
|
|
10529
10885
|
if (!t.dependencies.every((depId) => dependencyMet(depId, store, runs))) return false;
|
|
10886
|
+
if (store.isOps(t.id)) {
|
|
10887
|
+
if (input.merging) return false;
|
|
10888
|
+
if (mergeGate(t, store, runs).length > 0) {
|
|
10889
|
+
gated.push(t);
|
|
10890
|
+
return false;
|
|
10891
|
+
}
|
|
10892
|
+
}
|
|
10530
10893
|
return true;
|
|
10531
10894
|
});
|
|
10532
|
-
const excluded = store.allTasks.filter((t) => t.type === "ai" && t.prompt && !candidates.includes(t)).map((task) => ({ task, reasons: exclusionReasons(task,
|
|
10895
|
+
const excluded = store.allTasks.filter((t) => t.type === "ai" && t.prompt && !candidates.includes(t)).map((task) => ({ task, reasons: exclusionReasons(task, input) }));
|
|
10533
10896
|
return {
|
|
10534
10897
|
ready: candidates.sort((a, b) => a.order - b.order).slice(0, availableSlots),
|
|
10535
10898
|
candidateCount: candidates.length,
|
|
10536
|
-
excluded
|
|
10899
|
+
excluded,
|
|
10900
|
+
gated
|
|
10537
10901
|
};
|
|
10538
10902
|
}
|
|
10539
10903
|
function isBlocked(task, store) {
|
|
@@ -10546,12 +10910,19 @@ function dependencyMet(depId, store, runs) {
|
|
|
10546
10910
|
const record = runs.openRecord(depId);
|
|
10547
10911
|
return !record || record.status === "merged";
|
|
10548
10912
|
}
|
|
10549
|
-
function
|
|
10913
|
+
function mergeGate(task, store, runs) {
|
|
10914
|
+
if (task.type !== "user" && !store.isOps(task.id)) return [];
|
|
10915
|
+
return task.dependencies.filter((depId) => store.isCompleted(depId) && runs.awaitsMerge(depId));
|
|
10916
|
+
}
|
|
10917
|
+
function exclusionReasons(task, input) {
|
|
10918
|
+
const { store, onHold, runs } = input;
|
|
10550
10919
|
const reasons = [];
|
|
10551
10920
|
if (task.status !== "pending" && task.status !== "approved") reasons.push(`status=${task.status}`);
|
|
10552
10921
|
if (onHold.has(task.id)) reasons.push("on-hold");
|
|
10553
10922
|
if (isBlocked(task, store)) reasons.push("blocked");
|
|
10554
10923
|
if (!task.dependencies.every((depId) => dependencyMet(depId, store, runs))) reasons.push("deps");
|
|
10924
|
+
if (mergeGate(task, store, runs).length > 0) reasons.push("merge-gate");
|
|
10925
|
+
if (input.merging && store.isOps(task.id)) reasons.push("merging");
|
|
10555
10926
|
return reasons;
|
|
10556
10927
|
}
|
|
10557
10928
|
|
|
@@ -12545,9 +12916,502 @@ ${item.text}` : item.text });
|
|
|
12545
12916
|
|
|
12546
12917
|
// src/services/harness/OpenCodeAdapter.ts
|
|
12547
12918
|
import { randomBytes as randomBytes2 } from "crypto";
|
|
12919
|
+
|
|
12920
|
+
// src/services/harness/OpenCodeV2.ts
|
|
12921
|
+
var STREAM_CONNECT_TIMEOUT_MS = 5e3;
|
|
12922
|
+
var ACTIVE_POLL_INTERVAL_MS = 1e3;
|
|
12923
|
+
var IDLE_POLLS_BEFORE_START = 3;
|
|
12924
|
+
var PLANNER_RULES = [
|
|
12925
|
+
{ action: "question", resource: "*", effect: "deny" },
|
|
12926
|
+
{ action: "edit", resource: "*", effect: "deny" }
|
|
12927
|
+
];
|
|
12928
|
+
var TASK_RULES = [{ action: "question", resource: "*", effect: "deny" }];
|
|
12929
|
+
var AUTO_APPROVALS = "auto";
|
|
12930
|
+
function newTurnState() {
|
|
12931
|
+
return { seen: /* @__PURE__ */ new Set(), toolNames: /* @__PURE__ */ new Map(), stepModels: /* @__PURE__ */ new Map(), textRuns: /* @__PURE__ */ new Map(), children: /* @__PURE__ */ new Map(), briefs: /* @__PURE__ */ new Map(), heldFrames: /* @__PURE__ */ new Map(), streamedMessages: /* @__PURE__ */ new Set() };
|
|
12932
|
+
}
|
|
12933
|
+
function splitModelId(id) {
|
|
12934
|
+
const slash = id.indexOf("/");
|
|
12935
|
+
if (slash <= 0 || slash === id.length - 1) return null;
|
|
12936
|
+
return { providerID: id.slice(0, slash), id: id.slice(slash + 1) };
|
|
12937
|
+
}
|
|
12938
|
+
function delay(ms, signal) {
|
|
12939
|
+
return new Promise((resolve7) => {
|
|
12940
|
+
const timer = setTimeout(resolve7, ms);
|
|
12941
|
+
timer.unref?.();
|
|
12942
|
+
signal?.addEventListener("abort", () => {
|
|
12943
|
+
clearTimeout(timer);
|
|
12944
|
+
resolve7();
|
|
12945
|
+
}, { once: true });
|
|
12946
|
+
});
|
|
12947
|
+
}
|
|
12948
|
+
function contentText(content) {
|
|
12949
|
+
return (content ?? []).filter((c) => c.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
|
|
12950
|
+
}
|
|
12951
|
+
function subagentDigest(output) {
|
|
12952
|
+
const inner = output.match(/<subagent[^>]*>\n?([\s\S]*?)\n?<\/subagent>/);
|
|
12953
|
+
return inner ? inner[1] : output;
|
|
12954
|
+
}
|
|
12955
|
+
function permissionReply(decision) {
|
|
12956
|
+
if (decision.decision === "allow") return { decision: "once" };
|
|
12957
|
+
if (decision.decision === "allowForTask") return { decision: "always" };
|
|
12958
|
+
const note = decision.note?.trim();
|
|
12959
|
+
return note ? { decision: "reject", message: note } : { decision: "reject" };
|
|
12960
|
+
}
|
|
12961
|
+
var OpenCodeV2 = class {
|
|
12962
|
+
constructor(host, opts, role) {
|
|
12963
|
+
this.host = host;
|
|
12964
|
+
this.opts = opts;
|
|
12965
|
+
this.role = role;
|
|
12966
|
+
this.agent = opts.kind === "task" ? opts.flags.permissionMode : "plan";
|
|
12967
|
+
const split = opts.model ? splitModelId(opts.model) : null;
|
|
12968
|
+
const variant = opts.kind === "task" ? opts.flags.effort : opts.effort;
|
|
12969
|
+
if (split) this.model = { ...split, ...variant ? { variant } : {} };
|
|
12970
|
+
}
|
|
12971
|
+
host;
|
|
12972
|
+
opts;
|
|
12973
|
+
role;
|
|
12974
|
+
sessionId = null;
|
|
12975
|
+
model = null;
|
|
12976
|
+
agent;
|
|
12977
|
+
turn = null;
|
|
12978
|
+
turnHasText = false;
|
|
12979
|
+
interruptRequested = false;
|
|
12980
|
+
/** A task's requests waiting for an answer, by request id, with the session that asked. */
|
|
12981
|
+
openPermissions = /* @__PURE__ */ new Map();
|
|
12982
|
+
nativeSessionId() {
|
|
12983
|
+
return this.sessionId;
|
|
12984
|
+
}
|
|
12985
|
+
async start() {
|
|
12986
|
+
const resume = this.opts.resumeSessionId;
|
|
12987
|
+
if (resume) {
|
|
12988
|
+
const existing = await this.host.json("GET", `/api/session/${resume}`).catch(() => null);
|
|
12989
|
+
if (existing?.data?.id) {
|
|
12990
|
+
this.sessionId = existing.data.id;
|
|
12991
|
+
if (this.model) await this.host.request("POST", `/api/session/${this.sessionId}/model`, { model: this.model }).catch(() => void 0);
|
|
12992
|
+
await this.host.request("POST", `/api/session/${this.sessionId}/agent`, { agent: this.agent }).catch(() => void 0);
|
|
12993
|
+
await this.applyInstructions();
|
|
12994
|
+
return;
|
|
12995
|
+
}
|
|
12996
|
+
if (this.opts.kind === "task") throw new Error(`OpenCode could not resume session ${resume}: the server does not know it.`);
|
|
12997
|
+
}
|
|
12998
|
+
const created = await this.host.json("POST", "/api/session", {
|
|
12999
|
+
agent: this.agent,
|
|
13000
|
+
...this.model ? { model: this.model } : {},
|
|
13001
|
+
permissions: this.role === "planner" ? PLANNER_RULES : TASK_RULES
|
|
13002
|
+
});
|
|
13003
|
+
if (!created?.data?.id) throw new Error(`The OpenCode ${this.role} server did not return a session id.`);
|
|
13004
|
+
this.sessionId = created.data.id;
|
|
13005
|
+
await this.applyInstructions();
|
|
13006
|
+
}
|
|
13007
|
+
/** The planner's system prompt rides on the session as an instruction entry — a prompt has no system field. */
|
|
13008
|
+
async applyInstructions() {
|
|
13009
|
+
if (this.opts.kind !== "planner" || !this.opts.systemPrompt) return;
|
|
13010
|
+
const response = await this.host.request("PUT", `/api/experimental/session/${this.sessionId}/instructions/entries/ordewell-planner`, { value: this.opts.systemPrompt });
|
|
13011
|
+
if (!response.ok) throw new Error(`OpenCode did not take the planner instructions: ${response.status} ${response.statusText}`);
|
|
13012
|
+
}
|
|
13013
|
+
async send(message, onEvent, signal, onActivity) {
|
|
13014
|
+
if (!this.sessionId) throw new Error(`OpenCode ${this.role} session is not started`);
|
|
13015
|
+
if (this.host.isExited()) {
|
|
13016
|
+
onEvent({ type: "error", message: this.host.exitMessage() });
|
|
13017
|
+
return;
|
|
13018
|
+
}
|
|
13019
|
+
const state = newTurnState();
|
|
13020
|
+
this.turnHasText = false;
|
|
13021
|
+
this.interruptRequested = false;
|
|
13022
|
+
let markDone = () => {
|
|
13023
|
+
};
|
|
13024
|
+
const ended = new Promise((resolve7) => {
|
|
13025
|
+
markDone = resolve7;
|
|
13026
|
+
});
|
|
13027
|
+
const turn = { live: false, done: false, outcome: null, failure: null, ended, finish: () => {
|
|
13028
|
+
turn.done = true;
|
|
13029
|
+
markDone();
|
|
13030
|
+
} };
|
|
13031
|
+
this.turn = turn;
|
|
13032
|
+
const closeStream = await this.openStream(state, turn, onEvent, onActivity);
|
|
13033
|
+
const poll = new AbortController();
|
|
13034
|
+
try {
|
|
13035
|
+
try {
|
|
13036
|
+
await this.host.json("POST", `/api/session/${this.sessionId}/prompt`, { text: message }, signal);
|
|
13037
|
+
} catch (err) {
|
|
13038
|
+
if (signal?.aborted) return;
|
|
13039
|
+
onEvent({ type: "error", message: `OpenCode did not take the message: ${err instanceof Error ? err.message : String(err)}` });
|
|
13040
|
+
return;
|
|
13041
|
+
}
|
|
13042
|
+
void this.pollActive(turn, poll.signal);
|
|
13043
|
+
const aborted = new Promise((resolve7) => {
|
|
13044
|
+
if (signal?.aborted) resolve7();
|
|
13045
|
+
signal?.addEventListener("abort", () => resolve7(), { once: true });
|
|
13046
|
+
});
|
|
13047
|
+
await Promise.race([turn.ended, aborted, this.host.processEnded]);
|
|
13048
|
+
if (signal?.aborted) return;
|
|
13049
|
+
if (!turn.done) {
|
|
13050
|
+
onEvent({ type: "error", message: this.host.exitMessage() });
|
|
13051
|
+
return;
|
|
13052
|
+
}
|
|
13053
|
+
await this.readBack(state, onEvent);
|
|
13054
|
+
for (const id of [...this.openPermissions.keys()]) {
|
|
13055
|
+
this.openPermissions.delete(id);
|
|
13056
|
+
onEvent({ type: "permission_cancelled", id });
|
|
13057
|
+
}
|
|
13058
|
+
if (turn.outcome === "interrupted" || this.interruptRequested) onEvent({ type: "turn_end", interrupted: true });
|
|
13059
|
+
else if (turn.outcome === "failed") onEvent({ type: "error", message: turn.failure ?? "OpenCode reported that the turn failed." });
|
|
13060
|
+
else onEvent({ type: "turn_end" });
|
|
13061
|
+
} finally {
|
|
13062
|
+
this.turn = null;
|
|
13063
|
+
poll.abort();
|
|
13064
|
+
await closeStream();
|
|
13065
|
+
}
|
|
13066
|
+
}
|
|
13067
|
+
/**
|
|
13068
|
+
* The stream can drop the end frame while still delivering the rest, so the
|
|
13069
|
+
* turn also asks which sessions are running. A session that is not, after
|
|
13070
|
+
* this turn's work was seen, is over; one never seen working after a few
|
|
13071
|
+
* polls had already finished before the stream connected.
|
|
13072
|
+
*/
|
|
13073
|
+
async pollActive(turn, signal) {
|
|
13074
|
+
let idlePolls = 0;
|
|
13075
|
+
while (!turn.done && !signal.aborted) {
|
|
13076
|
+
await delay(ACTIVE_POLL_INTERVAL_MS, signal);
|
|
13077
|
+
if (turn.done || signal.aborted) return;
|
|
13078
|
+
const active = await this.host.json("GET", "/api/session/active").catch(() => null);
|
|
13079
|
+
if (!active?.data) continue;
|
|
13080
|
+
if (this.sessionId && this.sessionId in active.data) {
|
|
13081
|
+
turn.live = true;
|
|
13082
|
+
idlePolls = 0;
|
|
13083
|
+
continue;
|
|
13084
|
+
}
|
|
13085
|
+
idlePolls++;
|
|
13086
|
+
if (turn.live || idlePolls >= IDLE_POLLS_BEFORE_START) {
|
|
13087
|
+
const messages = await this.messages();
|
|
13088
|
+
const idle = [...messages ?? []].reverse().find((m) => m.type === "idle");
|
|
13089
|
+
if (idle?.outcome === "failed" || idle?.outcome === "interrupted" || idle?.outcome === "succeeded") turn.outcome ??= idle.outcome;
|
|
13090
|
+
turn.outcome ??= "succeeded";
|
|
13091
|
+
if (!turn.done) turn.finish();
|
|
13092
|
+
}
|
|
13093
|
+
}
|
|
13094
|
+
}
|
|
13095
|
+
async messages() {
|
|
13096
|
+
const response = await this.host.json("GET", `/api/session/${this.sessionId}/message`).catch(() => null);
|
|
13097
|
+
return Array.isArray(response?.data) ? response.data : null;
|
|
13098
|
+
}
|
|
13099
|
+
/**
|
|
13100
|
+
* Whatever of the turn's own replies the stream missed. A message whose text
|
|
13101
|
+
* the stream delivered adds nothing here; one that lost its text frames still
|
|
13102
|
+
* reaches the plain-text channel, where the done marker is looked for.
|
|
13103
|
+
*/
|
|
13104
|
+
async readBack(state, onEvent) {
|
|
13105
|
+
const messages = await this.messages();
|
|
13106
|
+
if (!messages) return;
|
|
13107
|
+
const latestUser = messages.findIndex((m) => m.type === "user");
|
|
13108
|
+
const own = latestUser < 0 ? messages : messages.slice(0, latestUser);
|
|
13109
|
+
for (const message of [...own].reverse()) {
|
|
13110
|
+
if (message.type !== "assistant" || !message.id || state.streamedMessages.has(message.id)) continue;
|
|
13111
|
+
(message.content ?? []).forEach((part, index) => {
|
|
13112
|
+
if (part.type === "text" && part.text) this.emitText(`${message.id}:readback:${index}`, part.text, state, onEvent);
|
|
13113
|
+
});
|
|
13114
|
+
}
|
|
13115
|
+
}
|
|
13116
|
+
/**
|
|
13117
|
+
* Abort the running turn, keeping the server and its session. OpenCode
|
|
13118
|
+
* acknowledges with `session.execution.interrupted`, which also ends the turn.
|
|
13119
|
+
*/
|
|
13120
|
+
async interrupt(timeoutMs) {
|
|
13121
|
+
if (!this.sessionId || this.host.isExited()) return false;
|
|
13122
|
+
const turn = this.turn;
|
|
13123
|
+
this.interruptRequested = true;
|
|
13124
|
+
const response = await this.host.json("POST", `/api/session/${this.sessionId}/interrupt`).catch(() => null);
|
|
13125
|
+
if (!response) return false;
|
|
13126
|
+
if (!turn || turn.done) return true;
|
|
13127
|
+
let timer;
|
|
13128
|
+
const timedOut = new Promise((resolve7) => {
|
|
13129
|
+
timer = setTimeout(() => resolve7(false), timeoutMs);
|
|
13130
|
+
timer.unref?.();
|
|
13131
|
+
});
|
|
13132
|
+
const acknowledged = await Promise.race([turn.ended.then(() => true), this.host.processEnded.then(() => false), timedOut]);
|
|
13133
|
+
clearTimeout(timer);
|
|
13134
|
+
return acknowledged;
|
|
13135
|
+
}
|
|
13136
|
+
answerPermission(id, decision) {
|
|
13137
|
+
const sessionId = this.openPermissions.get(id);
|
|
13138
|
+
if (sessionId === void 0) return false;
|
|
13139
|
+
this.openPermissions.delete(id);
|
|
13140
|
+
void this.replyPermission(id, sessionId, permissionReply(decision)).catch(() => {
|
|
13141
|
+
});
|
|
13142
|
+
return true;
|
|
13143
|
+
}
|
|
13144
|
+
async replyPermission(id, sessionId, reply) {
|
|
13145
|
+
await this.host.request("POST", `/api/session/${sessionId}/permission/${id}/reply`, reply);
|
|
13146
|
+
}
|
|
13147
|
+
async openStream(state, turn, onEvent, onActivity) {
|
|
13148
|
+
const streamAbort = new AbortController();
|
|
13149
|
+
let connected = () => {
|
|
13150
|
+
};
|
|
13151
|
+
const ready = new Promise((resolve7) => {
|
|
13152
|
+
connected = resolve7;
|
|
13153
|
+
});
|
|
13154
|
+
const live = this.streamEvents(streamAbort.signal, (frame) => this.onFrame(frame, state, turn, onEvent), connected, onActivity);
|
|
13155
|
+
await Promise.race([ready, new Promise((r) => {
|
|
13156
|
+
const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS);
|
|
13157
|
+
t.unref?.();
|
|
13158
|
+
})]);
|
|
13159
|
+
return async () => {
|
|
13160
|
+
streamAbort.abort();
|
|
13161
|
+
await live.catch(() => {
|
|
13162
|
+
});
|
|
13163
|
+
};
|
|
13164
|
+
}
|
|
13165
|
+
async streamEvents(signal, onFrame, onConnected, onActivity) {
|
|
13166
|
+
const response = await this.host.openEvents(signal).catch(() => null);
|
|
13167
|
+
const body = response?.body;
|
|
13168
|
+
if (!body) {
|
|
13169
|
+
onConnected();
|
|
13170
|
+
return;
|
|
13171
|
+
}
|
|
13172
|
+
onConnected();
|
|
13173
|
+
const reader = body.getReader();
|
|
13174
|
+
const decoder = new TextDecoder();
|
|
13175
|
+
let buffer = "";
|
|
13176
|
+
for (; ; ) {
|
|
13177
|
+
const { done, value } = await reader.read().catch(() => ({ done: true, value: void 0 }));
|
|
13178
|
+
if (done) return;
|
|
13179
|
+
onActivity?.();
|
|
13180
|
+
buffer += decoder.decode(value, { stream: true });
|
|
13181
|
+
let newline;
|
|
13182
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
13183
|
+
const line = buffer.slice(0, newline).trim();
|
|
13184
|
+
buffer = buffer.slice(newline + 1);
|
|
13185
|
+
if (!line.startsWith("data:")) continue;
|
|
13186
|
+
try {
|
|
13187
|
+
onFrame(JSON.parse(line.slice(5).trim()));
|
|
13188
|
+
} catch {
|
|
13189
|
+
}
|
|
13190
|
+
}
|
|
13191
|
+
}
|
|
13192
|
+
}
|
|
13193
|
+
/**
|
|
13194
|
+
* One frame. Only this session and its children are followed: the stream is
|
|
13195
|
+
* the whole server's, and another client's session is none of this turn's business.
|
|
13196
|
+
*/
|
|
13197
|
+
onFrame(frame, state, turn, onEvent) {
|
|
13198
|
+
const data = frame.data;
|
|
13199
|
+
if (!data || !frame.type) return;
|
|
13200
|
+
if (frame.type === "session.created") {
|
|
13201
|
+
if (data.parentID && data.parentID === this.sessionId && data.sessionID && !state.children.has(data.sessionID)) state.children.set(data.sessionID, null);
|
|
13202
|
+
return;
|
|
13203
|
+
}
|
|
13204
|
+
const session = data.sessionID;
|
|
13205
|
+
if (!session) return;
|
|
13206
|
+
const isChild = session !== this.sessionId;
|
|
13207
|
+
if (isChild && !state.children.has(session)) return;
|
|
13208
|
+
if (!isChild) {
|
|
13209
|
+
if (frame.type === "session.execution.started") {
|
|
13210
|
+
turn.live = true;
|
|
13211
|
+
return;
|
|
13212
|
+
}
|
|
13213
|
+
if (frame.type === "session.execution.succeeded" || frame.type === "session.execution.failed" || frame.type === "session.execution.interrupted") {
|
|
13214
|
+
if (!turn.live || turn.done) return;
|
|
13215
|
+
turn.outcome = frame.type === "session.execution.succeeded" ? "succeeded" : frame.type === "session.execution.failed" ? "failed" : "interrupted";
|
|
13216
|
+
if (turn.outcome === "failed") turn.failure = data.error?.message ?? data.error?.type ?? null;
|
|
13217
|
+
turn.finish();
|
|
13218
|
+
return;
|
|
13219
|
+
}
|
|
13220
|
+
}
|
|
13221
|
+
if (frame.type === "permission.asked") {
|
|
13222
|
+
if (this.role === "task") this.askPermission(data, session, state, onEvent);
|
|
13223
|
+
else this.denyPermission(data, session, state, onEvent);
|
|
13224
|
+
return;
|
|
13225
|
+
}
|
|
13226
|
+
if (frame.type === "permission.replied") {
|
|
13227
|
+
const id = data.requestID;
|
|
13228
|
+
if (id && this.openPermissions.delete(id)) onEvent({ type: "permission_cancelled", id });
|
|
13229
|
+
return;
|
|
13230
|
+
}
|
|
13231
|
+
let subagentId;
|
|
13232
|
+
if (isChild) {
|
|
13233
|
+
const owner = state.children.get(session);
|
|
13234
|
+
if (!owner) {
|
|
13235
|
+
state.heldFrames.set(session, [...state.heldFrames.get(session) ?? [], frame]);
|
|
13236
|
+
return;
|
|
13237
|
+
}
|
|
13238
|
+
subagentId = owner;
|
|
13239
|
+
}
|
|
13240
|
+
this.onContentFrame(frame, data, state, turn, onEvent, subagentId);
|
|
13241
|
+
}
|
|
13242
|
+
onContentFrame(frame, data, state, turn, onEvent, subagentId) {
|
|
13243
|
+
const message = data.assistantMessageID ?? "";
|
|
13244
|
+
switch (frame.type) {
|
|
13245
|
+
case "session.step.started":
|
|
13246
|
+
if (message && data.model?.providerID && data.model.id) state.stepModels.set(message, `${data.model.providerID}/${data.model.id}`);
|
|
13247
|
+
return;
|
|
13248
|
+
case "session.reasoning.delta":
|
|
13249
|
+
if (data.delta) onEvent({ type: "thinking_delta", text: data.delta, subagentId });
|
|
13250
|
+
return;
|
|
13251
|
+
case "session.reasoning.ended": {
|
|
13252
|
+
const key = `reasoning:${message}:${data.ordinal ?? 0}`;
|
|
13253
|
+
if (!data.text || state.seen.has(key)) return;
|
|
13254
|
+
state.seen.add(key);
|
|
13255
|
+
onEvent({ type: "thinking", text: data.text, subagentId });
|
|
13256
|
+
return;
|
|
13257
|
+
}
|
|
13258
|
+
case "session.text.delta":
|
|
13259
|
+
if (!subagentId && data.delta) this.onTextDelta(`${message}:${data.ordinal ?? 0}`, data.delta, state, onEvent);
|
|
13260
|
+
return;
|
|
13261
|
+
case "session.text.ended":
|
|
13262
|
+
if (subagentId || !data.text) return;
|
|
13263
|
+
if (message) state.streamedMessages.add(message);
|
|
13264
|
+
this.emitText(`${message}:${data.ordinal ?? 0}`, data.text, state, onEvent);
|
|
13265
|
+
return;
|
|
13266
|
+
case "session.tool.input.started":
|
|
13267
|
+
if (data.id && data.name) state.toolNames.set(data.id, data.name);
|
|
13268
|
+
return;
|
|
13269
|
+
case "session.tool.called": {
|
|
13270
|
+
const id = data.id;
|
|
13271
|
+
if (!id || state.seen.has(`call:${id}`)) return;
|
|
13272
|
+
state.seen.add(`call:${id}`);
|
|
13273
|
+
const input = data.input ?? {};
|
|
13274
|
+
const name = state.toolNames.get(id) ?? "tool";
|
|
13275
|
+
const brief = typeof input.description === "string" ? input.description : typeof input.prompt === "string" ? input.prompt : "";
|
|
13276
|
+
state.briefs.set(id, brief);
|
|
13277
|
+
onEvent({ type: "tool_call", id, name, args: input, subagentId });
|
|
13278
|
+
return;
|
|
13279
|
+
}
|
|
13280
|
+
case "session.tool.progress":
|
|
13281
|
+
if (!subagentId) this.adoptChild(data, state, onEvent, turn);
|
|
13282
|
+
return;
|
|
13283
|
+
case "session.tool.success":
|
|
13284
|
+
case "session.tool.failed": {
|
|
13285
|
+
const id = data.id;
|
|
13286
|
+
if (!id || state.seen.has(`result:${id}`)) return;
|
|
13287
|
+
state.seen.add(`result:${id}`);
|
|
13288
|
+
const success = frame.type === "session.tool.success";
|
|
13289
|
+
const output = success ? contentText(data.content) : data.error?.message ?? data.error?.type ?? "";
|
|
13290
|
+
onEvent({ type: "tool_result", id, name: state.toolNames.get(id) ?? "tool", output, success, subagentId });
|
|
13291
|
+
if (!subagentId) this.finishChild(id, success, output, state, onEvent);
|
|
13292
|
+
return;
|
|
13293
|
+
}
|
|
13294
|
+
case "session.step.ended":
|
|
13295
|
+
case "session.step.failed":
|
|
13296
|
+
this.countUsage(data, state, onEvent, subagentId);
|
|
13297
|
+
return;
|
|
13298
|
+
default:
|
|
13299
|
+
}
|
|
13300
|
+
}
|
|
13301
|
+
adoptChild(data, state, onEvent, turn) {
|
|
13302
|
+
const child = data.metadata?.sessionID;
|
|
13303
|
+
const callId = data.id;
|
|
13304
|
+
if (!child || !callId || state.children.get(child)) return;
|
|
13305
|
+
const model = state.stepModels.get(data.assistantMessageID ?? "");
|
|
13306
|
+
onEvent({ type: "subagent_started", subagentId: callId, brief: state.briefs.get(callId) ?? "", ...model ? { model } : {} });
|
|
13307
|
+
state.children.set(child, callId);
|
|
13308
|
+
const held = state.heldFrames.get(child) ?? [];
|
|
13309
|
+
state.heldFrames.delete(child);
|
|
13310
|
+
for (const frame of held) this.onFrame(frame, state, turn, onEvent);
|
|
13311
|
+
}
|
|
13312
|
+
finishChild(callId, success, output, state, onEvent) {
|
|
13313
|
+
if (![...state.children.values()].includes(callId)) return;
|
|
13314
|
+
onEvent({ type: "subagent_finished", subagentId: callId, outcome: success ? "done" : "failed", digest: subagentDigest(output) });
|
|
13315
|
+
}
|
|
13316
|
+
/** One model call's usage, once, when its step ends. Zeros mean the call failed before the provider answered. */
|
|
13317
|
+
countUsage(data, state, onEvent, subagentId) {
|
|
13318
|
+
const message = data.assistantMessageID;
|
|
13319
|
+
const tokens = data.tokens;
|
|
13320
|
+
if (!message || !tokens || state.seen.has(`usage:${message}`)) return;
|
|
13321
|
+
state.seen.add(`usage:${message}`);
|
|
13322
|
+
const prompt = partedPromptUsage({ uncached: tokens.input, cacheRead: tokens.cache?.read, cacheWrite: tokens.cache?.write });
|
|
13323
|
+
const outputTokens = (tokens.output ?? 0) + (tokens.reasoning ?? 0);
|
|
13324
|
+
if ((prompt.inputTokens ?? 0) + outputTokens === 0) return;
|
|
13325
|
+
const record = { source: "opencode", ...prompt, outputTokens };
|
|
13326
|
+
const model = state.stepModels.get(message);
|
|
13327
|
+
if (model) record.model = model;
|
|
13328
|
+
if (typeof data.cost === "number" && data.cost > 0) record.reportedCost = { amount: data.cost, currency: "USD" };
|
|
13329
|
+
if (subagentId) record.subagentId = subagentId;
|
|
13330
|
+
onEvent({ type: "usage", record });
|
|
13331
|
+
}
|
|
13332
|
+
/** One complete reply text, once. A message can carry text on both sides of a tool call, so each run after the first opens a paragraph. */
|
|
13333
|
+
emitText(id, text, state, onEvent) {
|
|
13334
|
+
if (state.seen.has(`text:${id}`)) return;
|
|
13335
|
+
state.seen.add(`text:${id}`);
|
|
13336
|
+
if (!text.trim()) return;
|
|
13337
|
+
const lead = state.textRuns.get(id)?.lead ?? (this.turnHasText ? "\n\n" : "");
|
|
13338
|
+
onEvent({ type: "assistant_text", text: `${lead}${text}` });
|
|
13339
|
+
this.turnHasText = true;
|
|
13340
|
+
}
|
|
13341
|
+
/**
|
|
13342
|
+
* Stream one piece of a reply text. The paragraph break goes out with the
|
|
13343
|
+
* first visible delta, so the deltas add up to exactly the text the completed
|
|
13344
|
+
* run then re-sends; a run that is only whitespace so far is held back.
|
|
13345
|
+
*/
|
|
13346
|
+
onTextDelta(id, delta, state, onEvent) {
|
|
13347
|
+
if (state.seen.has(`text:${id}`)) return;
|
|
13348
|
+
const run = state.textRuns.get(id) ?? { held: "", lead: null };
|
|
13349
|
+
state.textRuns.set(id, run);
|
|
13350
|
+
if (run.lead !== null) {
|
|
13351
|
+
onEvent({ type: "assistant_text_delta", text: delta });
|
|
13352
|
+
return;
|
|
13353
|
+
}
|
|
13354
|
+
run.held += delta;
|
|
13355
|
+
if (!run.held.trim()) return;
|
|
13356
|
+
run.lead = this.turnHasText ? "\n\n" : "";
|
|
13357
|
+
this.turnHasText = true;
|
|
13358
|
+
onEvent({ type: "assistant_text_delta", text: `${run.lead}${run.held}` });
|
|
13359
|
+
}
|
|
13360
|
+
permissionRequest(ask) {
|
|
13361
|
+
const id = ask.id;
|
|
13362
|
+
if (!id) return null;
|
|
13363
|
+
const scope = (ask.resources ?? []).join(", ");
|
|
13364
|
+
return {
|
|
13365
|
+
type: "permission_request",
|
|
13366
|
+
id,
|
|
13367
|
+
name: ask.action ?? "permission",
|
|
13368
|
+
detail: JSON.stringify(scope ? { scope } : {}),
|
|
13369
|
+
input: ask.resources ? { resources: ask.resources } : {},
|
|
13370
|
+
// The patterns `always` would grant: the runner's own offer, and the only grounds for "Allow for this task".
|
|
13371
|
+
...ask.save?.length ? { suggestions: ask.save } : {},
|
|
13372
|
+
...ask.source?.id ? { toolUseId: ask.source.id } : {}
|
|
13373
|
+
};
|
|
13374
|
+
}
|
|
13375
|
+
/**
|
|
13376
|
+
* Deny one planner request. OpenCode blocks the turn until it is answered, so
|
|
13377
|
+
* this must answer: `reject` rather than a silent drop is the "absent answer
|
|
13378
|
+
* is a denial" invariant ADR-0008 states. The refusal is announced so the
|
|
13379
|
+
* timeline shows the planner reaching for something it may not have.
|
|
13380
|
+
*/
|
|
13381
|
+
denyPermission(ask, session, state, onEvent) {
|
|
13382
|
+
const request = this.permissionRequest(ask);
|
|
13383
|
+
if (!request || state.seen.has(`perm:${request.id}`)) return;
|
|
13384
|
+
state.seen.add(`perm:${request.id}`);
|
|
13385
|
+
onEvent(request);
|
|
13386
|
+
void this.replyPermission(request.id, session, { decision: "reject" }).catch(() => {
|
|
13387
|
+
});
|
|
13388
|
+
}
|
|
13389
|
+
/**
|
|
13390
|
+
* A task's request. Under a mode whose manifest sets `approvals: auto` it is
|
|
13391
|
+
* answered at once with what `opencode run --auto` answers, so the same plan
|
|
13392
|
+
* behaves the same on both transports (ADR-0001), and announced already
|
|
13393
|
+
* decided so the log still shows it. Any other mode leaves it open for an
|
|
13394
|
+
* approval card.
|
|
13395
|
+
*/
|
|
13396
|
+
askPermission(ask, session, state, onEvent) {
|
|
13397
|
+
const request = this.permissionRequest(ask);
|
|
13398
|
+
if (!request || state.seen.has(`perm:${request.id}`)) return;
|
|
13399
|
+
state.seen.add(`perm:${request.id}`);
|
|
13400
|
+
if (this.opts.kind === "task" && this.opts.flags.modeSettings.approvals === AUTO_APPROVALS) {
|
|
13401
|
+
onEvent({ ...request, decided: { decision: "allow" } });
|
|
13402
|
+
void this.replyPermission(request.id, session, { decision: "once" }).catch(() => {
|
|
13403
|
+
});
|
|
13404
|
+
return;
|
|
13405
|
+
}
|
|
13406
|
+
this.openPermissions.set(request.id, session);
|
|
13407
|
+
onEvent(request);
|
|
13408
|
+
}
|
|
13409
|
+
};
|
|
13410
|
+
|
|
13411
|
+
// src/services/harness/OpenCodeAdapter.ts
|
|
12548
13412
|
var SERVER_READY_TIMEOUT_MS = 3e4;
|
|
12549
13413
|
var STDERR_TAIL_CHARS2 = 4e3;
|
|
12550
|
-
var
|
|
13414
|
+
var STREAM_CONNECT_TIMEOUT_MS2 = 5e3;
|
|
12551
13415
|
var RECOVERY_POLL_INTERVAL_MS = 2e3;
|
|
12552
13416
|
var RECOVERY_TIMEOUT_MS = 9e5;
|
|
12553
13417
|
var STATUS_POLL_INTERVAL_MS = 1e3;
|
|
@@ -12560,8 +13424,8 @@ var DISABLED_TOOLS = {
|
|
|
12560
13424
|
todowrite: false
|
|
12561
13425
|
};
|
|
12562
13426
|
var TASK_DISABLED_TOOLS = { question: false };
|
|
12563
|
-
var
|
|
12564
|
-
function
|
|
13427
|
+
var AUTO_APPROVALS2 = "auto";
|
|
13428
|
+
function splitModelId2(id) {
|
|
12565
13429
|
const slash = id.indexOf("/");
|
|
12566
13430
|
if (slash <= 0 || slash === id.length - 1) return null;
|
|
12567
13431
|
return { providerID: id.slice(0, slash), modelID: id.slice(slash + 1) };
|
|
@@ -12617,7 +13481,7 @@ function unwrapFileToolOutput(output) {
|
|
|
12617
13481
|
const inner = output.match(/<content>\n?([\s\S]*?)\n?<\/content>/);
|
|
12618
13482
|
return inner ? inner[1] : output;
|
|
12619
13483
|
}
|
|
12620
|
-
function
|
|
13484
|
+
function delay2(ms, signal) {
|
|
12621
13485
|
return new Promise((resolve7) => {
|
|
12622
13486
|
const timer = setTimeout(resolve7, ms);
|
|
12623
13487
|
timer.unref?.();
|
|
@@ -12627,10 +13491,10 @@ function delay(ms, signal) {
|
|
|
12627
13491
|
}, { once: true });
|
|
12628
13492
|
});
|
|
12629
13493
|
}
|
|
12630
|
-
function
|
|
13494
|
+
function newTurnState2() {
|
|
12631
13495
|
return { seen: /* @__PURE__ */ new Set(), assistantMessages: /* @__PURE__ */ new Set(), partTypes: /* @__PURE__ */ new Map(), textRuns: /* @__PURE__ */ new Map(), children: /* @__PURE__ */ new Map(), heldFrames: /* @__PURE__ */ new Map() };
|
|
12632
13496
|
}
|
|
12633
|
-
function
|
|
13497
|
+
function permissionReply2(decision) {
|
|
12634
13498
|
if (decision.decision === "allow") return { reply: "once" };
|
|
12635
13499
|
if (decision.decision === "allowForTask") return { reply: "always" };
|
|
12636
13500
|
const note = decision.note?.trim();
|
|
@@ -12667,6 +13531,8 @@ var OpenCodeAdapter = class {
|
|
|
12667
13531
|
interruptRequested = false;
|
|
12668
13532
|
/** A task's requests waiting for an answer, by request id, with the session that asked. */
|
|
12669
13533
|
openPermissions = /* @__PURE__ */ new Map();
|
|
13534
|
+
/** Set once the server turns out to speak the 2.x API, which then owns the session. */
|
|
13535
|
+
v2 = null;
|
|
12670
13536
|
async start(opts) {
|
|
12671
13537
|
if (opts.kind === "task") this.task = opts;
|
|
12672
13538
|
else this.planner = opts;
|
|
@@ -12681,14 +13547,14 @@ var OpenCodeAdapter = class {
|
|
|
12681
13547
|
throw new ExecutableNotFoundError("opencode", PATH);
|
|
12682
13548
|
}
|
|
12683
13549
|
const workspaceEnv = await (this.deps.workspaceEnv ?? workspaceEnvOf)(opts.cwd);
|
|
12684
|
-
const password =
|
|
13550
|
+
const password = randomBytes2(24).toString("base64url");
|
|
12685
13551
|
this.process = this.deps.spawn(launch.file, launch.args, {
|
|
12686
|
-
env: runnerEnv(PATH,
|
|
13552
|
+
env: runnerEnv(PATH, { ...workspaceEnv, OPENCODE_SERVER_USERNAME: SERVER_USERNAME, OPENCODE_SERVER_PASSWORD: password }),
|
|
12687
13553
|
stdio: ["pipe", "pipe", "pipe"],
|
|
12688
13554
|
cwd: opts.cwd,
|
|
12689
13555
|
windowsVerbatimArguments: launch.verbatim
|
|
12690
13556
|
});
|
|
12691
|
-
|
|
13557
|
+
this.authorization = `Basic ${Buffer.from(`${SERVER_USERNAME}:${password}`).toString("base64")}`;
|
|
12692
13558
|
this.process.stdin?.on("error", () => {
|
|
12693
13559
|
});
|
|
12694
13560
|
const banner = new Promise((resolve7) => {
|
|
@@ -12725,6 +13591,24 @@ ${err.message}`).slice(-STDERR_TAIL_CHARS2);
|
|
|
12725
13591
|
|
|
12726
13592
|
${this.stderrTail.trim()}` : ""}`);
|
|
12727
13593
|
}
|
|
13594
|
+
if (await this.speaksV2()) {
|
|
13595
|
+
this.v2 = new OpenCodeV2({
|
|
13596
|
+
request: (method, path26, body, signal) => this.request(method, path26, body, signal),
|
|
13597
|
+
json: (method, path26, body, signal) => this.json(method, path26, body, signal),
|
|
13598
|
+
openEvents: (signal) => this.deps.fetch(`${this.baseUrl}/api/event`, { signal, headers: this.headers() }),
|
|
13599
|
+
processEnded: this.processEnded,
|
|
13600
|
+
isExited: () => this.exited,
|
|
13601
|
+
exitMessage: () => this.exitMessage()
|
|
13602
|
+
}, opts, this.role());
|
|
13603
|
+
try {
|
|
13604
|
+
await this.v2.start();
|
|
13605
|
+
} catch (err) {
|
|
13606
|
+
this.dispose();
|
|
13607
|
+
throw err;
|
|
13608
|
+
}
|
|
13609
|
+
this.sessionId = this.v2.nativeSessionId();
|
|
13610
|
+
return;
|
|
13611
|
+
}
|
|
12728
13612
|
if (opts.resumeSessionId) {
|
|
12729
13613
|
const existing = await this.json("GET", `/session/${opts.resumeSessionId}`).catch(() => null);
|
|
12730
13614
|
if (existing?.id) {
|
|
@@ -12748,12 +13632,17 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
12748
13632
|
onEvent({ type: "error", message: this.exitMessage() });
|
|
12749
13633
|
return;
|
|
12750
13634
|
}
|
|
13635
|
+
if (this.v2) {
|
|
13636
|
+
await this.v2.send(message, onEvent, signal, onActivity);
|
|
13637
|
+
if (signal?.aborted) this.dispose();
|
|
13638
|
+
return;
|
|
13639
|
+
}
|
|
12751
13640
|
if (this.task) return this.sendTask(this.task, message, onEvent, signal, onActivity);
|
|
12752
|
-
const turn =
|
|
13641
|
+
const turn = newTurnState2();
|
|
12753
13642
|
this.turnHasText = false;
|
|
12754
13643
|
const closeStream = await this.openStream(turn, onEvent, onActivity);
|
|
12755
13644
|
try {
|
|
12756
|
-
const model = this.planner?.model ?
|
|
13645
|
+
const model = this.planner?.model ? splitModelId2(this.planner.model) : null;
|
|
12757
13646
|
const body = {
|
|
12758
13647
|
parts: [{ type: "text", text: message }],
|
|
12759
13648
|
// The read-only guarantee: OpenCode's own plan agent has no write tools.
|
|
@@ -12794,7 +13683,7 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
12794
13683
|
* {@link recoverReply}, never applies.
|
|
12795
13684
|
*/
|
|
12796
13685
|
async sendTask(task, message, onEvent, signal, onActivity) {
|
|
12797
|
-
const state =
|
|
13686
|
+
const state = newTurnState2();
|
|
12798
13687
|
this.turnHasText = false;
|
|
12799
13688
|
this.interruptRequested = false;
|
|
12800
13689
|
let markDone = () => {
|
|
@@ -12810,7 +13699,7 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
12810
13699
|
const closeStream = await this.openStream(state, onEvent, onActivity);
|
|
12811
13700
|
const poll = new AbortController();
|
|
12812
13701
|
try {
|
|
12813
|
-
const model = task.model ?
|
|
13702
|
+
const model = task.model ? splitModelId2(task.model) : null;
|
|
12814
13703
|
const body = {
|
|
12815
13704
|
parts: [{ type: "text", text: message }],
|
|
12816
13705
|
// The manifest's meaning of the task's mode (ADR-0001): for OpenCode a mode is an agent.
|
|
@@ -12890,7 +13779,7 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
12890
13779
|
*/
|
|
12891
13780
|
async pollStatus(turn, signal) {
|
|
12892
13781
|
while (!turn.done && !signal.aborted) {
|
|
12893
|
-
await
|
|
13782
|
+
await delay2(STATUS_POLL_INTERVAL_MS, signal);
|
|
12894
13783
|
if (turn.done || signal.aborted) return;
|
|
12895
13784
|
const busy = await this.sessionBusy();
|
|
12896
13785
|
if (busy === true) turn.live = true;
|
|
@@ -12937,6 +13826,7 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
12937
13826
|
*/
|
|
12938
13827
|
async interrupt(timeoutMs) {
|
|
12939
13828
|
if (!this.process || !this.sessionId || this.exited) return false;
|
|
13829
|
+
if (this.v2) return this.v2.interrupt(timeoutMs);
|
|
12940
13830
|
const turn = this.taskTurn;
|
|
12941
13831
|
this.interruptRequested = true;
|
|
12942
13832
|
const posted = await this.json("POST", `/session/${this.sessionId}/abort`).then(() => true, () => false);
|
|
@@ -12957,13 +13847,23 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
12957
13847
|
void this.processEnded.then(() => listener(this.exitCode));
|
|
12958
13848
|
}
|
|
12959
13849
|
answerPermission(id, decision) {
|
|
13850
|
+
if (this.v2) return this.process ? this.v2.answerPermission(id, decision) : false;
|
|
12960
13851
|
const sessionId = this.openPermissions.get(id);
|
|
12961
13852
|
if (sessionId === void 0 || !this.process) return false;
|
|
12962
13853
|
this.openPermissions.delete(id);
|
|
12963
|
-
void this.replyPermission(id, sessionId,
|
|
13854
|
+
void this.replyPermission(id, sessionId, permissionReply2(decision)).catch(() => {
|
|
12964
13855
|
});
|
|
12965
13856
|
return true;
|
|
12966
13857
|
}
|
|
13858
|
+
/**
|
|
13859
|
+
* OpenCode 2.x replaced the 1.x HTTP surface rather than extending it, and
|
|
13860
|
+
* only 2.x answers `/api/info` with its version. A 1.x server — or one that
|
|
13861
|
+
* answers with anything else — keeps the 1.x protocol this class speaks.
|
|
13862
|
+
*/
|
|
13863
|
+
async speaksV2() {
|
|
13864
|
+
const info = await this.json("GET", "/api/info").catch(() => null);
|
|
13865
|
+
return typeof info === "object" && info !== null && typeof info.version === "string";
|
|
13866
|
+
}
|
|
12967
13867
|
role() {
|
|
12968
13868
|
return this.task ? "task" : "planner";
|
|
12969
13869
|
}
|
|
@@ -13014,7 +13914,7 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
13014
13914
|
if (pending?.info?.time?.completed || pending?.info?.error) return pending;
|
|
13015
13915
|
if (pending) onActivity?.();
|
|
13016
13916
|
if (Date.now() >= deadline) return null;
|
|
13017
|
-
await
|
|
13917
|
+
await delay2(RECOVERY_POLL_INTERVAL_MS, signal);
|
|
13018
13918
|
}
|
|
13019
13919
|
}
|
|
13020
13920
|
newestAssistantId(messages) {
|
|
@@ -13227,7 +14127,7 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
13227
14127
|
...ask.always?.length ? { suggestions: ask.always } : {},
|
|
13228
14128
|
...ask.tool?.callID ? { toolUseId: ask.tool.callID } : {}
|
|
13229
14129
|
};
|
|
13230
|
-
if (this.task?.flags.modeSettings.approvals ===
|
|
14130
|
+
if (this.task?.flags.modeSettings.approvals === AUTO_APPROVALS2) {
|
|
13231
14131
|
onEvent({ ...request, decided: { decision: "allow" } });
|
|
13232
14132
|
void this.replyPermission(id, sessionId, { reply: "once" }).catch(() => {
|
|
13233
14133
|
});
|
|
@@ -13261,7 +14161,7 @@ ${this.stderrTail.trim()}` : ""}`);
|
|
|
13261
14161
|
});
|
|
13262
14162
|
const live = this.streamEvents(streamAbort.signal, (frame) => this.onFrame(frame, turn, onEvent), connected, onActivity);
|
|
13263
14163
|
await Promise.race([streamReady, new Promise((r) => {
|
|
13264
|
-
const t = setTimeout(r,
|
|
14164
|
+
const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS2);
|
|
13265
14165
|
t.unref?.();
|
|
13266
14166
|
})]);
|
|
13267
14167
|
return async () => {
|
|
@@ -13426,6 +14326,10 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
13426
14326
|
* pending and nothing ran until the user also re-ran the whole plan.
|
|
13427
14327
|
*/
|
|
13428
14328
|
haltedByFailure = false;
|
|
14329
|
+
/** A Merge all is under way: no ops task starts until it ends (ADR-0020). */
|
|
14330
|
+
merging = false;
|
|
14331
|
+
/** The last "waiting for Merge all" notice, so a run paused at a gate says it once, not on every tick. */
|
|
14332
|
+
gateNotice = "";
|
|
13429
14333
|
/** The workspace's own variables for a task's cwd (ADR-0016); swapped out in tests. */
|
|
13430
14334
|
workspaceEnv;
|
|
13431
14335
|
/** What the workspace env has already warned about, so a run says it once, not per task. */
|
|
@@ -13446,6 +14350,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
13446
14350
|
observers = [];
|
|
13447
14351
|
tddEnabled;
|
|
13448
14352
|
readRunnerTransport;
|
|
14353
|
+
previousAttemptFromLog;
|
|
13449
14354
|
/**
|
|
13450
14355
|
* The setting as the latest run copied it when it opened. Every spawn of
|
|
13451
14356
|
* that run uses this, so flipping the setting mid-run changes the next run
|
|
@@ -13465,6 +14370,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
13465
14370
|
this.workspaceEnv = deps.workspaceEnv;
|
|
13466
14371
|
this.tddEnabled = deps.tddEnabled;
|
|
13467
14372
|
this.readRunnerTransport = deps.runnerTransport;
|
|
14373
|
+
this.previousAttemptFromLog = deps.previousAttemptFromLog;
|
|
13468
14374
|
this.store.onMutate = () => this.emit("onTaskChanged");
|
|
13469
14375
|
this.verifier.onVerdict((taskId, verdict) => this.onVerdict(taskId, verdict));
|
|
13470
14376
|
this.verifier.onCheckpoint((taskId, summary) => {
|
|
@@ -13520,7 +14426,8 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
13520
14426
|
workspaceRoot,
|
|
13521
14427
|
workspaceEnv: options.workspaceEnv ?? ((cwd) => resolveWorkspaceEnv(cwd)),
|
|
13522
14428
|
tddEnabled: options.tddEnabled ?? (() => false),
|
|
13523
|
-
runnerTransport: options.runnerTransport ?? (() => "terminal")
|
|
14429
|
+
runnerTransport: options.runnerTransport ?? (() => "terminal"),
|
|
14430
|
+
previousAttemptFromLog: options.previousAttemptFromLog ?? (() => null)
|
|
13524
14431
|
});
|
|
13525
14432
|
return orchestrator;
|
|
13526
14433
|
}
|
|
@@ -13528,6 +14435,18 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
13528
14435
|
getIdleSince(taskId) {
|
|
13529
14436
|
return this.verifier.getIdleSince(taskId);
|
|
13530
14437
|
}
|
|
14438
|
+
/**
|
|
14439
|
+
* What an ops retry is told about the attempt before. The saved log is read
|
|
14440
|
+
* whether or not the session was reloaded, so both behave alike; the terminal
|
|
14441
|
+
* buffer is the fallback for a runner that has no log. A reload clears the
|
|
14442
|
+
* attempt count along with the buffer, so a retry made since is what says
|
|
14443
|
+
* there was an attempt to report on.
|
|
14444
|
+
*/
|
|
14445
|
+
opsPreviousAttempt(taskId) {
|
|
14446
|
+
const saved = this.previousAttemptFromLog(taskId) ?? this.output.liveTail(taskId, { maxLines: OPS_RETRY_TAIL_LINES })?.text;
|
|
14447
|
+
if (saved !== void 0) return saved;
|
|
14448
|
+
return (this.retryCounts.get(taskId) ?? 0) > 0 ? "Its output is not available: this session was reloaded since it ran." : void 0;
|
|
14449
|
+
}
|
|
13531
14450
|
/** Recent clean output of a task's latest attempt, running or ended; null if it never ran. */
|
|
13532
14451
|
getLiveOutput(taskId, opts) {
|
|
13533
14452
|
return this.output.liveTail(taskId, opts);
|
|
@@ -13734,9 +14653,48 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
13734
14653
|
async reviewRunDiff() {
|
|
13735
14654
|
return this.runs.reviewDiff();
|
|
13736
14655
|
}
|
|
13737
|
-
/**
|
|
14656
|
+
/**
|
|
14657
|
+
* "Merge all"; a settled run that merged everything is cleared up and
|
|
14658
|
+
* forgotten. During a run it merges what has landed and the run goes on:
|
|
14659
|
+
* the tasks waiting at a merge gate for that work then start (ADR-0020).
|
|
14660
|
+
* Never while an ops task runs, and no ops task starts until it ends — the
|
|
14661
|
+
* one overlap the plan cannot order, because the merge is the user's.
|
|
14662
|
+
*/
|
|
13738
14663
|
async mergeRun() {
|
|
13739
|
-
|
|
14664
|
+
const ops = [...this.attempts.values()].find((a) => a.ops);
|
|
14665
|
+
if (ops) {
|
|
14666
|
+
const title = this.store.get(ops.taskId)?.title ?? ops.taskId;
|
|
14667
|
+
throw new TaskControlError(`Ops task "${title}" is running in your checkout \u2014 Merge all once it has finished.`);
|
|
14668
|
+
}
|
|
14669
|
+
this.merging = true;
|
|
14670
|
+
let result;
|
|
14671
|
+
try {
|
|
14672
|
+
result = await this.runs.merge();
|
|
14673
|
+
} finally {
|
|
14674
|
+
this.merging = false;
|
|
14675
|
+
}
|
|
14676
|
+
await this.tick();
|
|
14677
|
+
return result;
|
|
14678
|
+
}
|
|
14679
|
+
/**
|
|
14680
|
+
* The run as a surface offers Merge all mid-run (ADR-0020): what has landed
|
|
14681
|
+
* and is not merged, and whether the run is paused at its gates with nothing
|
|
14682
|
+
* else running. Null while no task waits at a gate.
|
|
14683
|
+
*/
|
|
14684
|
+
mergeGateView() {
|
|
14685
|
+
const handoff = this.runs.view()?.handoff;
|
|
14686
|
+
if (!handoff || !this.store.allTasks.some((t) => this.getMergeGate(t.id).length > 0)) return null;
|
|
14687
|
+
return { paused: this.running && this.attempts.size === 0, repos: handoff.repos, landed: handoff.landed };
|
|
14688
|
+
}
|
|
14689
|
+
/**
|
|
14690
|
+
* The dependencies an ops or user task waits on at its merge gate: those
|
|
14691
|
+
* whose landed work is not in the user's branch yet (ADR-0020). Empty once
|
|
14692
|
+
* the task has started, and for every other task.
|
|
14693
|
+
*/
|
|
14694
|
+
getMergeGate(taskId) {
|
|
14695
|
+
const task = this.store.get(taskId);
|
|
14696
|
+
if (!task || task.status !== "pending" && task.status !== "approved") return [];
|
|
14697
|
+
return mergeGate(task, this.store, this.runs);
|
|
13740
14698
|
}
|
|
13741
14699
|
/** Worktrees and task branches go; the integration branch and the record stay for review and merge. */
|
|
13742
14700
|
async cleanupRun() {
|
|
@@ -13836,7 +14794,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
13836
14794
|
this.emit("onReviewNeeded", { tasks: this.store.planTasks, planRunners: this.store.planRunners });
|
|
13837
14795
|
return;
|
|
13838
14796
|
}
|
|
13839
|
-
|
|
14797
|
+
this.runs.open();
|
|
13840
14798
|
console.log(`[TaskOrchestrator] Starting with ${this.store.allTasks.length} tasks (${this.store.allTasks.filter((t) => t.type === "ai" && t.prompt).length} AI ready)`);
|
|
13841
14799
|
this.running = true;
|
|
13842
14800
|
this.haltedByFailure = false;
|
|
@@ -13878,13 +14836,17 @@ var TaskOrchestrator = class _TaskOrchestrator {
|
|
|
13878
14836
|
const summary = await this.output.finalText({ ...attempt, completionMarker: task.completionMarker }, doneToken);
|
|
13879
14837
|
if (this.attempts.get(taskId) !== attempt) return;
|
|
13880
14838
|
const landing = verdict.outcome === "pass" ? await this.land(attempt, () => this.landing.landPassed(task, attempt)) : null;
|
|
14839
|
+
const changedFiles = landing && attempt.ops ? await this.runs.filesChangedSince(attempt.snapshot) : [];
|
|
13881
14840
|
const unresumedReason = this.unresumed(attempt) ? unresumedMessage(task, `${attempt.runner} could not find its saved session`) : null;
|
|
13882
14841
|
if (this.attempts.get(taskId) !== attempt) return;
|
|
13883
14842
|
this.endAttempt(taskId, "verdict");
|
|
13884
14843
|
this.store.setTaskVerdict(taskId, verdict);
|
|
13885
14844
|
console.error(`[TaskOrchestrator] Output summary:
|
|
13886
14845
|
${summary || "(empty \u2014 no output captured)"}`);
|
|
13887
|
-
if (
|
|
14846
|
+
if (changedFiles.length > 0) {
|
|
14847
|
+
this.store.markAwaitingUser(taskId, "files-changed");
|
|
14848
|
+
this.tell("warn", `Ops task "${task.title}" finished, but changed tracked files in your checkout: ${capConflictFiles(changedFiles)}. An ops task must not change repository files, so nothing was committed \u2014 look at the changes, then mark it complete or retry it.`);
|
|
14849
|
+
} else if (landing) {
|
|
13888
14850
|
await this.applyLanding(task, landing, () => this.notifications.info(`Task "${task.title}" completed.`));
|
|
13889
14851
|
} else if (classifyRunnerStop(attempt.session?.getOutput() ?? "") === "usage-limit") {
|
|
13890
14852
|
this.store.markAwaitingUser(taskId);
|
|
@@ -13986,21 +14948,35 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
13986
14948
|
});
|
|
13987
14949
|
}
|
|
13988
14950
|
getReadyTasks() {
|
|
13989
|
-
|
|
14951
|
+
return this.readiness().ready;
|
|
14952
|
+
}
|
|
14953
|
+
readiness() {
|
|
14954
|
+
if (!this.running) return { ready: [], candidateCount: 0, excluded: [], gated: [] };
|
|
13990
14955
|
const maxParallel = this.config.maxParallelSessions;
|
|
13991
14956
|
const active = this.attempts.size;
|
|
13992
|
-
const
|
|
14957
|
+
const readiness = selectReadyTasks({
|
|
13993
14958
|
store: this.store,
|
|
13994
14959
|
onHold: this.onHold,
|
|
13995
14960
|
runs: this.runs,
|
|
13996
14961
|
active,
|
|
13997
|
-
maxParallel
|
|
14962
|
+
maxParallel,
|
|
14963
|
+
merging: this.merging
|
|
13998
14964
|
});
|
|
13999
|
-
for (const { task, reasons } of excluded) {
|
|
14965
|
+
for (const { task, reasons } of readiness.excluded) {
|
|
14000
14966
|
console.log(`[TaskOrchestrator] excluded: #${task.order} "${task.title}" \u2014 ${reasons.join(", ")}`);
|
|
14001
14967
|
}
|
|
14002
|
-
console.log(`[TaskOrchestrator] getReadyTasks: ${candidateCount} candidates, ${Math.max(0, maxParallel - active)} slots, maxParallel=${maxParallel}`);
|
|
14003
|
-
return
|
|
14968
|
+
console.log(`[TaskOrchestrator] getReadyTasks: ${readiness.candidateCount} candidates, ${Math.max(0, maxParallel - active)} slots, maxParallel=${maxParallel}`);
|
|
14969
|
+
return readiness;
|
|
14970
|
+
}
|
|
14971
|
+
/**
|
|
14972
|
+
* Open the run a task starts in, and for a change task decide whether it
|
|
14973
|
+
* isolates (ADR-0020): an ops task needs no worktree, so it never waits on
|
|
14974
|
+
* that choice. False while a dirty tree waits on the user; `resume` is what
|
|
14975
|
+
* their choice replays.
|
|
14976
|
+
*/
|
|
14977
|
+
async openFor(taskId, resume) {
|
|
14978
|
+
this.runs.open();
|
|
14979
|
+
return this.store.isOps(taskId) || this.runs.decide(resume);
|
|
14004
14980
|
}
|
|
14005
14981
|
/**
|
|
14006
14982
|
* Cancel a running (or scheduled) task: kill its session and return it to
|
|
@@ -14128,7 +15104,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
14128
15104
|
const runner = this.store.resolveTaskRunner(task);
|
|
14129
15105
|
const route = routeTransport("structured", runner, this.registry);
|
|
14130
15106
|
if (route.transport !== "structured") throw new TaskControlError(`Task "${task.title}" cannot be continued: ${route.fallback}. Use Retry instead.`);
|
|
14131
|
-
if (!await this.
|
|
15107
|
+
if (!await this.openFor(taskId, () => this.continueTask(taskId, message))) return;
|
|
14132
15108
|
if (this.attempts.has(taskId) || !continuability(task).ok) return;
|
|
14133
15109
|
this.retryCounts.set(taskId, (this.retryCounts.get(taskId) ?? 0) + 1);
|
|
14134
15110
|
this.endAttempt(taskId, "retry");
|
|
@@ -14150,10 +15126,29 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
14150
15126
|
const task = this.store.get(taskId);
|
|
14151
15127
|
if (!task || task.type !== "ai") return;
|
|
14152
15128
|
if (this.attempts.has(taskId)) return;
|
|
14153
|
-
|
|
15129
|
+
this.refuseOpsDuringMerge(task);
|
|
15130
|
+
if (!await this.openFor(taskId, () => this.forceStartTask(taskId))) return;
|
|
14154
15131
|
this.onHold.delete(taskId);
|
|
15132
|
+
this.notePastGate(task);
|
|
14155
15133
|
await this.startTask(task);
|
|
14156
15134
|
}
|
|
15135
|
+
refuseOpsDuringMerge(task) {
|
|
15136
|
+
if (this.merging && this.store.isOps(task.id)) {
|
|
15137
|
+
throw new TaskControlError(`A Merge all is under way \u2014 ops task "${task.title}" can start once it has finished.`);
|
|
15138
|
+
}
|
|
15139
|
+
}
|
|
15140
|
+
/**
|
|
15141
|
+
* A force start passes a merge gate (ADR-0020), and the surface confirmed it
|
|
15142
|
+
* first; the choice is kept on the task and said, because the work it acts
|
|
15143
|
+
* on is not in the user's branch.
|
|
15144
|
+
*/
|
|
15145
|
+
notePastGate(task) {
|
|
15146
|
+
const unmerged = mergeGate(task, this.store, this.runs);
|
|
15147
|
+
if (unmerged.length === 0) return;
|
|
15148
|
+
const titles = unmerged.map((id) => `"${this.store.get(id)?.title ?? id}"`);
|
|
15149
|
+
this.store.setForcedPastGate(task.id, titles);
|
|
15150
|
+
this.tell("warn", `Task "${task.title}" was force-started before the work of ${titles.join(", ")} was merged into your branch.`);
|
|
15151
|
+
}
|
|
14157
15152
|
/**
|
|
14158
15153
|
* Run exactly one task outside full-plan scheduling. The active/starting
|
|
14159
15154
|
* session still contributes to {@link hasLiveWork} so every surface exposes
|
|
@@ -14164,8 +15159,10 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
14164
15159
|
if (this.hasLiveWork) return;
|
|
14165
15160
|
const task = this.store.get(taskId);
|
|
14166
15161
|
if (!task || task.type !== "ai") return;
|
|
14167
|
-
|
|
15162
|
+
this.refuseOpsDuringMerge(task);
|
|
15163
|
+
if (!await this.openFor(taskId, () => this.runTask(taskId))) return;
|
|
14168
15164
|
this.onHold.delete(taskId);
|
|
15165
|
+
this.notePastGate(task);
|
|
14169
15166
|
await this.startTask(task);
|
|
14170
15167
|
}
|
|
14171
15168
|
getCompletedCount() {
|
|
@@ -14205,7 +15202,11 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
14205
15202
|
}
|
|
14206
15203
|
return;
|
|
14207
15204
|
}
|
|
14208
|
-
|
|
15205
|
+
let readiness = this.readiness();
|
|
15206
|
+
const atGate = readiness.gated.length > 0 || this.store.allTasks.some((t) => t.type === "user" && this.getMergeGate(t.id).length > 0);
|
|
15207
|
+
if (atGate && await this.runs.refreshInHead()) readiness = this.readiness();
|
|
15208
|
+
if (!this.running) return;
|
|
15209
|
+
const { ready } = readiness;
|
|
14209
15210
|
console.log(`[TaskOrchestrator] tick(): ${ready.length} ready, ${this.attempts.size} active, queue=${this.messageQueue.length}`);
|
|
14210
15211
|
if (ready.length === 0 && this.attempts.size === 0) {
|
|
14211
15212
|
const remaining = this.store.allTasks.filter((t) => t.status !== "completed");
|
|
@@ -14225,14 +15226,29 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
14225
15226
|
this.emit("onExecutionComplete");
|
|
14226
15227
|
} else {
|
|
14227
15228
|
this.notifications.info("Remaining tasks require user action or are on hold.");
|
|
15229
|
+
this.sayGate();
|
|
14228
15230
|
this.emit("onTaskChanged");
|
|
14229
15231
|
this.emit("onTick");
|
|
14230
15232
|
}
|
|
14231
15233
|
return;
|
|
14232
15234
|
}
|
|
14233
|
-
for (const task of ready)
|
|
15235
|
+
for (const task of ready) {
|
|
15236
|
+
if (this.store.isOps(task.id) ? this.merging : !await this.runs.decide(() => this.tick())) continue;
|
|
15237
|
+
await this.startTask(task);
|
|
15238
|
+
}
|
|
14234
15239
|
this.emit("onTick");
|
|
14235
15240
|
}
|
|
15241
|
+
/**
|
|
15242
|
+
* A run paused at merge gates says so, as plainly as a task that waits on
|
|
15243
|
+
* the user: which tasks wait, and that Merge all lets them go on (ADR-0020).
|
|
15244
|
+
*/
|
|
15245
|
+
sayGate() {
|
|
15246
|
+
const waiting = this.store.allTasks.filter((t) => this.getMergeGate(t.id).length > 0);
|
|
15247
|
+
const notice = waiting.length === 0 ? "" : `Waiting for Merge all: ${waiting.map((t) => `"${t.title}"`).join(", ")} ${waiting.length === 1 ? "needs" : "need"} work that is not merged into your branch yet.`;
|
|
15248
|
+
if (notice === this.gateNotice) return;
|
|
15249
|
+
this.gateNotice = notice;
|
|
15250
|
+
if (notice) this.tell("info", notice);
|
|
15251
|
+
}
|
|
14236
15252
|
async startTask(task, continuation = null) {
|
|
14237
15253
|
if (this.attempts.has(task.id)) return;
|
|
14238
15254
|
const attempt = {
|
|
@@ -14246,6 +15262,8 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
14246
15262
|
integration: null,
|
|
14247
15263
|
repair: this.landing.nextRepair(task.id),
|
|
14248
15264
|
continuation,
|
|
15265
|
+
ops: this.store.isOps(task.id),
|
|
15266
|
+
snapshot: null,
|
|
14249
15267
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
14250
15268
|
decided: false
|
|
14251
15269
|
};
|
|
@@ -14271,18 +15289,22 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
14271
15289
|
*/
|
|
14272
15290
|
async spawnAttempt(task, attempt) {
|
|
14273
15291
|
try {
|
|
14274
|
-
const { cwd, worktree } = await this.runs.attemptCwd(task, { repair: attempt.repair !== null });
|
|
15292
|
+
const { cwd, worktree } = await this.runs.attemptCwd(task, { repair: attempt.repair !== null, ops: attempt.ops });
|
|
14275
15293
|
attempt.cwd = cwd;
|
|
14276
15294
|
attempt.worktree = worktree;
|
|
15295
|
+
if (attempt.ops) attempt.snapshot = await this.runs.snapshotWorkspace();
|
|
14277
15296
|
if (this.attempts.get(task.id) !== attempt) {
|
|
14278
15297
|
if (attempt.worktree && !this.attempts.has(task.id)) await this.runs.release(task.id, { keep: attempt.repair !== null });
|
|
14279
15298
|
this.abandonSpawn(task, attempt);
|
|
14280
15299
|
return false;
|
|
14281
15300
|
}
|
|
14282
|
-
const finalPrompt = attempt.continuation ? composeContinuationPrompt(task, attempt.continuation.message) : composeAugmentedPrompt(attempt.repair ? { ...task, prompt: this.landing.repairPrompt(task) } : task, this.store.planTasks, {
|
|
15301
|
+
const finalPrompt = attempt.continuation ? composeContinuationPrompt(task, attempt.continuation.message, { ops: attempt.ops }) : composeAugmentedPrompt(attempt.repair ? { ...task, prompt: this.landing.repairPrompt(task) } : task, this.store.planTasks, {
|
|
14283
15302
|
planMapEnabled: this.config.planMapEnabled,
|
|
14284
15303
|
// A merge to resolve is not new behaviour to drive test-first.
|
|
14285
|
-
tddEnabled: !attempt.repair && this.tddEnabled()
|
|
15304
|
+
tddEnabled: !attempt.repair && this.tddEnabled(),
|
|
15305
|
+
// An ops task's effects outlive a failed attempt and are never rolled
|
|
15306
|
+
// back, so the next one is told what the last one did (ADR-0020).
|
|
15307
|
+
previousAttempt: attempt.ops ? this.opsPreviousAttempt(task.id) : void 0
|
|
14286
15308
|
});
|
|
14287
15309
|
this.lingering.close(task.id);
|
|
14288
15310
|
const env = await this.envForTask(cwd);
|
|
@@ -14459,6 +15481,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
|
|
|
14459
15481
|
return ended;
|
|
14460
15482
|
}
|
|
14461
15483
|
};
|
|
15484
|
+
var OPS_RETRY_TAIL_LINES = 60;
|
|
14462
15485
|
function unresumedMessage(task, why) {
|
|
14463
15486
|
return `Could not continue task "${task.title}": ${why}. Retry starts it afresh.`;
|
|
14464
15487
|
}
|
|
@@ -16916,7 +17939,7 @@ Execution is RUNNING${locked.length ? ` \u2014 these tasks are locked: ${locked.
|
|
|
16916
17939
|
return tasks.map((t) => {
|
|
16917
17940
|
const isMan = t.type === "user";
|
|
16918
17941
|
const runFields = isMan ? `steps:${t.userSteps?.length ?? 0}` : `${t.assignedModel ? `model:${t.assignedModel.modelId} ` : ""}mode:${t.taskMode ?? "build"} effort:${t.thinkingEffort ?? "-"}`;
|
|
16919
|
-
return `#${t.order} id=${t.id} "${t.title}" [${t.status}] type:${isMan ? "MAN" : "AI"} runner:${t.assignedRunner} ${runFields} autonomy:${t.autonomy ?? "-"} slice:${t.sliceType ?? "-"} deps:[${t.dependencies.map((d) => orderOf.get(d) ?? d).join(", ")}]`;
|
|
17942
|
+
return `#${t.order} id=${t.id} "${t.title}" [${t.status}] type:${isMan ? "MAN" : t.ops ? "AI ops" : "AI"} runner:${t.assignedRunner} ${runFields} autonomy:${t.autonomy ?? "-"} slice:${t.sliceType ?? "-"} deps:[${t.dependencies.map((d) => orderOf.get(d) ?? d).join(", ")}]`;
|
|
16920
17943
|
});
|
|
16921
17944
|
}
|
|
16922
17945
|
};
|
|
@@ -17413,6 +18436,7 @@ var SessionEventRelay = class {
|
|
|
17413
18436
|
this.statusOwed = this.statusHeld;
|
|
17414
18437
|
if (this.statusHeld) return;
|
|
17415
18438
|
plan.tasks = this.store.snapshot();
|
|
18439
|
+
const gate = this.orchestrator.mergeGateView();
|
|
17416
18440
|
this.broadcast({
|
|
17417
18441
|
type: "status_update",
|
|
17418
18442
|
tasks: this.store.allTasks.map((t) => serializeTaskStatus(
|
|
@@ -17420,8 +18444,10 @@ var SessionEventRelay = class {
|
|
|
17420
18444
|
this.orchestrator.getIdleSince(t.id),
|
|
17421
18445
|
this.orchestrator.getTaskIsolation(t.id),
|
|
17422
18446
|
this.orchestrator.getQueuedTaskMessages(t.id),
|
|
17423
|
-
this.awaitingApproval(t.id)
|
|
17424
|
-
|
|
18447
|
+
this.awaitingApproval(t.id),
|
|
18448
|
+
this.orchestrator.getMergeGate(t.id)
|
|
18449
|
+
)),
|
|
18450
|
+
...gate ? { gate } : {}
|
|
17425
18451
|
});
|
|
17426
18452
|
}
|
|
17427
18453
|
/**
|
|
@@ -17897,6 +18923,13 @@ var PlanEditError = class extends Error {
|
|
|
17897
18923
|
this.name = "PlanEditError";
|
|
17898
18924
|
}
|
|
17899
18925
|
};
|
|
18926
|
+
var OPS_RETRY_DIGEST_CALLS = 20;
|
|
18927
|
+
function lastAttemptDigest(location, taskId) {
|
|
18928
|
+
const attempt = listTaskLogAttempts(location, taskId).pop();
|
|
18929
|
+
if (attempt === void 0) return null;
|
|
18930
|
+
const events = readTaskLog(location, taskId, attempt);
|
|
18931
|
+
return events.length > 0 ? digestTaskLog(events, OPS_RETRY_DIGEST_CALLS) : null;
|
|
18932
|
+
}
|
|
17900
18933
|
function sessionRuntimeSettings(settings) {
|
|
17901
18934
|
return { ...plannerRuntimeToggles(settings), modelAllowlist: settings.modelAllowlist, runnerTransport: settings.runnerTransport };
|
|
17902
18935
|
}
|
|
@@ -17981,7 +19014,8 @@ function createSession(deps) {
|
|
|
17981
19014
|
registry: deps.registry,
|
|
17982
19015
|
workspaceRoot: deps.workspaceRoot,
|
|
17983
19016
|
tddEnabled: () => deps.settings().tddEnabled,
|
|
17984
|
-
runnerTransport: () => deps.settings().runnerTransport ?? "terminal"
|
|
19017
|
+
runnerTransport: () => deps.settings().runnerTransport ?? "terminal",
|
|
19018
|
+
previousAttemptFromLog: (taskId) => lastAttemptDigest(session.taskLogLocation, taskId)
|
|
17985
19019
|
});
|
|
17986
19020
|
const usage = new PlannerUsageLedger();
|
|
17987
19021
|
const events = new SessionEventRelay({
|
|
@@ -18754,6 +19788,14 @@ var Session = class {
|
|
|
18754
19788
|
isolationView() {
|
|
18755
19789
|
return this.orchestrator.isolationView();
|
|
18756
19790
|
}
|
|
19791
|
+
/** The dependencies a task waits on at its merge gate (ADR-0020); empty when it waits for no Merge all. */
|
|
19792
|
+
mergeGate(taskId) {
|
|
19793
|
+
return this.orchestrator.getMergeGate(taskId);
|
|
19794
|
+
}
|
|
19795
|
+
/** What Merge all would merge mid-run, while any task waits at a merge gate; null otherwise. */
|
|
19796
|
+
mergeGateView() {
|
|
19797
|
+
return this.orchestrator.mergeGateView();
|
|
19798
|
+
}
|
|
18757
19799
|
/** The run's integration branch against its base ref, as a unified diff. */
|
|
18758
19800
|
async reviewRunDiff() {
|
|
18759
19801
|
if (!this.orchestrator.isolationRecord) throw new PlanEditError("This plan has no isolated run");
|
|
@@ -18763,10 +19805,12 @@ var Session = class {
|
|
|
18763
19805
|
* "Merge all": each repo's integration branch into whatever the user has
|
|
18764
19806
|
* checked out there — every repo, or none if any cannot take it. The one
|
|
18765
19807
|
* irreversible step of isolated execution, so this explicit call is the
|
|
18766
|
-
* only way it ever happens.
|
|
19808
|
+
* only way it ever happens. During a run it merges what has landed so far,
|
|
19809
|
+
* which is what opens a merge gate (ADR-0020); it waits for no ops task,
|
|
19810
|
+
* and refuses while one runs.
|
|
18767
19811
|
*/
|
|
18768
19812
|
async mergeRun() {
|
|
18769
|
-
this.
|
|
19813
|
+
if (!this.orchestrator.isolationRecord) throw new PlanEditError("This plan has no isolated run");
|
|
18770
19814
|
const result = await this.orchestrator.mergeRun();
|
|
18771
19815
|
this.broadcast({ type: "isolation_merge", result });
|
|
18772
19816
|
return result;
|
|
@@ -18856,7 +19900,8 @@ var Session = class {
|
|
|
18856
19900
|
* falls through to the no-op `store.update` below instead of throwing.
|
|
18857
19901
|
*/
|
|
18858
19902
|
async updateTask(taskId, changes) {
|
|
18859
|
-
if (
|
|
19903
|
+
if ("ops" in changes) changes = { ...changes, ops: changes.ops === true ? true : void 0 };
|
|
19904
|
+
if ((changes.dependencies || changes.type || changes.assignedModel || changes.taskMode || "ops" in changes) && this.store.get(taskId)) {
|
|
18860
19905
|
const check = validateTaskEdit("direct", this.store.planTasks, taskId, changes, this.editCatalog());
|
|
18861
19906
|
if (!check.ok) throw new PlanEditError(check.error ?? "Those changes are not valid");
|
|
18862
19907
|
if (check.clear?.length) {
|
|
@@ -21133,6 +22178,7 @@ export {
|
|
|
21133
22178
|
FsPluginStore,
|
|
21134
22179
|
GIT_READONLY_SUBCOMMANDS,
|
|
21135
22180
|
GREP_DEFAULT_HEAD_LIMIT,
|
|
22181
|
+
GREP_PCRE_PROBE_ARGS,
|
|
21136
22182
|
GeminiService,
|
|
21137
22183
|
HeadlessRunner,
|
|
21138
22184
|
HeadlessSession,
|
|
@@ -21272,6 +22318,7 @@ export {
|
|
|
21272
22318
|
dependencyCandidates,
|
|
21273
22319
|
dependentsOf,
|
|
21274
22320
|
describeMergeResult,
|
|
22321
|
+
digestTaskLog,
|
|
21275
22322
|
discoverGeminiModels,
|
|
21276
22323
|
drainNext,
|
|
21277
22324
|
effectiveAllowlist,
|
|
@@ -21304,6 +22351,7 @@ export {
|
|
|
21304
22351
|
getStateDir,
|
|
21305
22352
|
globalDataDir,
|
|
21306
22353
|
grantScopeFor,
|
|
22354
|
+
grepRegexFromProbe,
|
|
21307
22355
|
hasHiddenDetail,
|
|
21308
22356
|
hasTmux,
|
|
21309
22357
|
holdPrompt,
|