@ordewell/core 0.6.1 → 0.6.3

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/index.mjs CHANGED
@@ -127,6 +127,8 @@ import {
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,10 @@ 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;
2480
+ let followable = true;
2481
+ let bareSegment = false;
2449
2482
  let tokens = [];
2450
2483
  let expandable = false;
2451
2484
  let current = "";
@@ -2475,13 +2508,25 @@ function lex(command, nested, dialect) {
2475
2508
  current = "";
2476
2509
  started = false;
2477
2510
  };
2478
- const endSegment = (nextPiped) => {
2511
+ const endSegment = (nextPiped, next = "other", last = false) => {
2479
2512
  endToken();
2480
- if (tokens.length > 0) segments.push({ ...toSegment(tokens, piped, dialect), expandable, inputs });
2513
+ if (!last && next === "other") followable = false;
2514
+ if (tokens.length > 0) {
2515
+ const seg = toSegment(tokens, piped, dialect);
2516
+ if (!seg.binary) {
2517
+ followable = false;
2518
+ bareSegment = true;
2519
+ }
2520
+ segments.push({ ...seg, expandable, inputs, joinedBy });
2521
+ } else if (!last || segments.length > 0 || joinedBy !== void 0) {
2522
+ followable = false;
2523
+ bareSegment = true;
2524
+ }
2481
2525
  tokens = [];
2482
2526
  inputs = [];
2483
2527
  expandable = false;
2484
2528
  piped = nextPiped;
2529
+ joinedBy = next;
2485
2530
  };
2486
2531
  let i = 0;
2487
2532
  while (i < command.length) {
@@ -2603,17 +2648,18 @@ function lex(command, nested, dialect) {
2603
2648
  }
2604
2649
  if (c === "(" || c === ")") {
2605
2650
  endToken();
2651
+ grouped = true;
2606
2652
  i++;
2607
2653
  continue;
2608
2654
  }
2609
2655
  if (c === "|") {
2610
2656
  const double = command[i + 1] === "|";
2611
- endSegment(!double);
2657
+ endSegment(!double, double ? "other" : "pipe");
2612
2658
  i += double || command[i + 1] === "&" ? 2 : 1;
2613
2659
  continue;
2614
2660
  }
2615
2661
  if (c === "&" || c === ";" || c === "\n") {
2616
- endSegment(false);
2662
+ endSegment(false, c === "&" && command[i + 1] === "&" ? "and" : "other");
2617
2663
  i += command[i + 1] === c ? 2 : 1;
2618
2664
  continue;
2619
2665
  }
@@ -2630,8 +2676,8 @@ function lex(command, nested, dialect) {
2630
2676
  i++;
2631
2677
  }
2632
2678
  if (quote !== "") unbalanced = true;
2633
- endSegment(false);
2634
- return { segments, unsafeRedirect, processSubstitution, unbalanced };
2679
+ endSegment(false, "other", true);
2680
+ return { segments, unsafeRedirect, processSubstitution, unbalanced, grouped, followable, bareSegment };
2635
2681
  }
2636
2682
  function binaryName(token, dialect) {
2637
2683
  const base = token.split(/[/\\]/).pop() ?? "";
@@ -2771,10 +2817,12 @@ function unwrap(seg, dialect) {
2771
2817
  }
2772
2818
  return { seg: { ...current, assignments }, wrappers, runner };
2773
2819
  }
2774
- function lexAll(command, dialect) {
2820
+ function lexAll(command, dialect, cdpathSet = false) {
2775
2821
  const queue = [];
2776
2822
  const top = lex(command, queue, dialect);
2777
2823
  const all = { ...top, segments: top.segments.filter((s) => s.binary) };
2824
+ markShellState(all.segments, top.bareSegment);
2825
+ if (top.followable && !top.grouped && queue.length === 0 && !cdpathSet) followCd(all.segments);
2778
2826
  for (let depth = 0; depth < 32 && queue.length > 0; depth++) {
2779
2827
  const inner = lex(queue.shift(), queue, dialect);
2780
2828
  all.segments.push(...inner.segments.filter((s) => s.binary));
@@ -2784,15 +2832,61 @@ function lexAll(command, dialect) {
2784
2832
  }
2785
2833
  return all;
2786
2834
  }
2835
+ function literalCdTarget(seg) {
2836
+ if (seg.binary !== "cd" || seg.assignments.length > 0 || seg.expandable || seg.inputs.length > 0) return void 0;
2837
+ if (seg.args.length !== 1) return void 0;
2838
+ const [target] = seg.args;
2839
+ return target === "" || target.startsWith("-") || target.startsWith("~") || /[*?[]/.test(target) ? void 0 : target;
2840
+ }
2841
+ function followCd(segments) {
2842
+ if (segments.some((s) => s.joinedBy === "other" || SHELL_STATE_COMMANDS.has(s.binary))) return;
2843
+ const inPipeline = (i) => segments[i].joinedBy === "pipe" || segments[i + 1]?.joinedBy === "pipe";
2844
+ if (segments.some((s, i) => s.binary === "cd" && !inPipeline(i) && literalCdTarget(s) === void 0)) return;
2845
+ let chain = [];
2846
+ segments.forEach((seg, i) => {
2847
+ seg.cwd = chain;
2848
+ const target = inPipeline(i) ? void 0 : literalCdTarget(seg);
2849
+ if (target !== void 0) chain = [...chain, target];
2850
+ });
2851
+ }
2852
+ function markShellState(segments, bareSegment) {
2853
+ if (!bareSegment && !segments.some((s) => SHELL_STATE_COMMANDS.has(s.binary))) return;
2854
+ for (const seg of segments) if (seg.binary === "cd") seg.cdSteered = true;
2855
+ }
2856
+ var SHELL_STATE_COMMANDS = /* @__PURE__ */ new Set([
2857
+ "export",
2858
+ "set",
2859
+ "shopt",
2860
+ "declare",
2861
+ "typeset",
2862
+ "readonly",
2863
+ "local",
2864
+ "unset",
2865
+ "let",
2866
+ "source",
2867
+ ".",
2868
+ "eval",
2869
+ "exec",
2870
+ "pushd",
2871
+ "popd",
2872
+ "alias",
2873
+ "unalias",
2874
+ "builtin",
2875
+ "command",
2876
+ "enable",
2877
+ "hash",
2878
+ "trap"
2879
+ ]);
2787
2880
  function looksLikePath(arg) {
2788
2881
  if (arg.startsWith("--") && arg.includes("=")) return looksLikePath(arg.slice(arg.indexOf("=") + 1));
2789
2882
  return arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("../") || arg === ".." || arg.startsWith("./") || /^[A-Za-z]:/.test(arg) || arg.startsWith("\\") || arg.startsWith("..\\") || arg.startsWith(".\\") || /(^|[\\/])\.\.([\\/]|$)/.test(arg);
2790
2883
  }
2791
- function pathLikeArgs(command, opts = {}) {
2884
+ function pathRefs(command, opts = {}) {
2792
2885
  const dialect = dialectFor(opts.dialect);
2793
- return lexAll(command, dialect).segments.flatMap((seg) => {
2886
+ return lexAll(command.trim(), dialect, opts.cdpathSet).segments.flatMap((seg) => {
2794
2887
  const patterns = patternArgs(seg);
2795
- return [...seg.args.filter((_, i) => !patterns.has(i)), ...seg.inputs].flatMap(pathIn);
2888
+ const cwd = seg.cwd ?? [];
2889
+ return [...seg.args.filter((_, i) => !patterns.has(i)), ...seg.inputs].flatMap(pathIn).map((path26) => ({ path: path26, cwd }));
2796
2890
  });
2797
2891
  }
2798
2892
  function pathIn(a) {
@@ -2956,7 +3050,8 @@ function scopeFor(seg) {
2956
3050
  }
2957
3051
  return [seg.binary, ...lead].join(" ");
2958
3052
  }
2959
- function isAuto(seg) {
3053
+ function isAuto(seg, opts) {
3054
+ if (seg.binary === "cd") return !opts.cdpathSet && !seg.cdSteered && literalCdTarget(seg) !== void 0;
2960
3055
  if (!AUTO_COMMANDS.includes(seg.binary)) return false;
2961
3056
  if (seg.expandable) return false;
2962
3057
  if (seg.binary !== "git") return true;
@@ -3051,7 +3146,7 @@ function classifyCommand(command, opts = {}) {
3051
3146
  const trimmed = command.trim();
3052
3147
  if (!trimmed) return { tier: "refuse", scope: "", reason: "Empty command." };
3053
3148
  const dialect = dialectFor(opts.dialect);
3054
- const { segments, unsafeRedirect, processSubstitution, unbalanced } = lexAll(trimmed, dialect);
3149
+ const { segments, unsafeRedirect, processSubstitution, unbalanced } = lexAll(trimmed, dialect, opts.cdpathSet);
3055
3150
  if (unbalanced) {
3056
3151
  return {
3057
3152
  tier: "refuse",
@@ -3088,7 +3183,7 @@ function classifyCommand(command, opts = {}) {
3088
3183
  };
3089
3184
  }
3090
3185
  }
3091
- const nonAuto = unwrapped.filter((u) => u.runner !== void 0 || !isAuto(u.seg));
3186
+ const nonAuto = unwrapped.filter((u) => u.runner !== void 0 || !isAuto(u.seg, opts));
3092
3187
  if (nonAuto.length === 0) return { tier: "auto", scope: "" };
3093
3188
  const scope = [...new Set(nonAuto.map((u) => u.runner ? `${u.runner} ${scopeFor(u.seg)}` : scopeFor(u.seg)))].sort().join(" + ");
3094
3189
  return { tier: "ask", scope };
@@ -3158,6 +3253,9 @@ function researchShellWarning(shell) {
3158
3253
  if (shell.dialect !== "cmd") return null;
3159
3254
  return "No POSIX shell was found on this machine, so research commands run through cmd.exe, where most read-only tools (ls, cat, wc, head, grep, find) do not exist. Installing Git for Windows provides one and restores the full research toolset.";
3160
3255
  }
3256
+ function researchPolicyOptions(shell) {
3257
+ return { dialect: shell.dialect, cdpathSet: !!process.env.CDPATH };
3258
+ }
3161
3259
 
3162
3260
  // src/services/pathScope.ts
3163
3261
  import * as os from "os";
@@ -3168,11 +3266,15 @@ function expandHome(target) {
3168
3266
  if (target === "~" || target.startsWith("~/")) return path2.join(home, target.slice(1));
3169
3267
  return path2.join(path2.dirname(home), target.slice(1));
3170
3268
  }
3171
- function resolveWithin(root, target) {
3172
- const abs = path2.resolve(root, expandHome(target));
3269
+ function resolveWithin(root, target, from = root) {
3270
+ const abs = path2.resolve(from, expandHome(target));
3173
3271
  const rel = path2.relative(root, abs);
3174
3272
  return { abs, inside: rel === "" || !rel.startsWith("..") && !path2.isAbsolute(rel) };
3175
3273
  }
3274
+ function resolveRef(root, ref) {
3275
+ const from = ref.cwd.reduce((dir, target) => resolveWithin(root, target, dir).abs, root);
3276
+ return resolveWithin(root, ref.path, from);
3277
+ }
3176
3278
  function grantScopeFor(abs, kind) {
3177
3279
  if (kind === "directory") return path2.join(abs, "*");
3178
3280
  const parent = path2.dirname(abs);
@@ -3378,14 +3480,17 @@ ${refs.output.trim()}`);
3378
3480
  outsidePaths(command) {
3379
3481
  const root = this.getWorkspaceRoot();
3380
3482
  const found = /* @__PURE__ */ new Map();
3381
- for (const p of pathLikeArgs(command, { dialect: this.researchShell.dialect })) {
3382
- const { abs, inside } = resolveWithin(root, p);
3483
+ for (const ref of pathRefs(command, this.policyOptions())) {
3484
+ const { abs, inside } = resolveRef(root, ref);
3383
3485
  if (inside || isInertDevice(abs)) continue;
3384
3486
  const scope = grantScopeFor(abs, "file");
3385
3487
  if (!found.has(scope)) found.set(scope, abs);
3386
3488
  }
3387
3489
  return [...found].map(([scope, abs]) => ({ abs, scope }));
3388
3490
  }
3491
+ policyOptions() {
3492
+ return researchPolicyOptions(this.researchShell);
3493
+ }
3389
3494
  /**
3390
3495
  * Three tiers (see `commandPolicy.ts`): read-only inspection runs silently,
3391
3496
  * anything else asks once and is remembered, and writes are refused outright
@@ -3397,7 +3502,7 @@ ${refs.output.trim()}`);
3397
3502
  * directory the next command names.
3398
3503
  */
3399
3504
  async bash(command, signal) {
3400
- const { tier, scope, reason } = classifyCommand(command, { dialect: this.researchShell.dialect });
3505
+ const { tier, scope, reason } = classifyCommand(command, this.policyOptions());
3401
3506
  if (tier === "refuse") {
3402
3507
  return { success: false, output: `Command refused: ${reason}`, truncated: false };
3403
3508
  }
@@ -3896,10 +4001,78 @@ function buildGrepArgs(pattern, opts, root) {
3896
4001
  function buildGlobArgs(pattern, root) {
3897
4002
  return ["--files", ...SORT_BY_RECENCY, "--glob", pattern, ...exclusionArgs(), "--", root];
3898
4003
  }
3899
- function buildFallbackGrepArgs(pattern, opts, root) {
4004
+ var GREP_PCRE_PROBE_ARGS = ["-P", "-e", "x", "/dev/null"];
4005
+ function grepRegexFromProbe(exitCode) {
4006
+ return exitCode === 0 || exitCode === 1 ? "pcre" : "ere";
4007
+ }
4008
+ function toPosixClasses(pattern) {
4009
+ const CLASS_ESCAPES = { s: "[:space:]", w: "[:alnum:]_", d: "[:digit:]", t: " " };
4010
+ let out = "";
4011
+ let i = 0;
4012
+ while (i < pattern.length) {
4013
+ const c = pattern[i];
4014
+ if (c === "\\") {
4015
+ out += pattern.slice(i, i + 2);
4016
+ i += 2;
4017
+ continue;
4018
+ }
4019
+ if (c !== "[") {
4020
+ out += c;
4021
+ i++;
4022
+ continue;
4023
+ }
4024
+ const start = i;
4025
+ i++;
4026
+ let negate = false;
4027
+ if (pattern[i] === "^") {
4028
+ negate = true;
4029
+ i++;
4030
+ }
4031
+ let body = "";
4032
+ let closeBracket = false;
4033
+ let openBracket = false;
4034
+ let dash = false;
4035
+ let closed = false;
4036
+ while (i < pattern.length) {
4037
+ const ch = pattern[i];
4038
+ if (ch === "]") {
4039
+ closed = true;
4040
+ i++;
4041
+ break;
4042
+ }
4043
+ if (ch === "[" && pattern[i + 1] === ":") {
4044
+ const end = pattern.indexOf(":]", i + 2);
4045
+ if (end !== -1) {
4046
+ body += pattern.slice(i, end + 2);
4047
+ i = end + 2;
4048
+ continue;
4049
+ }
4050
+ }
4051
+ if (ch === "\\" && i + 1 < pattern.length) {
4052
+ const e = pattern[i + 1];
4053
+ i += 2;
4054
+ if (e in CLASS_ESCAPES) body += CLASS_ESCAPES[e];
4055
+ else if (e === "]") closeBracket = true;
4056
+ else if (e === "[") openBracket = true;
4057
+ else if (e === "-") dash = true;
4058
+ else body += e;
4059
+ continue;
4060
+ }
4061
+ body += ch;
4062
+ i++;
4063
+ }
4064
+ if (!closed) {
4065
+ out += pattern.slice(start);
4066
+ break;
4067
+ }
4068
+ out += `[${negate ? "^" : ""}${closeBracket ? "]" : ""}${body}${openBracket ? "[" : ""}${dash ? "-" : ""}]`;
4069
+ }
4070
+ return out;
4071
+ }
4072
+ function buildFallbackGrepArgs(pattern, opts, root, regex = "pcre") {
3900
4073
  const args = ["-r", "-n"];
3901
4074
  if (opts.literal) args.push("-F");
3902
- else args.push("-P");
4075
+ else args.push(regex === "pcre" ? "-P" : "-E");
3903
4076
  if (opts.caseInsensitive) args.push("-i");
3904
4077
  if (opts.outputMode === "files") args.push("-l");
3905
4078
  if (opts.outputMode === "count") args.push("-c");
@@ -3909,7 +4082,7 @@ function buildFallbackGrepArgs(pattern, opts, root) {
3909
4082
  }
3910
4083
  if (opts.include) args.push(`--include=${opts.include}`);
3911
4084
  for (const dir of SEARCH_EXCLUSIONS) args.push(`--exclude-dir=${dir}`);
3912
- args.push("-e", pattern, root);
4085
+ args.push("-e", !opts.literal && regex === "ere" ? toPosixClasses(pattern) : pattern, root);
3913
4086
  return args;
3914
4087
  }
3915
4088
  function globSegmentToRegex(segment) {
@@ -7599,15 +7772,15 @@ function nonPromptingFs(fs16) {
7599
7772
  findSymbol: (symbol, opts) => withinWorkspace(opts?.path) ? fs16.findSymbol(symbol, opts) : Promise.resolve(outsideRefusal(opts.path)),
7600
7773
  listDir: (p, depth) => withinWorkspace(p) ? fs16.listDir(p, depth) : Promise.resolve(outsideRefusal(p)),
7601
7774
  bash: async (command, signal) => {
7602
- const dialect = resolveResearchShell().dialect;
7603
- const { tier, reason } = classifyCommand(command, { dialect });
7775
+ const policy = researchPolicyOptions(resolveResearchShell());
7776
+ const { tier, reason } = classifyCommand(command, policy);
7604
7777
  if (tier !== "auto") {
7605
7778
  return refused2(
7606
7779
  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.`
7607
7780
  );
7608
7781
  }
7609
- const escaping = pathLikeArgs(command, { dialect }).find((p) => !withinWorkspace(p));
7610
- if (escaping) return outsideRefusal(escaping);
7782
+ const escaping = pathRefs(command, policy).map((ref) => resolveRef(fs16.getWorkspaceRoot(), ref)).find((r) => !r.inside);
7783
+ if (escaping) return outsideRefusal(escaping.abs);
7611
7784
  return fs16.bash(command, signal);
7612
7785
  }
7613
7786
  };
@@ -8923,6 +9096,12 @@ var VerdictEngine = class {
8923
9096
  idleSince = /* @__PURE__ */ new Map();
8924
9097
  /** Tasks waiting on the user, whose silence is expected rather than a sign of a stuck runner. */
8925
9098
  idlePaused = /* @__PURE__ */ new Set();
9099
+ /**
9100
+ * Open tool approvals per structured task. They arrive mid-turn and leave the
9101
+ * status alone (ADR-0018, W1), but a task waiting on one is waiting on the
9102
+ * user, so it is not idle either.
9103
+ */
9104
+ openApprovals = /* @__PURE__ */ new Map();
8926
9105
  onVerdict(listener) {
8927
9106
  this.listeners.push(listener);
8928
9107
  }
@@ -8951,7 +9130,7 @@ var VerdictEngine = class {
8951
9130
  }
8952
9131
  /** Restart the silence timer on fresh output; broadcasts the null transition if it was idle. */
8953
9132
  touchIdle(taskId, gen) {
8954
- if (this.idlePaused.has(taskId)) return;
9133
+ if (this.idlePaused.has(taskId) || this.openApprovals.get(taskId)?.size) return;
8955
9134
  const existing = this.idleTimers.get(taskId);
8956
9135
  if (existing) clearTimeout(existing);
8957
9136
  if (this.idleSince.get(taskId)) {
@@ -9033,7 +9212,10 @@ ${line}
9033
9212
  });
9034
9213
  if (isStructuredSession(session)) {
9035
9214
  session.onEvent((event) => {
9036
- if (event.type === "turn_start" && this.generations.get(task.id) === gen) this.resumeIdle(task.id);
9215
+ if (this.generations.get(task.id) !== gen) return;
9216
+ if (event.type === "turn_start") this.resumeIdle(task.id);
9217
+ else if (event.type === "permission_request" && !event.decided) this.approvalOpened(task.id, event.id);
9218
+ else if (event.type === "permission_decided" || event.type === "permission_withdrawn") this.approvalClosed(task.id, event.id, gen);
9037
9219
  });
9038
9220
  }
9039
9221
  session.onExit((exitCode) => {
@@ -9044,6 +9226,18 @@ ${line}
9044
9226
  for (const l of this.listeners) l(task.id, verdict);
9045
9227
  });
9046
9228
  }
9229
+ approvalOpened(taskId, approvalId) {
9230
+ const open = this.openApprovals.get(taskId) ?? /* @__PURE__ */ new Set();
9231
+ open.add(approvalId);
9232
+ this.openApprovals.set(taskId, open);
9233
+ this.clearIdle(taskId);
9234
+ }
9235
+ approvalClosed(taskId, approvalId, gen) {
9236
+ const open = this.openApprovals.get(taskId);
9237
+ if (!open?.delete(approvalId) || open.size) return;
9238
+ this.openApprovals.delete(taskId);
9239
+ this.touchIdle(taskId, gen);
9240
+ }
9047
9241
  /** Scan only the new text plus the unmatched carry, so a long run stays linear. */
9048
9242
  scanCheckpoints(taskId, session, text) {
9049
9243
  const scan = (this.checkpointCarry.get(taskId) ?? "") + text;
@@ -9065,6 +9259,7 @@ ${line}
9065
9259
  this.checkpointCarry.delete(taskId);
9066
9260
  this.pausedSessions.delete(taskId);
9067
9261
  this.idlePaused.delete(taskId);
9262
+ this.openApprovals.delete(taskId);
9068
9263
  this.clearIdle(taskId);
9069
9264
  }
9070
9265
  /** Manual "Mark complete" override: a pass verdict that bypasses evidence. */
@@ -9102,6 +9297,7 @@ ${line}
9102
9297
  this.idleTimers.clear();
9103
9298
  this.idleSince.clear();
9104
9299
  this.idlePaused.clear();
9300
+ this.openApprovals.clear();
9105
9301
  this.generations.clear();
9106
9302
  }
9107
9303
  decide(task, exitCode) {
@@ -10623,11 +10819,17 @@ function buildShellInvocation(command, args) {
10623
10819
  const inner = [command, ...args].map(posixShellQuote).join(" ");
10624
10820
  return { shellPath: "/bin/bash", shellArgs: ["-lc", inner] };
10625
10821
  }
10822
+ var BSD_SCRIPT_PLATFORMS = /* @__PURE__ */ new Set(["darwin", "freebsd", "openbsd", "netbsd"]);
10823
+ var BSD_SCRIPT_FEED = 'exec 5<&0; p=$(mktemp) || exit 1; { cat <&5 & echo $! >"$p"; wait; } 2>/dev/null | { script -q -e -F /dev/null /bin/sh -c "$0"; s=$?; kill "$(cat "$p")" 2>/dev/null; rm -f "$p"; exit $s; }';
10626
10824
  function wrapWithPty(command, args, opts = {}) {
10627
10825
  const inner = [command, ...args].map(posixShellQuote).join(" ");
10628
10826
  const watcher = opts.controlChannel ? 'exec 4<&0; ( while read -r C R <&3; do stty cols "$C" rows "$R" <&4; done ) & ' : "";
10629
10827
  const setup = opts.size ? `stty cols ${opts.size.cols} rows ${opts.size.rows}; ` : "";
10630
- return { command: "script", args: ["-q", "-e", "-f", "-c", `${watcher}${setup}${inner}`, "/dev/null"] };
10828
+ const line = `${watcher}${setup}${inner}`;
10829
+ if (BSD_SCRIPT_PLATFORMS.has(opts.platform ?? process.platform)) {
10830
+ return { command: "/bin/sh", args: ["-c", BSD_SCRIPT_FEED, line] };
10831
+ }
10832
+ return { command: "script", args: ["-q", "-e", "-f", "-c", line, "/dev/null"] };
10631
10833
  }
10632
10834
 
10633
10835
  // src/services/blockingPrompts.ts
@@ -10822,24 +11024,37 @@ var Block = /* @__PURE__ */ ((Block2) => {
10822
11024
  Block2[Block2["IfHeadlessSession"] = 8] = "IfHeadlessSession";
10823
11025
  Block2[Block2["IfInteractiveVariant"] = 9] = "IfInteractiveVariant";
10824
11026
  Block2[Block2["IfProjectTrust"] = 10] = "IfProjectTrust";
11027
+ Block2[Block2["IfHeadlessModel"] = 11] = "IfHeadlessModel";
10825
11028
  return Block2;
10826
11029
  })(Block || {});
10827
11030
  function isInteractive(ctx) {
10828
11031
  return ctx.interactive ?? !ctx.headless;
10829
11032
  }
11033
+ function majorVersion(version) {
11034
+ const match = version?.match(/(\d+)\.\d+/);
11035
+ return match ? Number(match[1]) : void 0;
11036
+ }
11037
+ function versionedInvocation(manifest, ctx) {
11038
+ const major = majorVersion(ctx.runnerVersion);
11039
+ if (major === void 0) return void 0;
11040
+ return (manifest.runner.versioned ?? []).filter((v) => major >= v.minMajor).sort((a, b) => b.minMajor - a.minMajor)[0];
11041
+ }
10830
11042
  function resolveArgs(manifest, ctx) {
10831
11043
  const args = [];
10832
11044
  const env = {};
10833
- if (manifest.runner.env) {
10834
- for (const [key, value] of Object.entries(manifest.runner.env)) {
11045
+ const versioned = versionedInvocation(manifest, ctx);
11046
+ const argsTemplate = versioned?.argsTemplate ?? manifest.runner.argsTemplate;
11047
+ const envTemplate = versioned ? versioned.env : manifest.runner.env;
11048
+ if (envTemplate) {
11049
+ for (const [key, value] of Object.entries(envTemplate)) {
10835
11050
  const resolved = substituteSimple(value, ctx);
10836
11051
  if (resolved.length > 0) env[key] = resolved;
10837
11052
  }
10838
11053
  }
10839
11054
  let block = 0 /* None */;
10840
11055
  let include = true;
10841
- for (let i = 0; i < manifest.runner.argsTemplate.length; i++) {
10842
- const raw = manifest.runner.argsTemplate[i];
11056
+ for (let i = 0; i < argsTemplate.length; i++) {
11057
+ const raw = argsTemplate[i];
10843
11058
  const blockOpen = parseBlockOpen(raw);
10844
11059
  if (blockOpen !== null) {
10845
11060
  if (block !== 0 /* None */) throw new ResolveError(`Nested conditional block at position ${i}: "${raw}"`);
@@ -10898,6 +11113,8 @@ function parseBlockOpen(raw) {
10898
11113
  return 9 /* IfInteractiveVariant */;
10899
11114
  case "{{if projectTrust}}":
10900
11115
  return 10 /* IfProjectTrust */;
11116
+ case "{{if headlessModel}}":
11117
+ return 11 /* IfHeadlessModel */;
10901
11118
  default:
10902
11119
  return null;
10903
11120
  }
@@ -10937,6 +11154,9 @@ function shouldIncludeBlock(block, ctx) {
10937
11154
  // there is no cwd to name.
10938
11155
  case 10 /* IfProjectTrust */:
10939
11156
  return isInteractive(ctx) && !!ctx.cwd;
11157
+ // A model flag that only the non-interactive subcommand takes.
11158
+ case 11 /* IfHeadlessModel */:
11159
+ return !!ctx.model && !isInteractive(ctx);
10940
11160
  default:
10941
11161
  return true;
10942
11162
  }
@@ -10982,6 +11202,10 @@ function resolveToken(token, manifest, ctx) {
10982
11202
  if (token === "{{model}}") return ctx.model || "";
10983
11203
  if (token === "{{thinkingEffort}}") return ctx.thinkingEffort || "";
10984
11204
  if (token === "{{mode}}") return ctx.mode || "build";
11205
+ if (token === "{{modelWithVariant}}") {
11206
+ if (!ctx.model) return "";
11207
+ return ctx.thinkingEffort ? `${ctx.model}#${ctx.thinkingEffort}` : ctx.model;
11208
+ }
10985
11209
  if (token === "{{projectTrust}}") {
10986
11210
  if (!ctx.cwd) return "";
10987
11211
  return `projects.${JSON.stringify(ctx.cwd)}.trust_level="trusted"`;
@@ -11056,13 +11280,21 @@ function resolveOpencodeVariantConfig(ctx) {
11056
11280
  }
11057
11281
  return JSON.stringify(config);
11058
11282
  }
11283
+ function resolveOpencodeSessionConfig(ctx) {
11284
+ const mode = ctx.mode || "build";
11285
+ const pinned = resolveOpencodeVariantConfig(ctx);
11286
+ const config = pinned ? JSON.parse(pinned) : {};
11287
+ config.default_agent = mode;
11288
+ if (ctx.model && !pinned) config.agent = { [mode]: { model: ctx.model } };
11289
+ return JSON.stringify(config);
11290
+ }
11059
11291
  function substituteSimple(value, ctx) {
11060
11292
  const conditioned = value.replace(/\{\{if (\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (match, name, body) => {
11061
11293
  const block = parseBlockOpen(`{{if ${name}}}`);
11062
11294
  if (block === null) return match;
11063
11295
  return shouldIncludeBlock(block, ctx) ? body : "";
11064
11296
  });
11065
- return conditioned.replace(/\{\{opencodeVariantConfig\}\}/g, () => resolveOpencodeVariantConfig(ctx)).replace(/\{\{prompt\}\}/g, ctx.prompt).replace(/\{\{model\}\}/g, ctx.model || "").replace(/\{\{thinkingEffort\}\}/g, ctx.thinkingEffort || "").replace(/\{\{mode\}\}/g, ctx.mode || "build");
11297
+ return conditioned.replace(/\{\{opencodeVariantConfig\}\}/g, () => resolveOpencodeVariantConfig(ctx)).replace(/\{\{opencodeSessionConfig\}\}/g, () => resolveOpencodeSessionConfig(ctx)).replace(/\{\{prompt\}\}/g, ctx.prompt).replace(/\{\{model\}\}/g, ctx.model || "").replace(/\{\{thinkingEffort\}\}/g, ctx.thinkingEffort || "").replace(/\{\{mode\}\}/g, ctx.mode || "build");
11066
11298
  }
11067
11299
 
11068
11300
  // src/utils/launch.ts
@@ -12765,9 +12997,502 @@ ${item.text}` : item.text });
12765
12997
 
12766
12998
  // src/services/harness/OpenCodeAdapter.ts
12767
12999
  import { randomBytes as randomBytes2 } from "crypto";
13000
+
13001
+ // src/services/harness/OpenCodeV2.ts
13002
+ var STREAM_CONNECT_TIMEOUT_MS = 5e3;
13003
+ var ACTIVE_POLL_INTERVAL_MS = 1e3;
13004
+ var IDLE_POLLS_BEFORE_START = 3;
13005
+ var PLANNER_RULES = [
13006
+ { action: "question", resource: "*", effect: "deny" },
13007
+ { action: "edit", resource: "*", effect: "deny" }
13008
+ ];
13009
+ var TASK_RULES = [{ action: "question", resource: "*", effect: "deny" }];
13010
+ var AUTO_APPROVALS = "auto";
13011
+ function newTurnState() {
13012
+ 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() };
13013
+ }
13014
+ function splitModelId(id) {
13015
+ const slash = id.indexOf("/");
13016
+ if (slash <= 0 || slash === id.length - 1) return null;
13017
+ return { providerID: id.slice(0, slash), id: id.slice(slash + 1) };
13018
+ }
13019
+ function delay(ms, signal) {
13020
+ return new Promise((resolve7) => {
13021
+ const timer = setTimeout(resolve7, ms);
13022
+ timer.unref?.();
13023
+ signal?.addEventListener("abort", () => {
13024
+ clearTimeout(timer);
13025
+ resolve7();
13026
+ }, { once: true });
13027
+ });
13028
+ }
13029
+ function contentText(content) {
13030
+ return (content ?? []).filter((c) => c.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
13031
+ }
13032
+ function subagentDigest(output) {
13033
+ const inner = output.match(/<subagent[^>]*>\n?([\s\S]*?)\n?<\/subagent>/);
13034
+ return inner ? inner[1] : output;
13035
+ }
13036
+ function permissionReply(decision) {
13037
+ if (decision.decision === "allow") return { decision: "once" };
13038
+ if (decision.decision === "allowForTask") return { decision: "always" };
13039
+ const note = decision.note?.trim();
13040
+ return note ? { decision: "reject", message: note } : { decision: "reject" };
13041
+ }
13042
+ var OpenCodeV2 = class {
13043
+ constructor(host, opts, role) {
13044
+ this.host = host;
13045
+ this.opts = opts;
13046
+ this.role = role;
13047
+ this.agent = opts.kind === "task" ? opts.flags.permissionMode : "plan";
13048
+ const split = opts.model ? splitModelId(opts.model) : null;
13049
+ const variant = opts.kind === "task" ? opts.flags.effort : opts.effort;
13050
+ if (split) this.model = { ...split, ...variant ? { variant } : {} };
13051
+ }
13052
+ host;
13053
+ opts;
13054
+ role;
13055
+ sessionId = null;
13056
+ model = null;
13057
+ agent;
13058
+ turn = null;
13059
+ turnHasText = false;
13060
+ interruptRequested = false;
13061
+ /** A task's requests waiting for an answer, by request id, with the session that asked. */
13062
+ openPermissions = /* @__PURE__ */ new Map();
13063
+ nativeSessionId() {
13064
+ return this.sessionId;
13065
+ }
13066
+ async start() {
13067
+ const resume = this.opts.resumeSessionId;
13068
+ if (resume) {
13069
+ const existing = await this.host.json("GET", `/api/session/${resume}`).catch(() => null);
13070
+ if (existing?.data?.id) {
13071
+ this.sessionId = existing.data.id;
13072
+ if (this.model) await this.host.request("POST", `/api/session/${this.sessionId}/model`, { model: this.model }).catch(() => void 0);
13073
+ await this.host.request("POST", `/api/session/${this.sessionId}/agent`, { agent: this.agent }).catch(() => void 0);
13074
+ await this.applyInstructions();
13075
+ return;
13076
+ }
13077
+ if (this.opts.kind === "task") throw new Error(`OpenCode could not resume session ${resume}: the server does not know it.`);
13078
+ }
13079
+ const created = await this.host.json("POST", "/api/session", {
13080
+ agent: this.agent,
13081
+ ...this.model ? { model: this.model } : {},
13082
+ permissions: this.role === "planner" ? PLANNER_RULES : TASK_RULES
13083
+ });
13084
+ if (!created?.data?.id) throw new Error(`The OpenCode ${this.role} server did not return a session id.`);
13085
+ this.sessionId = created.data.id;
13086
+ await this.applyInstructions();
13087
+ }
13088
+ /** The planner's system prompt rides on the session as an instruction entry — a prompt has no system field. */
13089
+ async applyInstructions() {
13090
+ if (this.opts.kind !== "planner" || !this.opts.systemPrompt) return;
13091
+ const response = await this.host.request("PUT", `/api/experimental/session/${this.sessionId}/instructions/entries/ordewell-planner`, { value: this.opts.systemPrompt });
13092
+ if (!response.ok) throw new Error(`OpenCode did not take the planner instructions: ${response.status} ${response.statusText}`);
13093
+ }
13094
+ async send(message, onEvent, signal, onActivity) {
13095
+ if (!this.sessionId) throw new Error(`OpenCode ${this.role} session is not started`);
13096
+ if (this.host.isExited()) {
13097
+ onEvent({ type: "error", message: this.host.exitMessage() });
13098
+ return;
13099
+ }
13100
+ const state = newTurnState();
13101
+ this.turnHasText = false;
13102
+ this.interruptRequested = false;
13103
+ let markDone = () => {
13104
+ };
13105
+ const ended = new Promise((resolve7) => {
13106
+ markDone = resolve7;
13107
+ });
13108
+ const turn = { live: false, done: false, outcome: null, failure: null, ended, finish: () => {
13109
+ turn.done = true;
13110
+ markDone();
13111
+ } };
13112
+ this.turn = turn;
13113
+ const closeStream = await this.openStream(state, turn, onEvent, onActivity);
13114
+ const poll = new AbortController();
13115
+ try {
13116
+ try {
13117
+ await this.host.json("POST", `/api/session/${this.sessionId}/prompt`, { text: message }, signal);
13118
+ } catch (err) {
13119
+ if (signal?.aborted) return;
13120
+ onEvent({ type: "error", message: `OpenCode did not take the message: ${err instanceof Error ? err.message : String(err)}` });
13121
+ return;
13122
+ }
13123
+ void this.pollActive(turn, poll.signal);
13124
+ const aborted = new Promise((resolve7) => {
13125
+ if (signal?.aborted) resolve7();
13126
+ signal?.addEventListener("abort", () => resolve7(), { once: true });
13127
+ });
13128
+ await Promise.race([turn.ended, aborted, this.host.processEnded]);
13129
+ if (signal?.aborted) return;
13130
+ if (!turn.done) {
13131
+ onEvent({ type: "error", message: this.host.exitMessage() });
13132
+ return;
13133
+ }
13134
+ await this.readBack(state, onEvent);
13135
+ for (const id of [...this.openPermissions.keys()]) {
13136
+ this.openPermissions.delete(id);
13137
+ onEvent({ type: "permission_cancelled", id });
13138
+ }
13139
+ if (turn.outcome === "interrupted" || this.interruptRequested) onEvent({ type: "turn_end", interrupted: true });
13140
+ else if (turn.outcome === "failed") onEvent({ type: "error", message: turn.failure ?? "OpenCode reported that the turn failed." });
13141
+ else onEvent({ type: "turn_end" });
13142
+ } finally {
13143
+ this.turn = null;
13144
+ poll.abort();
13145
+ await closeStream();
13146
+ }
13147
+ }
13148
+ /**
13149
+ * The stream can drop the end frame while still delivering the rest, so the
13150
+ * turn also asks which sessions are running. A session that is not, after
13151
+ * this turn's work was seen, is over; one never seen working after a few
13152
+ * polls had already finished before the stream connected.
13153
+ */
13154
+ async pollActive(turn, signal) {
13155
+ let idlePolls = 0;
13156
+ while (!turn.done && !signal.aborted) {
13157
+ await delay(ACTIVE_POLL_INTERVAL_MS, signal);
13158
+ if (turn.done || signal.aborted) return;
13159
+ const active = await this.host.json("GET", "/api/session/active").catch(() => null);
13160
+ if (!active?.data) continue;
13161
+ if (this.sessionId && this.sessionId in active.data) {
13162
+ turn.live = true;
13163
+ idlePolls = 0;
13164
+ continue;
13165
+ }
13166
+ idlePolls++;
13167
+ if (turn.live || idlePolls >= IDLE_POLLS_BEFORE_START) {
13168
+ const messages = await this.messages();
13169
+ const idle = [...messages ?? []].reverse().find((m) => m.type === "idle");
13170
+ if (idle?.outcome === "failed" || idle?.outcome === "interrupted" || idle?.outcome === "succeeded") turn.outcome ??= idle.outcome;
13171
+ turn.outcome ??= "succeeded";
13172
+ if (!turn.done) turn.finish();
13173
+ }
13174
+ }
13175
+ }
13176
+ async messages() {
13177
+ const response = await this.host.json("GET", `/api/session/${this.sessionId}/message`).catch(() => null);
13178
+ return Array.isArray(response?.data) ? response.data : null;
13179
+ }
13180
+ /**
13181
+ * Whatever of the turn's own replies the stream missed. A message whose text
13182
+ * the stream delivered adds nothing here; one that lost its text frames still
13183
+ * reaches the plain-text channel, where the done marker is looked for.
13184
+ */
13185
+ async readBack(state, onEvent) {
13186
+ const messages = await this.messages();
13187
+ if (!messages) return;
13188
+ const latestUser = messages.findIndex((m) => m.type === "user");
13189
+ const own = latestUser < 0 ? messages : messages.slice(0, latestUser);
13190
+ for (const message of [...own].reverse()) {
13191
+ if (message.type !== "assistant" || !message.id || state.streamedMessages.has(message.id)) continue;
13192
+ (message.content ?? []).forEach((part, index) => {
13193
+ if (part.type === "text" && part.text) this.emitText(`${message.id}:readback:${index}`, part.text, state, onEvent);
13194
+ });
13195
+ }
13196
+ }
13197
+ /**
13198
+ * Abort the running turn, keeping the server and its session. OpenCode
13199
+ * acknowledges with `session.execution.interrupted`, which also ends the turn.
13200
+ */
13201
+ async interrupt(timeoutMs) {
13202
+ if (!this.sessionId || this.host.isExited()) return false;
13203
+ const turn = this.turn;
13204
+ this.interruptRequested = true;
13205
+ const response = await this.host.json("POST", `/api/session/${this.sessionId}/interrupt`).catch(() => null);
13206
+ if (!response) return false;
13207
+ if (!turn || turn.done) return true;
13208
+ let timer;
13209
+ const timedOut = new Promise((resolve7) => {
13210
+ timer = setTimeout(() => resolve7(false), timeoutMs);
13211
+ timer.unref?.();
13212
+ });
13213
+ const acknowledged = await Promise.race([turn.ended.then(() => true), this.host.processEnded.then(() => false), timedOut]);
13214
+ clearTimeout(timer);
13215
+ return acknowledged;
13216
+ }
13217
+ answerPermission(id, decision) {
13218
+ const sessionId = this.openPermissions.get(id);
13219
+ if (sessionId === void 0) return false;
13220
+ this.openPermissions.delete(id);
13221
+ void this.replyPermission(id, sessionId, permissionReply(decision)).catch(() => {
13222
+ });
13223
+ return true;
13224
+ }
13225
+ async replyPermission(id, sessionId, reply) {
13226
+ await this.host.request("POST", `/api/session/${sessionId}/permission/${id}/reply`, reply);
13227
+ }
13228
+ async openStream(state, turn, onEvent, onActivity) {
13229
+ const streamAbort = new AbortController();
13230
+ let connected = () => {
13231
+ };
13232
+ const ready = new Promise((resolve7) => {
13233
+ connected = resolve7;
13234
+ });
13235
+ const live = this.streamEvents(streamAbort.signal, (frame) => this.onFrame(frame, state, turn, onEvent), connected, onActivity);
13236
+ await Promise.race([ready, new Promise((r) => {
13237
+ const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS);
13238
+ t.unref?.();
13239
+ })]);
13240
+ return async () => {
13241
+ streamAbort.abort();
13242
+ await live.catch(() => {
13243
+ });
13244
+ };
13245
+ }
13246
+ async streamEvents(signal, onFrame, onConnected, onActivity) {
13247
+ const response = await this.host.openEvents(signal).catch(() => null);
13248
+ const body = response?.body;
13249
+ if (!body) {
13250
+ onConnected();
13251
+ return;
13252
+ }
13253
+ onConnected();
13254
+ const reader = body.getReader();
13255
+ const decoder = new TextDecoder();
13256
+ let buffer = "";
13257
+ for (; ; ) {
13258
+ const { done, value } = await reader.read().catch(() => ({ done: true, value: void 0 }));
13259
+ if (done) return;
13260
+ onActivity?.();
13261
+ buffer += decoder.decode(value, { stream: true });
13262
+ let newline;
13263
+ while ((newline = buffer.indexOf("\n")) >= 0) {
13264
+ const line = buffer.slice(0, newline).trim();
13265
+ buffer = buffer.slice(newline + 1);
13266
+ if (!line.startsWith("data:")) continue;
13267
+ try {
13268
+ onFrame(JSON.parse(line.slice(5).trim()));
13269
+ } catch {
13270
+ }
13271
+ }
13272
+ }
13273
+ }
13274
+ /**
13275
+ * One frame. Only this session and its children are followed: the stream is
13276
+ * the whole server's, and another client's session is none of this turn's business.
13277
+ */
13278
+ onFrame(frame, state, turn, onEvent) {
13279
+ const data = frame.data;
13280
+ if (!data || !frame.type) return;
13281
+ if (frame.type === "session.created") {
13282
+ if (data.parentID && data.parentID === this.sessionId && data.sessionID && !state.children.has(data.sessionID)) state.children.set(data.sessionID, null);
13283
+ return;
13284
+ }
13285
+ const session = data.sessionID;
13286
+ if (!session) return;
13287
+ const isChild = session !== this.sessionId;
13288
+ if (isChild && !state.children.has(session)) return;
13289
+ if (!isChild) {
13290
+ if (frame.type === "session.execution.started") {
13291
+ turn.live = true;
13292
+ return;
13293
+ }
13294
+ if (frame.type === "session.execution.succeeded" || frame.type === "session.execution.failed" || frame.type === "session.execution.interrupted") {
13295
+ if (!turn.live || turn.done) return;
13296
+ turn.outcome = frame.type === "session.execution.succeeded" ? "succeeded" : frame.type === "session.execution.failed" ? "failed" : "interrupted";
13297
+ if (turn.outcome === "failed") turn.failure = data.error?.message ?? data.error?.type ?? null;
13298
+ turn.finish();
13299
+ return;
13300
+ }
13301
+ }
13302
+ if (frame.type === "permission.asked") {
13303
+ if (this.role === "task") this.askPermission(data, session, state, onEvent);
13304
+ else this.denyPermission(data, session, state, onEvent);
13305
+ return;
13306
+ }
13307
+ if (frame.type === "permission.replied") {
13308
+ const id = data.requestID;
13309
+ if (id && this.openPermissions.delete(id)) onEvent({ type: "permission_cancelled", id });
13310
+ return;
13311
+ }
13312
+ let subagentId;
13313
+ if (isChild) {
13314
+ const owner = state.children.get(session);
13315
+ if (!owner) {
13316
+ state.heldFrames.set(session, [...state.heldFrames.get(session) ?? [], frame]);
13317
+ return;
13318
+ }
13319
+ subagentId = owner;
13320
+ }
13321
+ this.onContentFrame(frame, data, state, turn, onEvent, subagentId);
13322
+ }
13323
+ onContentFrame(frame, data, state, turn, onEvent, subagentId) {
13324
+ const message = data.assistantMessageID ?? "";
13325
+ switch (frame.type) {
13326
+ case "session.step.started":
13327
+ if (message && data.model?.providerID && data.model.id) state.stepModels.set(message, `${data.model.providerID}/${data.model.id}`);
13328
+ return;
13329
+ case "session.reasoning.delta":
13330
+ if (data.delta) onEvent({ type: "thinking_delta", text: data.delta, subagentId });
13331
+ return;
13332
+ case "session.reasoning.ended": {
13333
+ const key = `reasoning:${message}:${data.ordinal ?? 0}`;
13334
+ if (!data.text || state.seen.has(key)) return;
13335
+ state.seen.add(key);
13336
+ onEvent({ type: "thinking", text: data.text, subagentId });
13337
+ return;
13338
+ }
13339
+ case "session.text.delta":
13340
+ if (!subagentId && data.delta) this.onTextDelta(`${message}:${data.ordinal ?? 0}`, data.delta, state, onEvent);
13341
+ return;
13342
+ case "session.text.ended":
13343
+ if (subagentId || !data.text) return;
13344
+ if (message) state.streamedMessages.add(message);
13345
+ this.emitText(`${message}:${data.ordinal ?? 0}`, data.text, state, onEvent);
13346
+ return;
13347
+ case "session.tool.input.started":
13348
+ if (data.id && data.name) state.toolNames.set(data.id, data.name);
13349
+ return;
13350
+ case "session.tool.called": {
13351
+ const id = data.id;
13352
+ if (!id || state.seen.has(`call:${id}`)) return;
13353
+ state.seen.add(`call:${id}`);
13354
+ const input = data.input ?? {};
13355
+ const name = state.toolNames.get(id) ?? "tool";
13356
+ const brief = typeof input.description === "string" ? input.description : typeof input.prompt === "string" ? input.prompt : "";
13357
+ state.briefs.set(id, brief);
13358
+ onEvent({ type: "tool_call", id, name, args: input, subagentId });
13359
+ return;
13360
+ }
13361
+ case "session.tool.progress":
13362
+ if (!subagentId) this.adoptChild(data, state, onEvent, turn);
13363
+ return;
13364
+ case "session.tool.success":
13365
+ case "session.tool.failed": {
13366
+ const id = data.id;
13367
+ if (!id || state.seen.has(`result:${id}`)) return;
13368
+ state.seen.add(`result:${id}`);
13369
+ const success = frame.type === "session.tool.success";
13370
+ const output = success ? contentText(data.content) : data.error?.message ?? data.error?.type ?? "";
13371
+ onEvent({ type: "tool_result", id, name: state.toolNames.get(id) ?? "tool", output, success, subagentId });
13372
+ if (!subagentId) this.finishChild(id, success, output, state, onEvent);
13373
+ return;
13374
+ }
13375
+ case "session.step.ended":
13376
+ case "session.step.failed":
13377
+ this.countUsage(data, state, onEvent, subagentId);
13378
+ return;
13379
+ default:
13380
+ }
13381
+ }
13382
+ adoptChild(data, state, onEvent, turn) {
13383
+ const child = data.metadata?.sessionID;
13384
+ const callId = data.id;
13385
+ if (!child || !callId || state.children.get(child)) return;
13386
+ const model = state.stepModels.get(data.assistantMessageID ?? "");
13387
+ onEvent({ type: "subagent_started", subagentId: callId, brief: state.briefs.get(callId) ?? "", ...model ? { model } : {} });
13388
+ state.children.set(child, callId);
13389
+ const held = state.heldFrames.get(child) ?? [];
13390
+ state.heldFrames.delete(child);
13391
+ for (const frame of held) this.onFrame(frame, state, turn, onEvent);
13392
+ }
13393
+ finishChild(callId, success, output, state, onEvent) {
13394
+ if (![...state.children.values()].includes(callId)) return;
13395
+ onEvent({ type: "subagent_finished", subagentId: callId, outcome: success ? "done" : "failed", digest: subagentDigest(output) });
13396
+ }
13397
+ /** One model call's usage, once, when its step ends. Zeros mean the call failed before the provider answered. */
13398
+ countUsage(data, state, onEvent, subagentId) {
13399
+ const message = data.assistantMessageID;
13400
+ const tokens = data.tokens;
13401
+ if (!message || !tokens || state.seen.has(`usage:${message}`)) return;
13402
+ state.seen.add(`usage:${message}`);
13403
+ const prompt = partedPromptUsage({ uncached: tokens.input, cacheRead: tokens.cache?.read, cacheWrite: tokens.cache?.write });
13404
+ const outputTokens = (tokens.output ?? 0) + (tokens.reasoning ?? 0);
13405
+ if ((prompt.inputTokens ?? 0) + outputTokens === 0) return;
13406
+ const record = { source: "opencode", ...prompt, outputTokens };
13407
+ const model = state.stepModels.get(message);
13408
+ if (model) record.model = model;
13409
+ if (typeof data.cost === "number" && data.cost > 0) record.reportedCost = { amount: data.cost, currency: "USD" };
13410
+ if (subagentId) record.subagentId = subagentId;
13411
+ onEvent({ type: "usage", record });
13412
+ }
13413
+ /** 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. */
13414
+ emitText(id, text, state, onEvent) {
13415
+ if (state.seen.has(`text:${id}`)) return;
13416
+ state.seen.add(`text:${id}`);
13417
+ if (!text.trim()) return;
13418
+ const lead = state.textRuns.get(id)?.lead ?? (this.turnHasText ? "\n\n" : "");
13419
+ onEvent({ type: "assistant_text", text: `${lead}${text}` });
13420
+ this.turnHasText = true;
13421
+ }
13422
+ /**
13423
+ * Stream one piece of a reply text. The paragraph break goes out with the
13424
+ * first visible delta, so the deltas add up to exactly the text the completed
13425
+ * run then re-sends; a run that is only whitespace so far is held back.
13426
+ */
13427
+ onTextDelta(id, delta, state, onEvent) {
13428
+ if (state.seen.has(`text:${id}`)) return;
13429
+ const run = state.textRuns.get(id) ?? { held: "", lead: null };
13430
+ state.textRuns.set(id, run);
13431
+ if (run.lead !== null) {
13432
+ onEvent({ type: "assistant_text_delta", text: delta });
13433
+ return;
13434
+ }
13435
+ run.held += delta;
13436
+ if (!run.held.trim()) return;
13437
+ run.lead = this.turnHasText ? "\n\n" : "";
13438
+ this.turnHasText = true;
13439
+ onEvent({ type: "assistant_text_delta", text: `${run.lead}${run.held}` });
13440
+ }
13441
+ permissionRequest(ask) {
13442
+ const id = ask.id;
13443
+ if (!id) return null;
13444
+ const scope = (ask.resources ?? []).join(", ");
13445
+ return {
13446
+ type: "permission_request",
13447
+ id,
13448
+ name: ask.action ?? "permission",
13449
+ detail: JSON.stringify(scope ? { scope } : {}),
13450
+ input: ask.resources ? { resources: ask.resources } : {},
13451
+ // The patterns `always` would grant: the runner's own offer, and the only grounds for "Allow for this task".
13452
+ ...ask.save?.length ? { suggestions: ask.save } : {},
13453
+ ...ask.source?.id ? { toolUseId: ask.source.id } : {}
13454
+ };
13455
+ }
13456
+ /**
13457
+ * Deny one planner request. OpenCode blocks the turn until it is answered, so
13458
+ * this must answer: `reject` rather than a silent drop is the "absent answer
13459
+ * is a denial" invariant ADR-0008 states. The refusal is announced so the
13460
+ * timeline shows the planner reaching for something it may not have.
13461
+ */
13462
+ denyPermission(ask, session, state, onEvent) {
13463
+ const request = this.permissionRequest(ask);
13464
+ if (!request || state.seen.has(`perm:${request.id}`)) return;
13465
+ state.seen.add(`perm:${request.id}`);
13466
+ onEvent(request);
13467
+ void this.replyPermission(request.id, session, { decision: "reject" }).catch(() => {
13468
+ });
13469
+ }
13470
+ /**
13471
+ * A task's request. Under a mode whose manifest sets `approvals: auto` it is
13472
+ * answered at once with what `opencode run --auto` answers, so the same plan
13473
+ * behaves the same on both transports (ADR-0001), and announced already
13474
+ * decided so the log still shows it. Any other mode leaves it open for an
13475
+ * approval card.
13476
+ */
13477
+ askPermission(ask, session, state, onEvent) {
13478
+ const request = this.permissionRequest(ask);
13479
+ if (!request || state.seen.has(`perm:${request.id}`)) return;
13480
+ state.seen.add(`perm:${request.id}`);
13481
+ if (this.opts.kind === "task" && this.opts.flags.modeSettings.approvals === AUTO_APPROVALS) {
13482
+ onEvent({ ...request, decided: { decision: "allow" } });
13483
+ void this.replyPermission(request.id, session, { decision: "once" }).catch(() => {
13484
+ });
13485
+ return;
13486
+ }
13487
+ this.openPermissions.set(request.id, session);
13488
+ onEvent(request);
13489
+ }
13490
+ };
13491
+
13492
+ // src/services/harness/OpenCodeAdapter.ts
12768
13493
  var SERVER_READY_TIMEOUT_MS = 3e4;
12769
13494
  var STDERR_TAIL_CHARS2 = 4e3;
12770
- var STREAM_CONNECT_TIMEOUT_MS = 5e3;
13495
+ var STREAM_CONNECT_TIMEOUT_MS2 = 5e3;
12771
13496
  var RECOVERY_POLL_INTERVAL_MS = 2e3;
12772
13497
  var RECOVERY_TIMEOUT_MS = 9e5;
12773
13498
  var STATUS_POLL_INTERVAL_MS = 1e3;
@@ -12780,8 +13505,8 @@ var DISABLED_TOOLS = {
12780
13505
  todowrite: false
12781
13506
  };
12782
13507
  var TASK_DISABLED_TOOLS = { question: false };
12783
- var AUTO_APPROVALS = "auto";
12784
- function splitModelId(id) {
13508
+ var AUTO_APPROVALS2 = "auto";
13509
+ function splitModelId2(id) {
12785
13510
  const slash = id.indexOf("/");
12786
13511
  if (slash <= 0 || slash === id.length - 1) return null;
12787
13512
  return { providerID: id.slice(0, slash), modelID: id.slice(slash + 1) };
@@ -12837,7 +13562,7 @@ function unwrapFileToolOutput(output) {
12837
13562
  const inner = output.match(/<content>\n?([\s\S]*?)\n?<\/content>/);
12838
13563
  return inner ? inner[1] : output;
12839
13564
  }
12840
- function delay(ms, signal) {
13565
+ function delay2(ms, signal) {
12841
13566
  return new Promise((resolve7) => {
12842
13567
  const timer = setTimeout(resolve7, ms);
12843
13568
  timer.unref?.();
@@ -12847,10 +13572,10 @@ function delay(ms, signal) {
12847
13572
  }, { once: true });
12848
13573
  });
12849
13574
  }
12850
- function newTurnState() {
13575
+ function newTurnState2() {
12851
13576
  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() };
12852
13577
  }
12853
- function permissionReply(decision) {
13578
+ function permissionReply2(decision) {
12854
13579
  if (decision.decision === "allow") return { reply: "once" };
12855
13580
  if (decision.decision === "allowForTask") return { reply: "always" };
12856
13581
  const note = decision.note?.trim();
@@ -12887,6 +13612,8 @@ var OpenCodeAdapter = class {
12887
13612
  interruptRequested = false;
12888
13613
  /** A task's requests waiting for an answer, by request id, with the session that asked. */
12889
13614
  openPermissions = /* @__PURE__ */ new Map();
13615
+ /** Set once the server turns out to speak the 2.x API, which then owns the session. */
13616
+ v2 = null;
12890
13617
  async start(opts) {
12891
13618
  if (opts.kind === "task") this.task = opts;
12892
13619
  else this.planner = opts;
@@ -12901,14 +13628,14 @@ var OpenCodeAdapter = class {
12901
13628
  throw new ExecutableNotFoundError("opencode", PATH);
12902
13629
  }
12903
13630
  const workspaceEnv = await (this.deps.workspaceEnv ?? workspaceEnvOf)(opts.cwd);
12904
- const password = opts.kind === "task" ? randomBytes2(24).toString("base64url") : null;
13631
+ const password = randomBytes2(24).toString("base64url");
12905
13632
  this.process = this.deps.spawn(launch.file, launch.args, {
12906
- env: runnerEnv(PATH, password ? { ...workspaceEnv, OPENCODE_SERVER_USERNAME: SERVER_USERNAME, OPENCODE_SERVER_PASSWORD: password } : workspaceEnv),
13633
+ env: runnerEnv(PATH, { ...workspaceEnv, OPENCODE_SERVER_USERNAME: SERVER_USERNAME, OPENCODE_SERVER_PASSWORD: password }),
12907
13634
  stdio: ["pipe", "pipe", "pipe"],
12908
13635
  cwd: opts.cwd,
12909
13636
  windowsVerbatimArguments: launch.verbatim
12910
13637
  });
12911
- if (password) this.authorization = `Basic ${Buffer.from(`${SERVER_USERNAME}:${password}`).toString("base64")}`;
13638
+ this.authorization = `Basic ${Buffer.from(`${SERVER_USERNAME}:${password}`).toString("base64")}`;
12912
13639
  this.process.stdin?.on("error", () => {
12913
13640
  });
12914
13641
  const banner = new Promise((resolve7) => {
@@ -12945,6 +13672,24 @@ ${err.message}`).slice(-STDERR_TAIL_CHARS2);
12945
13672
 
12946
13673
  ${this.stderrTail.trim()}` : ""}`);
12947
13674
  }
13675
+ if (await this.speaksV2()) {
13676
+ this.v2 = new OpenCodeV2({
13677
+ request: (method, path26, body, signal) => this.request(method, path26, body, signal),
13678
+ json: (method, path26, body, signal) => this.json(method, path26, body, signal),
13679
+ openEvents: (signal) => this.deps.fetch(`${this.baseUrl}/api/event`, { signal, headers: this.headers() }),
13680
+ processEnded: this.processEnded,
13681
+ isExited: () => this.exited,
13682
+ exitMessage: () => this.exitMessage()
13683
+ }, opts, this.role());
13684
+ try {
13685
+ await this.v2.start();
13686
+ } catch (err) {
13687
+ this.dispose();
13688
+ throw err;
13689
+ }
13690
+ this.sessionId = this.v2.nativeSessionId();
13691
+ return;
13692
+ }
12948
13693
  if (opts.resumeSessionId) {
12949
13694
  const existing = await this.json("GET", `/session/${opts.resumeSessionId}`).catch(() => null);
12950
13695
  if (existing?.id) {
@@ -12968,12 +13713,17 @@ ${this.stderrTail.trim()}` : ""}`);
12968
13713
  onEvent({ type: "error", message: this.exitMessage() });
12969
13714
  return;
12970
13715
  }
13716
+ if (this.v2) {
13717
+ await this.v2.send(message, onEvent, signal, onActivity);
13718
+ if (signal?.aborted) this.dispose();
13719
+ return;
13720
+ }
12971
13721
  if (this.task) return this.sendTask(this.task, message, onEvent, signal, onActivity);
12972
- const turn = newTurnState();
13722
+ const turn = newTurnState2();
12973
13723
  this.turnHasText = false;
12974
13724
  const closeStream = await this.openStream(turn, onEvent, onActivity);
12975
13725
  try {
12976
- const model = this.planner?.model ? splitModelId(this.planner.model) : null;
13726
+ const model = this.planner?.model ? splitModelId2(this.planner.model) : null;
12977
13727
  const body = {
12978
13728
  parts: [{ type: "text", text: message }],
12979
13729
  // The read-only guarantee: OpenCode's own plan agent has no write tools.
@@ -13014,7 +13764,7 @@ ${this.stderrTail.trim()}` : ""}`);
13014
13764
  * {@link recoverReply}, never applies.
13015
13765
  */
13016
13766
  async sendTask(task, message, onEvent, signal, onActivity) {
13017
- const state = newTurnState();
13767
+ const state = newTurnState2();
13018
13768
  this.turnHasText = false;
13019
13769
  this.interruptRequested = false;
13020
13770
  let markDone = () => {
@@ -13030,7 +13780,7 @@ ${this.stderrTail.trim()}` : ""}`);
13030
13780
  const closeStream = await this.openStream(state, onEvent, onActivity);
13031
13781
  const poll = new AbortController();
13032
13782
  try {
13033
- const model = task.model ? splitModelId(task.model) : null;
13783
+ const model = task.model ? splitModelId2(task.model) : null;
13034
13784
  const body = {
13035
13785
  parts: [{ type: "text", text: message }],
13036
13786
  // The manifest's meaning of the task's mode (ADR-0001): for OpenCode a mode is an agent.
@@ -13110,7 +13860,7 @@ ${this.stderrTail.trim()}` : ""}`);
13110
13860
  */
13111
13861
  async pollStatus(turn, signal) {
13112
13862
  while (!turn.done && !signal.aborted) {
13113
- await delay(STATUS_POLL_INTERVAL_MS, signal);
13863
+ await delay2(STATUS_POLL_INTERVAL_MS, signal);
13114
13864
  if (turn.done || signal.aborted) return;
13115
13865
  const busy = await this.sessionBusy();
13116
13866
  if (busy === true) turn.live = true;
@@ -13157,6 +13907,7 @@ ${this.stderrTail.trim()}` : ""}`);
13157
13907
  */
13158
13908
  async interrupt(timeoutMs) {
13159
13909
  if (!this.process || !this.sessionId || this.exited) return false;
13910
+ if (this.v2) return this.v2.interrupt(timeoutMs);
13160
13911
  const turn = this.taskTurn;
13161
13912
  this.interruptRequested = true;
13162
13913
  const posted = await this.json("POST", `/session/${this.sessionId}/abort`).then(() => true, () => false);
@@ -13177,13 +13928,23 @@ ${this.stderrTail.trim()}` : ""}`);
13177
13928
  void this.processEnded.then(() => listener(this.exitCode));
13178
13929
  }
13179
13930
  answerPermission(id, decision) {
13931
+ if (this.v2) return this.process ? this.v2.answerPermission(id, decision) : false;
13180
13932
  const sessionId = this.openPermissions.get(id);
13181
13933
  if (sessionId === void 0 || !this.process) return false;
13182
13934
  this.openPermissions.delete(id);
13183
- void this.replyPermission(id, sessionId, permissionReply(decision)).catch(() => {
13935
+ void this.replyPermission(id, sessionId, permissionReply2(decision)).catch(() => {
13184
13936
  });
13185
13937
  return true;
13186
13938
  }
13939
+ /**
13940
+ * OpenCode 2.x replaced the 1.x HTTP surface rather than extending it, and
13941
+ * only 2.x answers `/api/info` with its version. A 1.x server — or one that
13942
+ * answers with anything else — keeps the 1.x protocol this class speaks.
13943
+ */
13944
+ async speaksV2() {
13945
+ const info = await this.json("GET", "/api/info").catch(() => null);
13946
+ return typeof info === "object" && info !== null && typeof info.version === "string";
13947
+ }
13187
13948
  role() {
13188
13949
  return this.task ? "task" : "planner";
13189
13950
  }
@@ -13234,7 +13995,7 @@ ${this.stderrTail.trim()}` : ""}`);
13234
13995
  if (pending?.info?.time?.completed || pending?.info?.error) return pending;
13235
13996
  if (pending) onActivity?.();
13236
13997
  if (Date.now() >= deadline) return null;
13237
- await delay(RECOVERY_POLL_INTERVAL_MS, signal);
13998
+ await delay2(RECOVERY_POLL_INTERVAL_MS, signal);
13238
13999
  }
13239
14000
  }
13240
14001
  newestAssistantId(messages) {
@@ -13447,7 +14208,7 @@ ${this.stderrTail.trim()}` : ""}`);
13447
14208
  ...ask.always?.length ? { suggestions: ask.always } : {},
13448
14209
  ...ask.tool?.callID ? { toolUseId: ask.tool.callID } : {}
13449
14210
  };
13450
- if (this.task?.flags.modeSettings.approvals === AUTO_APPROVALS) {
14211
+ if (this.task?.flags.modeSettings.approvals === AUTO_APPROVALS2) {
13451
14212
  onEvent({ ...request, decided: { decision: "allow" } });
13452
14213
  void this.replyPermission(id, sessionId, { reply: "once" }).catch(() => {
13453
14214
  });
@@ -13481,7 +14242,7 @@ ${this.stderrTail.trim()}` : ""}`);
13481
14242
  });
13482
14243
  const live = this.streamEvents(streamAbort.signal, (frame) => this.onFrame(frame, turn, onEvent), connected, onActivity);
13483
14244
  await Promise.race([streamReady, new Promise((r) => {
13484
- const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS);
14245
+ const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS2);
13485
14246
  t.unref?.();
13486
14247
  })]);
13487
14248
  return async () => {
@@ -13670,6 +14431,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
13670
14431
  observers = [];
13671
14432
  tddEnabled;
13672
14433
  readRunnerTransport;
14434
+ previousAttemptFromLog;
13673
14435
  /**
13674
14436
  * The setting as the latest run copied it when it opened. Every spawn of
13675
14437
  * that run uses this, so flipping the setting mid-run changes the next run
@@ -13689,6 +14451,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
13689
14451
  this.workspaceEnv = deps.workspaceEnv;
13690
14452
  this.tddEnabled = deps.tddEnabled;
13691
14453
  this.readRunnerTransport = deps.runnerTransport;
14454
+ this.previousAttemptFromLog = deps.previousAttemptFromLog;
13692
14455
  this.store.onMutate = () => this.emit("onTaskChanged");
13693
14456
  this.verifier.onVerdict((taskId, verdict) => this.onVerdict(taskId, verdict));
13694
14457
  this.verifier.onCheckpoint((taskId, summary) => {
@@ -13744,7 +14507,8 @@ var TaskOrchestrator = class _TaskOrchestrator {
13744
14507
  workspaceRoot,
13745
14508
  workspaceEnv: options.workspaceEnv ?? ((cwd) => resolveWorkspaceEnv(cwd)),
13746
14509
  tddEnabled: options.tddEnabled ?? (() => false),
13747
- runnerTransport: options.runnerTransport ?? (() => "terminal")
14510
+ runnerTransport: options.runnerTransport ?? (() => "terminal"),
14511
+ previousAttemptFromLog: options.previousAttemptFromLog ?? (() => null)
13748
14512
  });
13749
14513
  return orchestrator;
13750
14514
  }
@@ -13752,6 +14516,18 @@ var TaskOrchestrator = class _TaskOrchestrator {
13752
14516
  getIdleSince(taskId) {
13753
14517
  return this.verifier.getIdleSince(taskId);
13754
14518
  }
14519
+ /**
14520
+ * What an ops retry is told about the attempt before. The saved log is read
14521
+ * whether or not the session was reloaded, so both behave alike; the terminal
14522
+ * buffer is the fallback for a runner that has no log. A reload clears the
14523
+ * attempt count along with the buffer, so a retry made since is what says
14524
+ * there was an attempt to report on.
14525
+ */
14526
+ opsPreviousAttempt(taskId) {
14527
+ const saved = this.previousAttemptFromLog(taskId) ?? this.output.liveTail(taskId, { maxLines: OPS_RETRY_TAIL_LINES })?.text;
14528
+ if (saved !== void 0) return saved;
14529
+ return (this.retryCounts.get(taskId) ?? 0) > 0 ? "Its output is not available: this session was reloaded since it ran." : void 0;
14530
+ }
13755
14531
  /** Recent clean output of a task's latest attempt, running or ended; null if it never ran. */
13756
14532
  getLiveOutput(taskId, opts) {
13757
14533
  return this.output.liveTail(taskId, opts);
@@ -14609,7 +15385,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
14609
15385
  tddEnabled: !attempt.repair && this.tddEnabled(),
14610
15386
  // An ops task's effects outlive a failed attempt and are never rolled
14611
15387
  // back, so the next one is told what the last one did (ADR-0020).
14612
- previousAttempt: attempt.ops ? this.output.liveTail(task.id, { maxLines: OPS_RETRY_TAIL_LINES })?.text : void 0
15388
+ previousAttempt: attempt.ops ? this.opsPreviousAttempt(task.id) : void 0
14613
15389
  });
14614
15390
  this.lingering.close(task.id);
14615
15391
  const env = await this.envForTask(cwd);
@@ -18228,6 +19004,13 @@ var PlanEditError = class extends Error {
18228
19004
  this.name = "PlanEditError";
18229
19005
  }
18230
19006
  };
19007
+ var OPS_RETRY_DIGEST_CALLS = 20;
19008
+ function lastAttemptDigest(location, taskId) {
19009
+ const attempt = listTaskLogAttempts(location, taskId).pop();
19010
+ if (attempt === void 0) return null;
19011
+ const events = readTaskLog(location, taskId, attempt);
19012
+ return events.length > 0 ? digestTaskLog(events, OPS_RETRY_DIGEST_CALLS) : null;
19013
+ }
18231
19014
  function sessionRuntimeSettings(settings) {
18232
19015
  return { ...plannerRuntimeToggles(settings), modelAllowlist: settings.modelAllowlist, runnerTransport: settings.runnerTransport };
18233
19016
  }
@@ -18312,7 +19095,8 @@ function createSession(deps) {
18312
19095
  registry: deps.registry,
18313
19096
  workspaceRoot: deps.workspaceRoot,
18314
19097
  tddEnabled: () => deps.settings().tddEnabled,
18315
- runnerTransport: () => deps.settings().runnerTransport ?? "terminal"
19098
+ runnerTransport: () => deps.settings().runnerTransport ?? "terminal",
19099
+ previousAttemptFromLog: (taskId) => lastAttemptDigest(session.taskLogLocation, taskId)
18316
19100
  });
18317
19101
  const usage = new PlannerUsageLedger();
18318
19102
  const events = new SessionEventRelay({
@@ -19435,13 +20219,33 @@ function buildRunnerInvocation(opts) {
19435
20219
  mode: opts.mode ?? "build",
19436
20220
  headless: opts.headless,
19437
20221
  interactive: opts.interactive,
19438
- cwd: opts.cwd
20222
+ cwd: opts.cwd,
20223
+ runnerVersion: opts.runnerVersion
19439
20224
  });
19440
20225
  }
19441
20226
 
19442
20227
  // src/services/HeadlessRunner.ts
19443
20228
  import { spawn as nodeSpawn2, execSync } from "child_process";
19444
20229
 
20230
+ // src/services/runnerVersion.ts
20231
+ import { execFile as execFile4 } from "child_process";
20232
+ var VERSION_TIMEOUT_MS = 5e3;
20233
+ var cache = /* @__PURE__ */ new Map();
20234
+ var probeRunnerVersion = (command, PATH) => {
20235
+ const key = `${command}\0${PATH}`;
20236
+ let pending = cache.get(key);
20237
+ if (!pending) {
20238
+ pending = new Promise((resolve7) => {
20239
+ execFile4(command, ["--version"], { env: { ...process.env, PATH }, timeout: VERSION_TIMEOUT_MS }, (err, stdout) => {
20240
+ const line = String(stdout ?? "").trim();
20241
+ resolve7(err || !line ? void 0 : line);
20242
+ });
20243
+ });
20244
+ cache.set(key, pending);
20245
+ }
20246
+ return pending;
20247
+ };
20248
+
19445
20249
  // src/services/AbstractRunner.ts
19446
20250
  import { EventEmitter } from "events";
19447
20251
  var AbstractTerminalSession = class {
@@ -19562,6 +20366,10 @@ Process error: ${err.message}
19562
20366
  this.process.stdin.write(text);
19563
20367
  }
19564
20368
  }
20369
+ /** Closes the runner's stdin: what it has read so far is all it will get. */
20370
+ endInput() {
20371
+ this.process?.stdin?.end();
20372
+ }
19565
20373
  /** PTY resize requests from a surface that owns the terminal rendering the wrapper. */
19566
20374
  writeControl(text) {
19567
20375
  if (this.controlStream && !this.controlStream.destroyed) {
@@ -19574,6 +20382,7 @@ var HeadlessRunner = class extends AbstractRunner {
19574
20382
  hasScriptCmd;
19575
20383
  resolvePath;
19576
20384
  launchDeps;
20385
+ runnerVersion;
19577
20386
  spawnCount = 0;
19578
20387
  /**
19579
20388
  * Session shape: a piped subprocess is not a terminal, so runners get their
@@ -19589,6 +20398,7 @@ var HeadlessRunner = class extends AbstractRunner {
19589
20398
  this.hasScriptCmd = deps.hasScriptCmd ?? defaultHasScriptCmd;
19590
20399
  this.resolvePath = deps.resolvePath ?? augmentedPath;
19591
20400
  this.launchDeps = { resolvePath: this.resolvePath, ...deps.launchDeps };
20401
+ this.runnerVersion = deps.runnerVersion ?? probeRunnerVersion;
19592
20402
  }
19593
20403
  /**
19594
20404
  * Unique per spawn, not per task: a retry reuses its task id and ids often
@@ -19606,6 +20416,9 @@ var HeadlessRunner = class extends AbstractRunner {
19606
20416
  /** Everything up to, but not including, spawning — so a surface that owns its own child reaches the same decisions. */
19607
20417
  async prepareLaunch(opts, ptyOptions) {
19608
20418
  const interactive = this.defaultInteractive;
20419
+ const resolvedPath = await this.resolvePath();
20420
+ const manifest = opts.registry?.get(opts.runner)?.manifest;
20421
+ const runnerVersion = manifest?.runner.versioned?.length ? await this.runnerVersion(manifest.runner.command, resolvedPath) : void 0;
19609
20422
  const invocation = buildRunnerInvocation({
19610
20423
  runner: opts.runner,
19611
20424
  prompt: opts.prompt,
@@ -19616,18 +20429,18 @@ var HeadlessRunner = class extends AbstractRunner {
19616
20429
  headless: opts.headless ?? true,
19617
20430
  interactive,
19618
20431
  cwd: opts.cwd,
20432
+ runnerVersion,
19619
20433
  registry: opts.registry
19620
20434
  });
19621
- const manifest = opts.registry?.get(opts.runner)?.manifest;
19622
20435
  const pty = (manifest?.runner.requiresTty === true || interactive) && this.hasScriptCmd();
19623
- const resolvedPath = await this.resolvePath();
19624
- const { command, args } = pty ? wrapWithPty(invocation.command, invocation.args, ptyOptions) : { command: invocation.command, args: invocation.args };
20436
+ const { command, args } = pty ? wrapWithPty(invocation.command, invocation.args, { platform: this.launchDeps.platform, ...ptyOptions }) : { command: invocation.command, args: invocation.args };
19625
20437
  return {
19626
20438
  launch: await planDirectLaunch(command, args, this.launchDeps),
19627
20439
  resolvedPath,
19628
20440
  env: { ...opts.env, ...invocation.env },
19629
20441
  pty,
19630
- submitPromptKey: invocation.submitPromptKey
20442
+ submitPromptKey: invocation.submitPromptKey,
20443
+ closeStdin: !pty && !interactive && invocation.promptInArgs
19631
20444
  };
19632
20445
  }
19633
20446
  async spawn(opts) {
@@ -19638,13 +20451,14 @@ var HeadlessRunner = class extends AbstractRunner {
19638
20451
  console.error(`[headless] Starting ${opts.runner} [${modeStr}] (${opts.modelId || "default"}) for task ${opts.taskId.slice(0, 8)}${prepared.pty ? " (PTY)" : ""}`);
19639
20452
  session.start(prepared.launch, opts.cwd, prepared.resolvedPath, prepared.env);
19640
20453
  if (prepared.submitPromptKey) session.write("\r");
20454
+ if (prepared.closeStdin) session.endInput();
19641
20455
  this.registerSession(id, session);
19642
20456
  return session;
19643
20457
  }
19644
20458
  };
19645
20459
 
19646
20460
  // src/services/TmuxRunner.ts
19647
- import { execFile as execFile4 } from "child_process";
20461
+ import { execFile as execFile5 } from "child_process";
19648
20462
  import { promisify as promisify5 } from "util";
19649
20463
  import { tmpdir } from "os";
19650
20464
  import { join as join19 } from "path";
@@ -19701,7 +20515,7 @@ function clipboardCopyCommand(hasBin = defaultHasBin, platform = process.platfor
19701
20515
  var EXIT_RE = /<<<ORDEWELL_TMUX_EXIT:(\d+)>>>/;
19702
20516
  var LIVENESS_INTERVAL_MS = 5e3;
19703
20517
  var LIVENESS_MISSES = 2;
19704
- var execFileAsync2 = promisify5(execFile4);
20518
+ var execFileAsync2 = promisify5(execFile5);
19705
20519
  var defaultExecFile3 = async (command, args) => {
19706
20520
  const { stdout, stderr } = await execFileAsync2(command, args);
19707
20521
  return { stdout: String(stdout), stderr: String(stderr) };
@@ -19851,6 +20665,7 @@ var TmuxRunner = class extends AbstractRunner {
19851
20665
  pollIntervalMs;
19852
20666
  logDir;
19853
20667
  clipboardCommand;
20668
+ runnerVersion;
19854
20669
  /** Retries get a freshly named window so a failed attempt's output stays inspectable. */
19855
20670
  attempts = /* @__PURE__ */ new Map();
19856
20671
  ready = null;
@@ -19863,6 +20678,7 @@ var TmuxRunner = class extends AbstractRunner {
19863
20678
  this.pollIntervalMs = deps.pollIntervalMs ?? 500;
19864
20679
  this.logDir = deps.logDir ?? tmpdir();
19865
20680
  this.clipboardCommand = deps.clipboardCommand ?? (() => clipboardCopyCommand());
20681
+ this.runnerVersion = deps.runnerVersion ?? probeRunnerVersion;
19866
20682
  }
19867
20683
  /**
19868
20684
  * Reaps a session orphaned by a crashed prior daemon on the same port, then
@@ -19946,6 +20762,8 @@ var TmuxRunner = class extends AbstractRunner {
19946
20762
  }
19947
20763
  }
19948
20764
  async spawn(opts) {
20765
+ const manifest = opts.registry?.get(opts.runner)?.manifest;
20766
+ const runnerVersion = manifest?.runner.versioned?.length ? await this.runnerVersion(manifest.runner.command, await this.resolvePath()) : void 0;
19949
20767
  const invocation = buildRunnerInvocation({
19950
20768
  runner: opts.runner,
19951
20769
  prompt: opts.prompt,
@@ -19961,6 +20779,7 @@ var TmuxRunner = class extends AbstractRunner {
19961
20779
  headless: opts.headless ?? true,
19962
20780
  interactive: true,
19963
20781
  cwd: opts.cwd,
20782
+ runnerVersion,
19964
20783
  registry: opts.registry
19965
20784
  });
19966
20785
  await this.ensureSession();
@@ -20877,6 +21696,37 @@ var OPENCODE_MANIFEST = {
20877
21696
  env: {
20878
21697
  OPENCODE_CONFIG_CONTENT: "{{if interactiveVariant}}{{opencodeVariantConfig}}{{/if}}"
20879
21698
  },
21699
+ // OpenCode 2.x changed both shapes. Its TUI takes no `--model`/`--agent`,
21700
+ // so they ride on OPENCODE_CONFIG_CONTENT instead, and its `run` has no
21701
+ // `--variant` — the variant is a `#suffix` on the model id. Both run
21702
+ // `--standalone`: without it the TUI and `run` attach to the user's
21703
+ // background service, which neither reads this process's config nor
21704
+ // starts in the task's directory and environment.
21705
+ versioned: [{
21706
+ minMajor: 2,
21707
+ argsTemplate: [
21708
+ "{{if headlessSession}}",
21709
+ "run",
21710
+ "--agent",
21711
+ "{{mode}}",
21712
+ "{{/if}}",
21713
+ "{{if headlessModel}}",
21714
+ "--model",
21715
+ "{{modelWithVariant}}",
21716
+ "{{/if}}",
21717
+ "--standalone",
21718
+ "{{if buildMode}}",
21719
+ "--auto",
21720
+ "{{/if}}",
21721
+ "{{if interactive}}",
21722
+ "--prompt",
21723
+ "{{/if}}",
21724
+ "{{prompt}}"
21725
+ ],
21726
+ env: {
21727
+ OPENCODE_CONFIG_CONTENT: "{{if interactive}}{{opencodeSessionConfig}}{{/if}}"
21728
+ }
21729
+ }],
20880
21730
  promptInArgs: true,
20881
21731
  requiresTty: true,
20882
21732
  // `--prompt` only pre-fills the TUI's composer; it does not submit it.
@@ -21475,6 +22325,7 @@ export {
21475
22325
  FsPluginStore,
21476
22326
  GIT_READONLY_SUBCOMMANDS,
21477
22327
  GREP_DEFAULT_HEAD_LIMIT,
22328
+ GREP_PCRE_PROBE_ARGS,
21478
22329
  GeminiService,
21479
22330
  HeadlessRunner,
21480
22331
  HeadlessSession,
@@ -21614,6 +22465,7 @@ export {
21614
22465
  dependencyCandidates,
21615
22466
  dependentsOf,
21616
22467
  describeMergeResult,
22468
+ digestTaskLog,
21617
22469
  discoverGeminiModels,
21618
22470
  drainNext,
21619
22471
  effectiveAllowlist,
@@ -21646,6 +22498,7 @@ export {
21646
22498
  getStateDir,
21647
22499
  globalDataDir,
21648
22500
  grantScopeFor,
22501
+ grepRegexFromProbe,
21649
22502
  hasHiddenDetail,
21650
22503
  hasTmux,
21651
22504
  holdPrompt,