@ordewell/core 0.6.1 → 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/index.js CHANGED
@@ -67,6 +67,7 @@ __export(index_exports, {
67
67
  FsPluginStore: () => FsPluginStore,
68
68
  GIT_READONLY_SUBCOMMANDS: () => GIT_READONLY_SUBCOMMANDS,
69
69
  GREP_DEFAULT_HEAD_LIMIT: () => GREP_DEFAULT_HEAD_LIMIT,
70
+ GREP_PCRE_PROBE_ARGS: () => GREP_PCRE_PROBE_ARGS,
70
71
  GeminiService: () => GeminiService,
71
72
  HeadlessRunner: () => HeadlessRunner,
72
73
  HeadlessSession: () => HeadlessSession,
@@ -206,6 +207,7 @@ __export(index_exports, {
206
207
  dependencyCandidates: () => dependencyCandidates,
207
208
  dependentsOf: () => dependentsOf,
208
209
  describeMergeResult: () => describeMergeResult,
210
+ digestTaskLog: () => digestTaskLog,
209
211
  discoverGeminiModels: () => discoverGeminiModels,
210
212
  drainNext: () => drainNext,
211
213
  effectiveAllowlist: () => effectiveAllowlist,
@@ -238,6 +240,7 @@ __export(index_exports, {
238
240
  getStateDir: () => getStateDir,
239
241
  globalDataDir: () => globalDataDir,
240
242
  grantScopeFor: () => grantScopeFor,
243
+ grepRegexFromProbe: () => grepRegexFromProbe,
241
244
  hasHiddenDetail: () => hasHiddenDetail,
242
245
  hasTmux: () => hasTmux,
243
246
  holdPrompt: () => holdPrompt,
@@ -766,6 +769,8 @@ function plannerContextFill(usage) {
766
769
  var HEAD_LINES = 60;
767
770
  var TAIL_LINES = 40;
768
771
  var MAX_LINE_CHARS = 2e3;
772
+ var DIGEST_ARGS_CHARS = 120;
773
+ var DIGEST_MESSAGE_CHARS = 1e3;
769
774
  function capLine(line) {
770
775
  return line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}\u2026` : line;
771
776
  }
@@ -848,6 +853,33 @@ function coalesceTaskLog(events) {
848
853
  }
849
854
  return out;
850
855
  }
856
+ function digestTaskLog(events, maxCalls) {
857
+ const outcomes = /* @__PURE__ */ new Map();
858
+ let lastText;
859
+ let lastError;
860
+ for (const event of events) {
861
+ if (event.type === "tool_result") outcomes.set(event.id, event.success);
862
+ else if (event.type === "text") lastText = event.text;
863
+ else if (event.type === "error") lastError = event.message;
864
+ }
865
+ const lines = [];
866
+ for (const event of events) {
867
+ if (event.type !== "tool_call") continue;
868
+ const outcome = outcomes.get(event.id);
869
+ const args = event.args.length > DIGEST_ARGS_CHARS ? `${event.args.slice(0, DIGEST_ARGS_CHARS)}\u2026` : event.args;
870
+ lines.push(`- ${event.name} ${args} \u2192 ${outcome === void 0 ? "no result" : outcome ? "ok" : "failed"}`);
871
+ }
872
+ const omitted = lines.length - maxCalls;
873
+ const out = omitted > 0 ? [`\u2026 ${omitted} earlier call${omitted === 1 ? "" : "s"} omitted \u2026`, ...lines.slice(omitted)] : lines;
874
+ const gap = () => out.length > 0 ? [""] : [];
875
+ const said2 = lastText?.trim();
876
+ if (said2) {
877
+ const end = said2.length > DIGEST_MESSAGE_CHARS ? `\u2026${said2.slice(-DIGEST_MESSAGE_CHARS)}` : said2;
878
+ out.push(...gap(), "Its last message:", ...end.split("\n").map((l) => ` ${l}`));
879
+ }
880
+ if (lastError) out.push(...gap(), `It stopped with an error: ${lastError}`);
881
+ return out.join("\n");
882
+ }
851
883
 
852
884
  // src/interfaces/IFileSystem.ts
853
885
  var GREP_DEFAULT_HEAD_LIMIT = 100;
@@ -3085,6 +3117,8 @@ function lex(command, nested, dialect) {
3085
3117
  let unsafeRedirect;
3086
3118
  let processSubstitution = false;
3087
3119
  let unbalanced = false;
3120
+ let grouped = false;
3121
+ let joinedBy;
3088
3122
  let tokens = [];
3089
3123
  let expandable = false;
3090
3124
  let current = "";
@@ -3114,13 +3148,14 @@ function lex(command, nested, dialect) {
3114
3148
  current = "";
3115
3149
  started = false;
3116
3150
  };
3117
- const endSegment = (nextPiped) => {
3151
+ const endSegment = (nextPiped, next = "other") => {
3118
3152
  endToken();
3119
- if (tokens.length > 0) segments.push({ ...toSegment(tokens, piped, dialect), expandable, inputs });
3153
+ if (tokens.length > 0) segments.push({ ...toSegment(tokens, piped, dialect), expandable, inputs, joinedBy });
3120
3154
  tokens = [];
3121
3155
  inputs = [];
3122
3156
  expandable = false;
3123
3157
  piped = nextPiped;
3158
+ joinedBy = next;
3124
3159
  };
3125
3160
  let i = 0;
3126
3161
  while (i < command.length) {
@@ -3242,17 +3277,18 @@ function lex(command, nested, dialect) {
3242
3277
  }
3243
3278
  if (c === "(" || c === ")") {
3244
3279
  endToken();
3280
+ grouped = true;
3245
3281
  i++;
3246
3282
  continue;
3247
3283
  }
3248
3284
  if (c === "|") {
3249
3285
  const double = command[i + 1] === "|";
3250
- endSegment(!double);
3286
+ endSegment(!double, double ? "other" : "pipe");
3251
3287
  i += double || command[i + 1] === "&" ? 2 : 1;
3252
3288
  continue;
3253
3289
  }
3254
3290
  if (c === "&" || c === ";" || c === "\n") {
3255
- endSegment(false);
3291
+ endSegment(false, c === "&" && command[i + 1] === "&" ? "and" : "other");
3256
3292
  i += command[i + 1] === c ? 2 : 1;
3257
3293
  continue;
3258
3294
  }
@@ -3270,7 +3306,7 @@ function lex(command, nested, dialect) {
3270
3306
  }
3271
3307
  if (quote !== "") unbalanced = true;
3272
3308
  endSegment(false);
3273
- return { segments, unsafeRedirect, processSubstitution, unbalanced };
3309
+ return { segments, unsafeRedirect, processSubstitution, unbalanced, grouped };
3274
3310
  }
3275
3311
  function binaryName(token, dialect) {
3276
3312
  const base = token.split(/[/\\]/).pop() ?? "";
@@ -3410,10 +3446,11 @@ function unwrap(seg, dialect) {
3410
3446
  }
3411
3447
  return { seg: { ...current, assignments }, wrappers, runner };
3412
3448
  }
3413
- function lexAll(command, dialect) {
3449
+ function lexAll(command, dialect, cdpathSet = false) {
3414
3450
  const queue = [];
3415
3451
  const top = lex(command, queue, dialect);
3416
3452
  const all = { ...top, segments: top.segments.filter((s) => s.binary) };
3453
+ if (!top.grouped && queue.length === 0 && !cdpathSet) followCd(all.segments);
3417
3454
  for (let depth = 0; depth < 32 && queue.length > 0; depth++) {
3418
3455
  const inner = lex(queue.shift(), queue, dialect);
3419
3456
  all.segments.push(...inner.segments.filter((s) => s.binary));
@@ -3423,15 +3460,32 @@ function lexAll(command, dialect) {
3423
3460
  }
3424
3461
  return all;
3425
3462
  }
3463
+ function literalCdTarget(seg) {
3464
+ if (seg.binary !== "cd" || seg.assignments.length > 0 || seg.expandable || seg.inputs.length > 0) return void 0;
3465
+ if (seg.args.length !== 1) return void 0;
3466
+ const [target] = seg.args;
3467
+ return target === "" || target.startsWith("-") || target.startsWith("~") || /[*?[]/.test(target) ? void 0 : target;
3468
+ }
3469
+ function followCd(segments) {
3470
+ if (segments.some((s) => s.joinedBy === "other")) return;
3471
+ let chain = [];
3472
+ segments.forEach((seg, i) => {
3473
+ seg.cwd = chain;
3474
+ const inPipeline = seg.joinedBy === "pipe" || segments[i + 1]?.joinedBy === "pipe";
3475
+ const target = inPipeline ? void 0 : literalCdTarget(seg);
3476
+ if (target !== void 0) chain = [...chain, target];
3477
+ });
3478
+ }
3426
3479
  function looksLikePath(arg) {
3427
3480
  if (arg.startsWith("--") && arg.includes("=")) return looksLikePath(arg.slice(arg.indexOf("=") + 1));
3428
3481
  return arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("../") || arg === ".." || arg.startsWith("./") || /^[A-Za-z]:/.test(arg) || arg.startsWith("\\") || arg.startsWith("..\\") || arg.startsWith(".\\") || /(^|[\\/])\.\.([\\/]|$)/.test(arg);
3429
3482
  }
3430
- function pathLikeArgs(command, opts = {}) {
3483
+ function pathRefs(command, opts = {}) {
3431
3484
  const dialect = dialectFor(opts.dialect);
3432
- return lexAll(command, dialect).segments.flatMap((seg) => {
3485
+ return lexAll(command, dialect, opts.cdpathSet).segments.flatMap((seg) => {
3433
3486
  const patterns = patternArgs(seg);
3434
- return [...seg.args.filter((_, i) => !patterns.has(i)), ...seg.inputs].flatMap(pathIn);
3487
+ const cwd = seg.cwd ?? [];
3488
+ return [...seg.args.filter((_, i) => !patterns.has(i)), ...seg.inputs].flatMap(pathIn).map((path27) => ({ path: path27, cwd }));
3435
3489
  });
3436
3490
  }
3437
3491
  function pathIn(a) {
@@ -3595,7 +3649,8 @@ function scopeFor(seg) {
3595
3649
  }
3596
3650
  return [seg.binary, ...lead].join(" ");
3597
3651
  }
3598
- function isAuto(seg) {
3652
+ function isAuto(seg, opts) {
3653
+ if (seg.binary === "cd") return !opts.cdpathSet && literalCdTarget(seg) !== void 0;
3599
3654
  if (!AUTO_COMMANDS.includes(seg.binary)) return false;
3600
3655
  if (seg.expandable) return false;
3601
3656
  if (seg.binary !== "git") return true;
@@ -3690,7 +3745,7 @@ function classifyCommand(command, opts = {}) {
3690
3745
  const trimmed = command.trim();
3691
3746
  if (!trimmed) return { tier: "refuse", scope: "", reason: "Empty command." };
3692
3747
  const dialect = dialectFor(opts.dialect);
3693
- const { segments, unsafeRedirect, processSubstitution, unbalanced } = lexAll(trimmed, dialect);
3748
+ const { segments, unsafeRedirect, processSubstitution, unbalanced } = lexAll(trimmed, dialect, opts.cdpathSet);
3694
3749
  if (unbalanced) {
3695
3750
  return {
3696
3751
  tier: "refuse",
@@ -3727,7 +3782,7 @@ function classifyCommand(command, opts = {}) {
3727
3782
  };
3728
3783
  }
3729
3784
  }
3730
- const nonAuto = unwrapped.filter((u) => u.runner !== void 0 || !isAuto(u.seg));
3785
+ const nonAuto = unwrapped.filter((u) => u.runner !== void 0 || !isAuto(u.seg, opts));
3731
3786
  if (nonAuto.length === 0) return { tier: "auto", scope: "" };
3732
3787
  const scope = [...new Set(nonAuto.map((u) => u.runner ? `${u.runner} ${scopeFor(u.seg)}` : scopeFor(u.seg)))].sort().join(" + ");
3733
3788
  return { tier: "ask", scope };
@@ -3807,11 +3862,15 @@ function expandHome(target) {
3807
3862
  if (target === "~" || target.startsWith("~/")) return path2.join(home, target.slice(1));
3808
3863
  return path2.join(path2.dirname(home), target.slice(1));
3809
3864
  }
3810
- function resolveWithin(root, target) {
3811
- const abs = path2.resolve(root, expandHome(target));
3865
+ function resolveWithin(root, target, from = root) {
3866
+ const abs = path2.resolve(from, expandHome(target));
3812
3867
  const rel = path2.relative(root, abs);
3813
3868
  return { abs, inside: rel === "" || !rel.startsWith("..") && !path2.isAbsolute(rel) };
3814
3869
  }
3870
+ function resolveRef(root, ref) {
3871
+ const from = ref.cwd.reduce((dir, target) => resolveWithin(root, target, dir).abs, root);
3872
+ return resolveWithin(root, ref.path, from);
3873
+ }
3815
3874
  function grantScopeFor(abs, kind) {
3816
3875
  if (kind === "directory") return path2.join(abs, "*");
3817
3876
  const parent = path2.dirname(abs);
@@ -4017,14 +4076,17 @@ ${refs.output.trim()}`);
4017
4076
  outsidePaths(command) {
4018
4077
  const root = this.getWorkspaceRoot();
4019
4078
  const found = /* @__PURE__ */ new Map();
4020
- for (const p of pathLikeArgs(command, { dialect: this.researchShell.dialect })) {
4021
- const { abs, inside } = resolveWithin(root, p);
4079
+ for (const ref of pathRefs(command, this.policyOptions())) {
4080
+ const { abs, inside } = resolveRef(root, ref);
4022
4081
  if (inside || isInertDevice(abs)) continue;
4023
4082
  const scope = grantScopeFor(abs, "file");
4024
4083
  if (!found.has(scope)) found.set(scope, abs);
4025
4084
  }
4026
4085
  return [...found].map(([scope, abs]) => ({ abs, scope }));
4027
4086
  }
4087
+ policyOptions() {
4088
+ return { dialect: this.researchShell.dialect, cdpathSet: !!process.env.CDPATH };
4089
+ }
4028
4090
  /**
4029
4091
  * Three tiers (see `commandPolicy.ts`): read-only inspection runs silently,
4030
4092
  * anything else asks once and is remembered, and writes are refused outright
@@ -4036,7 +4098,7 @@ ${refs.output.trim()}`);
4036
4098
  * directory the next command names.
4037
4099
  */
4038
4100
  async bash(command, signal) {
4039
- const { tier, scope, reason } = classifyCommand(command, { dialect: this.researchShell.dialect });
4101
+ const { tier, scope, reason } = classifyCommand(command, this.policyOptions());
4040
4102
  if (tier === "refuse") {
4041
4103
  return { success: false, output: `Command refused: ${reason}`, truncated: false };
4042
4104
  }
@@ -5212,10 +5274,78 @@ function buildGrepArgs(pattern, opts, root) {
5212
5274
  function buildGlobArgs(pattern, root) {
5213
5275
  return ["--files", ...SORT_BY_RECENCY, "--glob", pattern, ...exclusionArgs(), "--", root];
5214
5276
  }
5215
- function buildFallbackGrepArgs(pattern, opts, root) {
5277
+ var GREP_PCRE_PROBE_ARGS = ["-P", "-e", "x", "/dev/null"];
5278
+ function grepRegexFromProbe(exitCode) {
5279
+ return exitCode === 0 || exitCode === 1 ? "pcre" : "ere";
5280
+ }
5281
+ function toPosixClasses(pattern) {
5282
+ const CLASS_ESCAPES = { s: "[:space:]", w: "[:alnum:]_", d: "[:digit:]", t: " " };
5283
+ let out = "";
5284
+ let i = 0;
5285
+ while (i < pattern.length) {
5286
+ const c = pattern[i];
5287
+ if (c === "\\") {
5288
+ out += pattern.slice(i, i + 2);
5289
+ i += 2;
5290
+ continue;
5291
+ }
5292
+ if (c !== "[") {
5293
+ out += c;
5294
+ i++;
5295
+ continue;
5296
+ }
5297
+ const start = i;
5298
+ i++;
5299
+ let negate = false;
5300
+ if (pattern[i] === "^") {
5301
+ negate = true;
5302
+ i++;
5303
+ }
5304
+ let body = "";
5305
+ let closeBracket = false;
5306
+ let openBracket = false;
5307
+ let dash = false;
5308
+ let closed = false;
5309
+ while (i < pattern.length) {
5310
+ const ch = pattern[i];
5311
+ if (ch === "]") {
5312
+ closed = true;
5313
+ i++;
5314
+ break;
5315
+ }
5316
+ if (ch === "[" && pattern[i + 1] === ":") {
5317
+ const end = pattern.indexOf(":]", i + 2);
5318
+ if (end !== -1) {
5319
+ body += pattern.slice(i, end + 2);
5320
+ i = end + 2;
5321
+ continue;
5322
+ }
5323
+ }
5324
+ if (ch === "\\" && i + 1 < pattern.length) {
5325
+ const e = pattern[i + 1];
5326
+ i += 2;
5327
+ if (e in CLASS_ESCAPES) body += CLASS_ESCAPES[e];
5328
+ else if (e === "]") closeBracket = true;
5329
+ else if (e === "[") openBracket = true;
5330
+ else if (e === "-") dash = true;
5331
+ else body += e;
5332
+ continue;
5333
+ }
5334
+ body += ch;
5335
+ i++;
5336
+ }
5337
+ if (!closed) {
5338
+ out += pattern.slice(start);
5339
+ break;
5340
+ }
5341
+ out += `[${negate ? "^" : ""}${closeBracket ? "]" : ""}${body}${openBracket ? "[" : ""}${dash ? "-" : ""}]`;
5342
+ }
5343
+ return out;
5344
+ }
5345
+ function buildFallbackGrepArgs(pattern, opts, root, regex = "pcre") {
5216
5346
  const args = ["-r", "-n"];
5217
5347
  if (opts.literal) args.push("-F");
5218
- else args.push("-P");
5348
+ else args.push(regex === "pcre" ? "-P" : "-E");
5219
5349
  if (opts.caseInsensitive) args.push("-i");
5220
5350
  if (opts.outputMode === "files") args.push("-l");
5221
5351
  if (opts.outputMode === "count") args.push("-c");
@@ -5225,7 +5355,7 @@ function buildFallbackGrepArgs(pattern, opts, root) {
5225
5355
  }
5226
5356
  if (opts.include) args.push(`--include=${opts.include}`);
5227
5357
  for (const dir of SEARCH_EXCLUSIONS) args.push(`--exclude-dir=${dir}`);
5228
- args.push("-e", pattern, root);
5358
+ args.push("-e", !opts.literal && regex === "ere" ? toPosixClasses(pattern) : pattern, root);
5229
5359
  return args;
5230
5360
  }
5231
5361
  function globSegmentToRegex(segment) {
@@ -9264,7 +9394,7 @@ function parseTaskOpsObject(json, text2) {
9264
9394
  }
9265
9395
  return ops;
9266
9396
  }
9267
- function resolveRef(ref, originalScope, liveScope, handleOwner, handleId, opIndex) {
9397
+ function resolveRef2(ref, originalScope, liveScope, handleOwner, handleId, opIndex) {
9268
9398
  if (typeof ref !== "string" && typeof ref !== "number") return { error: `invalid reference "${String(ref)}"` };
9269
9399
  const s = String(ref).trim();
9270
9400
  const ownerIdx = handleOwner.get(s);
@@ -9477,7 +9607,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9477
9607
  const bad = [];
9478
9608
  const liveFlat = flattenTasks(tasks);
9479
9609
  for (const d of Array.isArray(deps) ? deps : []) {
9480
- const r = resolveRef(d, originalFlatAll, liveFlat, handleOwner, handleId, opIndex);
9610
+ const r = resolveRef2(d, originalFlatAll, liveFlat, handleOwner, handleId, opIndex);
9481
9611
  if ("error" in r) {
9482
9612
  bad.push(`${String(d)} (${r.error})`);
9483
9613
  continue;
@@ -9494,7 +9624,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9494
9624
  const label = `op ${i + 1} (${op.op})`;
9495
9625
  switch (op.op) {
9496
9626
  case "update": {
9497
- const ref = resolveRef(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
9627
+ const ref = resolveRef2(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
9498
9628
  if ("error" in ref) {
9499
9629
  errors.push(`${label}: ${ref.error}`);
9500
9630
  break;
@@ -9565,7 +9695,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9565
9695
  break;
9566
9696
  }
9567
9697
  case "remove": {
9568
- const ref = resolveRef(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
9698
+ const ref = resolveRef2(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
9569
9699
  if ("error" in ref) {
9570
9700
  errors.push(`${label}: ${ref.error}`);
9571
9701
  break;
@@ -9588,7 +9718,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9588
9718
  break;
9589
9719
  }
9590
9720
  case "reorder": {
9591
- const refs = (op.taskIds ?? []).map((r) => resolveRef(r, currentTasks, tasks, handleOwner, handleId, i));
9721
+ const refs = (op.taskIds ?? []).map((r) => resolveRef2(r, currentTasks, tasks, handleOwner, handleId, i));
9592
9722
  const badRef = refs.find((r) => "error" in r);
9593
9723
  if (badRef) {
9594
9724
  errors.push(`${label}: ${badRef.error}`);
@@ -9610,7 +9740,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9610
9740
  break;
9611
9741
  }
9612
9742
  case "merge": {
9613
- const refs = (op.taskIds ?? []).map((r) => resolveRef(r, currentTasks, tasks, handleOwner, handleId, i));
9743
+ const refs = (op.taskIds ?? []).map((r) => resolveRef2(r, currentTasks, tasks, handleOwner, handleId, i));
9614
9744
  const badRef = refs.find((r) => "error" in r);
9615
9745
  if (badRef) {
9616
9746
  errors.push(`${label}: ${badRef.error}`);
@@ -9680,7 +9810,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9680
9810
  break;
9681
9811
  }
9682
9812
  case "split": {
9683
- const ref = resolveRef(op.taskId, currentTasks, tasks, handleOwner, handleId, i);
9813
+ const ref = resolveRef2(op.taskId, currentTasks, tasks, handleOwner, handleId, i);
9684
9814
  if ("error" in ref) {
9685
9815
  errors.push(`${label}: ${ref.error}`);
9686
9816
  break;
@@ -9759,7 +9889,7 @@ function applyTaskOps(currentTasks, ops, runners, catalog) {
9759
9889
  break;
9760
9890
  }
9761
9891
  case "rearm": {
9762
- const ref = resolveRef(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
9892
+ const ref = resolveRef2(op.taskId, originalFlatAll, flattenTasks(tasks), handleOwner, handleId, i);
9763
9893
  if ("error" in ref) {
9764
9894
  errors.push(`${label}: ${ref.error}`);
9765
9895
  break;
@@ -10403,14 +10533,15 @@ function nonPromptingFs(fs16) {
10403
10533
  listDir: (p, depth) => withinWorkspace(p) ? fs16.listDir(p, depth) : Promise.resolve(outsideRefusal(p)),
10404
10534
  bash: async (command, signal) => {
10405
10535
  const dialect = resolveResearchShell().dialect;
10406
- const { tier, reason } = classifyCommand(command, { dialect });
10536
+ const policy = { dialect, cdpathSet: !!process.env.CDPATH };
10537
+ const { tier, reason } = classifyCommand(command, policy);
10407
10538
  if (tier !== "auto") {
10408
10539
  return refused2(
10409
10540
  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.`
10410
10541
  );
10411
10542
  }
10412
- const escaping = pathLikeArgs(command, { dialect }).find((p) => !withinWorkspace(p));
10413
- if (escaping) return outsideRefusal(escaping);
10543
+ const escaping = pathRefs(command, policy).find((ref) => !resolveRef(fs16.getWorkspaceRoot(), ref).inside);
10544
+ if (escaping) return outsideRefusal(escaping.path);
10414
10545
  return fs16.bash(command, signal);
10415
10546
  }
10416
10547
  };
@@ -11724,6 +11855,12 @@ var VerdictEngine = class {
11724
11855
  idleSince = /* @__PURE__ */ new Map();
11725
11856
  /** Tasks waiting on the user, whose silence is expected rather than a sign of a stuck runner. */
11726
11857
  idlePaused = /* @__PURE__ */ new Set();
11858
+ /**
11859
+ * Open tool approvals per structured task. They arrive mid-turn and leave the
11860
+ * status alone (ADR-0018, W1), but a task waiting on one is waiting on the
11861
+ * user, so it is not idle either.
11862
+ */
11863
+ openApprovals = /* @__PURE__ */ new Map();
11727
11864
  onVerdict(listener) {
11728
11865
  this.listeners.push(listener);
11729
11866
  }
@@ -11752,7 +11889,7 @@ var VerdictEngine = class {
11752
11889
  }
11753
11890
  /** Restart the silence timer on fresh output; broadcasts the null transition if it was idle. */
11754
11891
  touchIdle(taskId, gen) {
11755
- if (this.idlePaused.has(taskId)) return;
11892
+ if (this.idlePaused.has(taskId) || this.openApprovals.get(taskId)?.size) return;
11756
11893
  const existing = this.idleTimers.get(taskId);
11757
11894
  if (existing) clearTimeout(existing);
11758
11895
  if (this.idleSince.get(taskId)) {
@@ -11834,7 +11971,10 @@ ${line}
11834
11971
  });
11835
11972
  if (isStructuredSession(session)) {
11836
11973
  session.onEvent((event) => {
11837
- if (event.type === "turn_start" && this.generations.get(task.id) === gen) this.resumeIdle(task.id);
11974
+ if (this.generations.get(task.id) !== gen) return;
11975
+ if (event.type === "turn_start") this.resumeIdle(task.id);
11976
+ else if (event.type === "permission_request" && !event.decided) this.approvalOpened(task.id, event.id);
11977
+ else if (event.type === "permission_decided" || event.type === "permission_withdrawn") this.approvalClosed(task.id, event.id, gen);
11838
11978
  });
11839
11979
  }
11840
11980
  session.onExit((exitCode) => {
@@ -11845,6 +11985,18 @@ ${line}
11845
11985
  for (const l of this.listeners) l(task.id, verdict);
11846
11986
  });
11847
11987
  }
11988
+ approvalOpened(taskId, approvalId) {
11989
+ const open = this.openApprovals.get(taskId) ?? /* @__PURE__ */ new Set();
11990
+ open.add(approvalId);
11991
+ this.openApprovals.set(taskId, open);
11992
+ this.clearIdle(taskId);
11993
+ }
11994
+ approvalClosed(taskId, approvalId, gen) {
11995
+ const open = this.openApprovals.get(taskId);
11996
+ if (!open?.delete(approvalId) || open.size) return;
11997
+ this.openApprovals.delete(taskId);
11998
+ this.touchIdle(taskId, gen);
11999
+ }
11848
12000
  /** Scan only the new text plus the unmatched carry, so a long run stays linear. */
11849
12001
  scanCheckpoints(taskId, session, text2) {
11850
12002
  const scan = (this.checkpointCarry.get(taskId) ?? "") + text2;
@@ -11866,6 +12018,7 @@ ${line}
11866
12018
  this.checkpointCarry.delete(taskId);
11867
12019
  this.pausedSessions.delete(taskId);
11868
12020
  this.idlePaused.delete(taskId);
12021
+ this.openApprovals.delete(taskId);
11869
12022
  this.clearIdle(taskId);
11870
12023
  }
11871
12024
  /** Manual "Mark complete" override: a pass verdict that bypasses evidence. */
@@ -11903,6 +12056,7 @@ ${line}
11903
12056
  this.idleTimers.clear();
11904
12057
  this.idleSince.clear();
11905
12058
  this.idlePaused.clear();
12059
+ this.openApprovals.clear();
11906
12060
  this.generations.clear();
11907
12061
  }
11908
12062
  decide(task, exitCode) {
@@ -15566,9 +15720,502 @@ ${item.text}` : item.text });
15566
15720
 
15567
15721
  // src/services/harness/OpenCodeAdapter.ts
15568
15722
  var import_crypto2 = require("crypto");
15723
+
15724
+ // src/services/harness/OpenCodeV2.ts
15725
+ var STREAM_CONNECT_TIMEOUT_MS = 5e3;
15726
+ var ACTIVE_POLL_INTERVAL_MS = 1e3;
15727
+ var IDLE_POLLS_BEFORE_START = 3;
15728
+ var PLANNER_RULES = [
15729
+ { action: "question", resource: "*", effect: "deny" },
15730
+ { action: "edit", resource: "*", effect: "deny" }
15731
+ ];
15732
+ var TASK_RULES = [{ action: "question", resource: "*", effect: "deny" }];
15733
+ var AUTO_APPROVALS = "auto";
15734
+ function newTurnState() {
15735
+ 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() };
15736
+ }
15737
+ function splitModelId(id) {
15738
+ const slash = id.indexOf("/");
15739
+ if (slash <= 0 || slash === id.length - 1) return null;
15740
+ return { providerID: id.slice(0, slash), id: id.slice(slash + 1) };
15741
+ }
15742
+ function delay(ms, signal) {
15743
+ return new Promise((resolve7) => {
15744
+ const timer = setTimeout(resolve7, ms);
15745
+ timer.unref?.();
15746
+ signal?.addEventListener("abort", () => {
15747
+ clearTimeout(timer);
15748
+ resolve7();
15749
+ }, { once: true });
15750
+ });
15751
+ }
15752
+ function contentText(content) {
15753
+ return (content ?? []).filter((c) => c.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
15754
+ }
15755
+ function subagentDigest(output) {
15756
+ const inner = output.match(/<subagent[^>]*>\n?([\s\S]*?)\n?<\/subagent>/);
15757
+ return inner ? inner[1] : output;
15758
+ }
15759
+ function permissionReply(decision) {
15760
+ if (decision.decision === "allow") return { decision: "once" };
15761
+ if (decision.decision === "allowForTask") return { decision: "always" };
15762
+ const note = decision.note?.trim();
15763
+ return note ? { decision: "reject", message: note } : { decision: "reject" };
15764
+ }
15765
+ var OpenCodeV2 = class {
15766
+ constructor(host, opts, role) {
15767
+ this.host = host;
15768
+ this.opts = opts;
15769
+ this.role = role;
15770
+ this.agent = opts.kind === "task" ? opts.flags.permissionMode : "plan";
15771
+ const split = opts.model ? splitModelId(opts.model) : null;
15772
+ const variant = opts.kind === "task" ? opts.flags.effort : opts.effort;
15773
+ if (split) this.model = { ...split, ...variant ? { variant } : {} };
15774
+ }
15775
+ host;
15776
+ opts;
15777
+ role;
15778
+ sessionId = null;
15779
+ model = null;
15780
+ agent;
15781
+ turn = null;
15782
+ turnHasText = false;
15783
+ interruptRequested = false;
15784
+ /** A task's requests waiting for an answer, by request id, with the session that asked. */
15785
+ openPermissions = /* @__PURE__ */ new Map();
15786
+ nativeSessionId() {
15787
+ return this.sessionId;
15788
+ }
15789
+ async start() {
15790
+ const resume = this.opts.resumeSessionId;
15791
+ if (resume) {
15792
+ const existing = await this.host.json("GET", `/api/session/${resume}`).catch(() => null);
15793
+ if (existing?.data?.id) {
15794
+ this.sessionId = existing.data.id;
15795
+ if (this.model) await this.host.request("POST", `/api/session/${this.sessionId}/model`, { model: this.model }).catch(() => void 0);
15796
+ await this.host.request("POST", `/api/session/${this.sessionId}/agent`, { agent: this.agent }).catch(() => void 0);
15797
+ await this.applyInstructions();
15798
+ return;
15799
+ }
15800
+ if (this.opts.kind === "task") throw new Error(`OpenCode could not resume session ${resume}: the server does not know it.`);
15801
+ }
15802
+ const created = await this.host.json("POST", "/api/session", {
15803
+ agent: this.agent,
15804
+ ...this.model ? { model: this.model } : {},
15805
+ permissions: this.role === "planner" ? PLANNER_RULES : TASK_RULES
15806
+ });
15807
+ if (!created?.data?.id) throw new Error(`The OpenCode ${this.role} server did not return a session id.`);
15808
+ this.sessionId = created.data.id;
15809
+ await this.applyInstructions();
15810
+ }
15811
+ /** The planner's system prompt rides on the session as an instruction entry — a prompt has no system field. */
15812
+ async applyInstructions() {
15813
+ if (this.opts.kind !== "planner" || !this.opts.systemPrompt) return;
15814
+ const response = await this.host.request("PUT", `/api/experimental/session/${this.sessionId}/instructions/entries/ordewell-planner`, { value: this.opts.systemPrompt });
15815
+ if (!response.ok) throw new Error(`OpenCode did not take the planner instructions: ${response.status} ${response.statusText}`);
15816
+ }
15817
+ async send(message, onEvent, signal, onActivity) {
15818
+ if (!this.sessionId) throw new Error(`OpenCode ${this.role} session is not started`);
15819
+ if (this.host.isExited()) {
15820
+ onEvent({ type: "error", message: this.host.exitMessage() });
15821
+ return;
15822
+ }
15823
+ const state = newTurnState();
15824
+ this.turnHasText = false;
15825
+ this.interruptRequested = false;
15826
+ let markDone = () => {
15827
+ };
15828
+ const ended = new Promise((resolve7) => {
15829
+ markDone = resolve7;
15830
+ });
15831
+ const turn = { live: false, done: false, outcome: null, failure: null, ended, finish: () => {
15832
+ turn.done = true;
15833
+ markDone();
15834
+ } };
15835
+ this.turn = turn;
15836
+ const closeStream = await this.openStream(state, turn, onEvent, onActivity);
15837
+ const poll = new AbortController();
15838
+ try {
15839
+ try {
15840
+ await this.host.json("POST", `/api/session/${this.sessionId}/prompt`, { text: message }, signal);
15841
+ } catch (err) {
15842
+ if (signal?.aborted) return;
15843
+ onEvent({ type: "error", message: `OpenCode did not take the message: ${err instanceof Error ? err.message : String(err)}` });
15844
+ return;
15845
+ }
15846
+ void this.pollActive(turn, poll.signal);
15847
+ const aborted = new Promise((resolve7) => {
15848
+ if (signal?.aborted) resolve7();
15849
+ signal?.addEventListener("abort", () => resolve7(), { once: true });
15850
+ });
15851
+ await Promise.race([turn.ended, aborted, this.host.processEnded]);
15852
+ if (signal?.aborted) return;
15853
+ if (!turn.done) {
15854
+ onEvent({ type: "error", message: this.host.exitMessage() });
15855
+ return;
15856
+ }
15857
+ await this.readBack(state, onEvent);
15858
+ for (const id of [...this.openPermissions.keys()]) {
15859
+ this.openPermissions.delete(id);
15860
+ onEvent({ type: "permission_cancelled", id });
15861
+ }
15862
+ if (turn.outcome === "interrupted" || this.interruptRequested) onEvent({ type: "turn_end", interrupted: true });
15863
+ else if (turn.outcome === "failed") onEvent({ type: "error", message: turn.failure ?? "OpenCode reported that the turn failed." });
15864
+ else onEvent({ type: "turn_end" });
15865
+ } finally {
15866
+ this.turn = null;
15867
+ poll.abort();
15868
+ await closeStream();
15869
+ }
15870
+ }
15871
+ /**
15872
+ * The stream can drop the end frame while still delivering the rest, so the
15873
+ * turn also asks which sessions are running. A session that is not, after
15874
+ * this turn's work was seen, is over; one never seen working after a few
15875
+ * polls had already finished before the stream connected.
15876
+ */
15877
+ async pollActive(turn, signal) {
15878
+ let idlePolls = 0;
15879
+ while (!turn.done && !signal.aborted) {
15880
+ await delay(ACTIVE_POLL_INTERVAL_MS, signal);
15881
+ if (turn.done || signal.aborted) return;
15882
+ const active = await this.host.json("GET", "/api/session/active").catch(() => null);
15883
+ if (!active?.data) continue;
15884
+ if (this.sessionId && this.sessionId in active.data) {
15885
+ turn.live = true;
15886
+ idlePolls = 0;
15887
+ continue;
15888
+ }
15889
+ idlePolls++;
15890
+ if (turn.live || idlePolls >= IDLE_POLLS_BEFORE_START) {
15891
+ const messages = await this.messages();
15892
+ const idle = [...messages ?? []].reverse().find((m) => m.type === "idle");
15893
+ if (idle?.outcome === "failed" || idle?.outcome === "interrupted" || idle?.outcome === "succeeded") turn.outcome ??= idle.outcome;
15894
+ turn.outcome ??= "succeeded";
15895
+ if (!turn.done) turn.finish();
15896
+ }
15897
+ }
15898
+ }
15899
+ async messages() {
15900
+ const response = await this.host.json("GET", `/api/session/${this.sessionId}/message`).catch(() => null);
15901
+ return Array.isArray(response?.data) ? response.data : null;
15902
+ }
15903
+ /**
15904
+ * Whatever of the turn's own replies the stream missed. A message whose text
15905
+ * the stream delivered adds nothing here; one that lost its text frames still
15906
+ * reaches the plain-text channel, where the done marker is looked for.
15907
+ */
15908
+ async readBack(state, onEvent) {
15909
+ const messages = await this.messages();
15910
+ if (!messages) return;
15911
+ const latestUser = messages.findIndex((m) => m.type === "user");
15912
+ const own = latestUser < 0 ? messages : messages.slice(0, latestUser);
15913
+ for (const message of [...own].reverse()) {
15914
+ if (message.type !== "assistant" || !message.id || state.streamedMessages.has(message.id)) continue;
15915
+ (message.content ?? []).forEach((part, index) => {
15916
+ if (part.type === "text" && part.text) this.emitText(`${message.id}:readback:${index}`, part.text, state, onEvent);
15917
+ });
15918
+ }
15919
+ }
15920
+ /**
15921
+ * Abort the running turn, keeping the server and its session. OpenCode
15922
+ * acknowledges with `session.execution.interrupted`, which also ends the turn.
15923
+ */
15924
+ async interrupt(timeoutMs) {
15925
+ if (!this.sessionId || this.host.isExited()) return false;
15926
+ const turn = this.turn;
15927
+ this.interruptRequested = true;
15928
+ const response = await this.host.json("POST", `/api/session/${this.sessionId}/interrupt`).catch(() => null);
15929
+ if (!response) return false;
15930
+ if (!turn || turn.done) return true;
15931
+ let timer;
15932
+ const timedOut = new Promise((resolve7) => {
15933
+ timer = setTimeout(() => resolve7(false), timeoutMs);
15934
+ timer.unref?.();
15935
+ });
15936
+ const acknowledged = await Promise.race([turn.ended.then(() => true), this.host.processEnded.then(() => false), timedOut]);
15937
+ clearTimeout(timer);
15938
+ return acknowledged;
15939
+ }
15940
+ answerPermission(id, decision) {
15941
+ const sessionId = this.openPermissions.get(id);
15942
+ if (sessionId === void 0) return false;
15943
+ this.openPermissions.delete(id);
15944
+ void this.replyPermission(id, sessionId, permissionReply(decision)).catch(() => {
15945
+ });
15946
+ return true;
15947
+ }
15948
+ async replyPermission(id, sessionId, reply) {
15949
+ await this.host.request("POST", `/api/session/${sessionId}/permission/${id}/reply`, reply);
15950
+ }
15951
+ async openStream(state, turn, onEvent, onActivity) {
15952
+ const streamAbort = new AbortController();
15953
+ let connected = () => {
15954
+ };
15955
+ const ready = new Promise((resolve7) => {
15956
+ connected = resolve7;
15957
+ });
15958
+ const live = this.streamEvents(streamAbort.signal, (frame) => this.onFrame(frame, state, turn, onEvent), connected, onActivity);
15959
+ await Promise.race([ready, new Promise((r) => {
15960
+ const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS);
15961
+ t.unref?.();
15962
+ })]);
15963
+ return async () => {
15964
+ streamAbort.abort();
15965
+ await live.catch(() => {
15966
+ });
15967
+ };
15968
+ }
15969
+ async streamEvents(signal, onFrame, onConnected, onActivity) {
15970
+ const response = await this.host.openEvents(signal).catch(() => null);
15971
+ const body = response?.body;
15972
+ if (!body) {
15973
+ onConnected();
15974
+ return;
15975
+ }
15976
+ onConnected();
15977
+ const reader = body.getReader();
15978
+ const decoder = new TextDecoder();
15979
+ let buffer = "";
15980
+ for (; ; ) {
15981
+ const { done, value } = await reader.read().catch(() => ({ done: true, value: void 0 }));
15982
+ if (done) return;
15983
+ onActivity?.();
15984
+ buffer += decoder.decode(value, { stream: true });
15985
+ let newline;
15986
+ while ((newline = buffer.indexOf("\n")) >= 0) {
15987
+ const line = buffer.slice(0, newline).trim();
15988
+ buffer = buffer.slice(newline + 1);
15989
+ if (!line.startsWith("data:")) continue;
15990
+ try {
15991
+ onFrame(JSON.parse(line.slice(5).trim()));
15992
+ } catch {
15993
+ }
15994
+ }
15995
+ }
15996
+ }
15997
+ /**
15998
+ * One frame. Only this session and its children are followed: the stream is
15999
+ * the whole server's, and another client's session is none of this turn's business.
16000
+ */
16001
+ onFrame(frame, state, turn, onEvent) {
16002
+ const data = frame.data;
16003
+ if (!data || !frame.type) return;
16004
+ if (frame.type === "session.created") {
16005
+ if (data.parentID && data.parentID === this.sessionId && data.sessionID && !state.children.has(data.sessionID)) state.children.set(data.sessionID, null);
16006
+ return;
16007
+ }
16008
+ const session = data.sessionID;
16009
+ if (!session) return;
16010
+ const isChild = session !== this.sessionId;
16011
+ if (isChild && !state.children.has(session)) return;
16012
+ if (!isChild) {
16013
+ if (frame.type === "session.execution.started") {
16014
+ turn.live = true;
16015
+ return;
16016
+ }
16017
+ if (frame.type === "session.execution.succeeded" || frame.type === "session.execution.failed" || frame.type === "session.execution.interrupted") {
16018
+ if (!turn.live || turn.done) return;
16019
+ turn.outcome = frame.type === "session.execution.succeeded" ? "succeeded" : frame.type === "session.execution.failed" ? "failed" : "interrupted";
16020
+ if (turn.outcome === "failed") turn.failure = data.error?.message ?? data.error?.type ?? null;
16021
+ turn.finish();
16022
+ return;
16023
+ }
16024
+ }
16025
+ if (frame.type === "permission.asked") {
16026
+ if (this.role === "task") this.askPermission(data, session, state, onEvent);
16027
+ else this.denyPermission(data, session, state, onEvent);
16028
+ return;
16029
+ }
16030
+ if (frame.type === "permission.replied") {
16031
+ const id = data.requestID;
16032
+ if (id && this.openPermissions.delete(id)) onEvent({ type: "permission_cancelled", id });
16033
+ return;
16034
+ }
16035
+ let subagentId;
16036
+ if (isChild) {
16037
+ const owner = state.children.get(session);
16038
+ if (!owner) {
16039
+ state.heldFrames.set(session, [...state.heldFrames.get(session) ?? [], frame]);
16040
+ return;
16041
+ }
16042
+ subagentId = owner;
16043
+ }
16044
+ this.onContentFrame(frame, data, state, turn, onEvent, subagentId);
16045
+ }
16046
+ onContentFrame(frame, data, state, turn, onEvent, subagentId) {
16047
+ const message = data.assistantMessageID ?? "";
16048
+ switch (frame.type) {
16049
+ case "session.step.started":
16050
+ if (message && data.model?.providerID && data.model.id) state.stepModels.set(message, `${data.model.providerID}/${data.model.id}`);
16051
+ return;
16052
+ case "session.reasoning.delta":
16053
+ if (data.delta) onEvent({ type: "thinking_delta", text: data.delta, subagentId });
16054
+ return;
16055
+ case "session.reasoning.ended": {
16056
+ const key = `reasoning:${message}:${data.ordinal ?? 0}`;
16057
+ if (!data.text || state.seen.has(key)) return;
16058
+ state.seen.add(key);
16059
+ onEvent({ type: "thinking", text: data.text, subagentId });
16060
+ return;
16061
+ }
16062
+ case "session.text.delta":
16063
+ if (!subagentId && data.delta) this.onTextDelta(`${message}:${data.ordinal ?? 0}`, data.delta, state, onEvent);
16064
+ return;
16065
+ case "session.text.ended":
16066
+ if (subagentId || !data.text) return;
16067
+ if (message) state.streamedMessages.add(message);
16068
+ this.emitText(`${message}:${data.ordinal ?? 0}`, data.text, state, onEvent);
16069
+ return;
16070
+ case "session.tool.input.started":
16071
+ if (data.id && data.name) state.toolNames.set(data.id, data.name);
16072
+ return;
16073
+ case "session.tool.called": {
16074
+ const id = data.id;
16075
+ if (!id || state.seen.has(`call:${id}`)) return;
16076
+ state.seen.add(`call:${id}`);
16077
+ const input = data.input ?? {};
16078
+ const name = state.toolNames.get(id) ?? "tool";
16079
+ const brief = typeof input.description === "string" ? input.description : typeof input.prompt === "string" ? input.prompt : "";
16080
+ state.briefs.set(id, brief);
16081
+ onEvent({ type: "tool_call", id, name, args: input, subagentId });
16082
+ return;
16083
+ }
16084
+ case "session.tool.progress":
16085
+ if (!subagentId) this.adoptChild(data, state, onEvent, turn);
16086
+ return;
16087
+ case "session.tool.success":
16088
+ case "session.tool.failed": {
16089
+ const id = data.id;
16090
+ if (!id || state.seen.has(`result:${id}`)) return;
16091
+ state.seen.add(`result:${id}`);
16092
+ const success = frame.type === "session.tool.success";
16093
+ const output = success ? contentText(data.content) : data.error?.message ?? data.error?.type ?? "";
16094
+ onEvent({ type: "tool_result", id, name: state.toolNames.get(id) ?? "tool", output, success, subagentId });
16095
+ if (!subagentId) this.finishChild(id, success, output, state, onEvent);
16096
+ return;
16097
+ }
16098
+ case "session.step.ended":
16099
+ case "session.step.failed":
16100
+ this.countUsage(data, state, onEvent, subagentId);
16101
+ return;
16102
+ default:
16103
+ }
16104
+ }
16105
+ adoptChild(data, state, onEvent, turn) {
16106
+ const child = data.metadata?.sessionID;
16107
+ const callId = data.id;
16108
+ if (!child || !callId || state.children.get(child)) return;
16109
+ const model = state.stepModels.get(data.assistantMessageID ?? "");
16110
+ onEvent({ type: "subagent_started", subagentId: callId, brief: state.briefs.get(callId) ?? "", ...model ? { model } : {} });
16111
+ state.children.set(child, callId);
16112
+ const held = state.heldFrames.get(child) ?? [];
16113
+ state.heldFrames.delete(child);
16114
+ for (const frame of held) this.onFrame(frame, state, turn, onEvent);
16115
+ }
16116
+ finishChild(callId, success, output, state, onEvent) {
16117
+ if (![...state.children.values()].includes(callId)) return;
16118
+ onEvent({ type: "subagent_finished", subagentId: callId, outcome: success ? "done" : "failed", digest: subagentDigest(output) });
16119
+ }
16120
+ /** One model call's usage, once, when its step ends. Zeros mean the call failed before the provider answered. */
16121
+ countUsage(data, state, onEvent, subagentId) {
16122
+ const message = data.assistantMessageID;
16123
+ const tokens = data.tokens;
16124
+ if (!message || !tokens || state.seen.has(`usage:${message}`)) return;
16125
+ state.seen.add(`usage:${message}`);
16126
+ const prompt = partedPromptUsage({ uncached: tokens.input, cacheRead: tokens.cache?.read, cacheWrite: tokens.cache?.write });
16127
+ const outputTokens = (tokens.output ?? 0) + (tokens.reasoning ?? 0);
16128
+ if ((prompt.inputTokens ?? 0) + outputTokens === 0) return;
16129
+ const record = { source: "opencode", ...prompt, outputTokens };
16130
+ const model = state.stepModels.get(message);
16131
+ if (model) record.model = model;
16132
+ if (typeof data.cost === "number" && data.cost > 0) record.reportedCost = { amount: data.cost, currency: "USD" };
16133
+ if (subagentId) record.subagentId = subagentId;
16134
+ onEvent({ type: "usage", record });
16135
+ }
16136
+ /** 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. */
16137
+ emitText(id, text2, state, onEvent) {
16138
+ if (state.seen.has(`text:${id}`)) return;
16139
+ state.seen.add(`text:${id}`);
16140
+ if (!text2.trim()) return;
16141
+ const lead = state.textRuns.get(id)?.lead ?? (this.turnHasText ? "\n\n" : "");
16142
+ onEvent({ type: "assistant_text", text: `${lead}${text2}` });
16143
+ this.turnHasText = true;
16144
+ }
16145
+ /**
16146
+ * Stream one piece of a reply text. The paragraph break goes out with the
16147
+ * first visible delta, so the deltas add up to exactly the text the completed
16148
+ * run then re-sends; a run that is only whitespace so far is held back.
16149
+ */
16150
+ onTextDelta(id, delta, state, onEvent) {
16151
+ if (state.seen.has(`text:${id}`)) return;
16152
+ const run = state.textRuns.get(id) ?? { held: "", lead: null };
16153
+ state.textRuns.set(id, run);
16154
+ if (run.lead !== null) {
16155
+ onEvent({ type: "assistant_text_delta", text: delta });
16156
+ return;
16157
+ }
16158
+ run.held += delta;
16159
+ if (!run.held.trim()) return;
16160
+ run.lead = this.turnHasText ? "\n\n" : "";
16161
+ this.turnHasText = true;
16162
+ onEvent({ type: "assistant_text_delta", text: `${run.lead}${run.held}` });
16163
+ }
16164
+ permissionRequest(ask) {
16165
+ const id = ask.id;
16166
+ if (!id) return null;
16167
+ const scope = (ask.resources ?? []).join(", ");
16168
+ return {
16169
+ type: "permission_request",
16170
+ id,
16171
+ name: ask.action ?? "permission",
16172
+ detail: JSON.stringify(scope ? { scope } : {}),
16173
+ input: ask.resources ? { resources: ask.resources } : {},
16174
+ // The patterns `always` would grant: the runner's own offer, and the only grounds for "Allow for this task".
16175
+ ...ask.save?.length ? { suggestions: ask.save } : {},
16176
+ ...ask.source?.id ? { toolUseId: ask.source.id } : {}
16177
+ };
16178
+ }
16179
+ /**
16180
+ * Deny one planner request. OpenCode blocks the turn until it is answered, so
16181
+ * this must answer: `reject` rather than a silent drop is the "absent answer
16182
+ * is a denial" invariant ADR-0008 states. The refusal is announced so the
16183
+ * timeline shows the planner reaching for something it may not have.
16184
+ */
16185
+ denyPermission(ask, session, state, onEvent) {
16186
+ const request = this.permissionRequest(ask);
16187
+ if (!request || state.seen.has(`perm:${request.id}`)) return;
16188
+ state.seen.add(`perm:${request.id}`);
16189
+ onEvent(request);
16190
+ void this.replyPermission(request.id, session, { decision: "reject" }).catch(() => {
16191
+ });
16192
+ }
16193
+ /**
16194
+ * A task's request. Under a mode whose manifest sets `approvals: auto` it is
16195
+ * answered at once with what `opencode run --auto` answers, so the same plan
16196
+ * behaves the same on both transports (ADR-0001), and announced already
16197
+ * decided so the log still shows it. Any other mode leaves it open for an
16198
+ * approval card.
16199
+ */
16200
+ askPermission(ask, session, state, onEvent) {
16201
+ const request = this.permissionRequest(ask);
16202
+ if (!request || state.seen.has(`perm:${request.id}`)) return;
16203
+ state.seen.add(`perm:${request.id}`);
16204
+ if (this.opts.kind === "task" && this.opts.flags.modeSettings.approvals === AUTO_APPROVALS) {
16205
+ onEvent({ ...request, decided: { decision: "allow" } });
16206
+ void this.replyPermission(request.id, session, { decision: "once" }).catch(() => {
16207
+ });
16208
+ return;
16209
+ }
16210
+ this.openPermissions.set(request.id, session);
16211
+ onEvent(request);
16212
+ }
16213
+ };
16214
+
16215
+ // src/services/harness/OpenCodeAdapter.ts
15569
16216
  var SERVER_READY_TIMEOUT_MS = 3e4;
15570
16217
  var STDERR_TAIL_CHARS2 = 4e3;
15571
- var STREAM_CONNECT_TIMEOUT_MS = 5e3;
16218
+ var STREAM_CONNECT_TIMEOUT_MS2 = 5e3;
15572
16219
  var RECOVERY_POLL_INTERVAL_MS = 2e3;
15573
16220
  var RECOVERY_TIMEOUT_MS = 9e5;
15574
16221
  var STATUS_POLL_INTERVAL_MS = 1e3;
@@ -15581,8 +16228,8 @@ var DISABLED_TOOLS = {
15581
16228
  todowrite: false
15582
16229
  };
15583
16230
  var TASK_DISABLED_TOOLS = { question: false };
15584
- var AUTO_APPROVALS = "auto";
15585
- function splitModelId(id) {
16231
+ var AUTO_APPROVALS2 = "auto";
16232
+ function splitModelId2(id) {
15586
16233
  const slash = id.indexOf("/");
15587
16234
  if (slash <= 0 || slash === id.length - 1) return null;
15588
16235
  return { providerID: id.slice(0, slash), modelID: id.slice(slash + 1) };
@@ -15638,7 +16285,7 @@ function unwrapFileToolOutput(output) {
15638
16285
  const inner = output.match(/<content>\n?([\s\S]*?)\n?<\/content>/);
15639
16286
  return inner ? inner[1] : output;
15640
16287
  }
15641
- function delay(ms, signal) {
16288
+ function delay2(ms, signal) {
15642
16289
  return new Promise((resolve7) => {
15643
16290
  const timer = setTimeout(resolve7, ms);
15644
16291
  timer.unref?.();
@@ -15648,10 +16295,10 @@ function delay(ms, signal) {
15648
16295
  }, { once: true });
15649
16296
  });
15650
16297
  }
15651
- function newTurnState() {
16298
+ function newTurnState2() {
15652
16299
  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() };
15653
16300
  }
15654
- function permissionReply(decision) {
16301
+ function permissionReply2(decision) {
15655
16302
  if (decision.decision === "allow") return { reply: "once" };
15656
16303
  if (decision.decision === "allowForTask") return { reply: "always" };
15657
16304
  const note = decision.note?.trim();
@@ -15688,6 +16335,8 @@ var OpenCodeAdapter = class {
15688
16335
  interruptRequested = false;
15689
16336
  /** A task's requests waiting for an answer, by request id, with the session that asked. */
15690
16337
  openPermissions = /* @__PURE__ */ new Map();
16338
+ /** Set once the server turns out to speak the 2.x API, which then owns the session. */
16339
+ v2 = null;
15691
16340
  async start(opts) {
15692
16341
  if (opts.kind === "task") this.task = opts;
15693
16342
  else this.planner = opts;
@@ -15702,14 +16351,14 @@ var OpenCodeAdapter = class {
15702
16351
  throw new ExecutableNotFoundError("opencode", PATH);
15703
16352
  }
15704
16353
  const workspaceEnv = await (this.deps.workspaceEnv ?? workspaceEnvOf)(opts.cwd);
15705
- const password = opts.kind === "task" ? (0, import_crypto2.randomBytes)(24).toString("base64url") : null;
16354
+ const password = (0, import_crypto2.randomBytes)(24).toString("base64url");
15706
16355
  this.process = this.deps.spawn(launch.file, launch.args, {
15707
- env: runnerEnv(PATH, password ? { ...workspaceEnv, OPENCODE_SERVER_USERNAME: SERVER_USERNAME, OPENCODE_SERVER_PASSWORD: password } : workspaceEnv),
16356
+ env: runnerEnv(PATH, { ...workspaceEnv, OPENCODE_SERVER_USERNAME: SERVER_USERNAME, OPENCODE_SERVER_PASSWORD: password }),
15708
16357
  stdio: ["pipe", "pipe", "pipe"],
15709
16358
  cwd: opts.cwd,
15710
16359
  windowsVerbatimArguments: launch.verbatim
15711
16360
  });
15712
- if (password) this.authorization = `Basic ${Buffer.from(`${SERVER_USERNAME}:${password}`).toString("base64")}`;
16361
+ this.authorization = `Basic ${Buffer.from(`${SERVER_USERNAME}:${password}`).toString("base64")}`;
15713
16362
  this.process.stdin?.on("error", () => {
15714
16363
  });
15715
16364
  const banner = new Promise((resolve7) => {
@@ -15746,6 +16395,24 @@ ${err.message}`).slice(-STDERR_TAIL_CHARS2);
15746
16395
 
15747
16396
  ${this.stderrTail.trim()}` : ""}`);
15748
16397
  }
16398
+ if (await this.speaksV2()) {
16399
+ this.v2 = new OpenCodeV2({
16400
+ request: (method, path27, body, signal) => this.request(method, path27, body, signal),
16401
+ json: (method, path27, body, signal) => this.json(method, path27, body, signal),
16402
+ openEvents: (signal) => this.deps.fetch(`${this.baseUrl}/api/event`, { signal, headers: this.headers() }),
16403
+ processEnded: this.processEnded,
16404
+ isExited: () => this.exited,
16405
+ exitMessage: () => this.exitMessage()
16406
+ }, opts, this.role());
16407
+ try {
16408
+ await this.v2.start();
16409
+ } catch (err) {
16410
+ this.dispose();
16411
+ throw err;
16412
+ }
16413
+ this.sessionId = this.v2.nativeSessionId();
16414
+ return;
16415
+ }
15749
16416
  if (opts.resumeSessionId) {
15750
16417
  const existing = await this.json("GET", `/session/${opts.resumeSessionId}`).catch(() => null);
15751
16418
  if (existing?.id) {
@@ -15769,12 +16436,17 @@ ${this.stderrTail.trim()}` : ""}`);
15769
16436
  onEvent({ type: "error", message: this.exitMessage() });
15770
16437
  return;
15771
16438
  }
16439
+ if (this.v2) {
16440
+ await this.v2.send(message, onEvent, signal, onActivity);
16441
+ if (signal?.aborted) this.dispose();
16442
+ return;
16443
+ }
15772
16444
  if (this.task) return this.sendTask(this.task, message, onEvent, signal, onActivity);
15773
- const turn = newTurnState();
16445
+ const turn = newTurnState2();
15774
16446
  this.turnHasText = false;
15775
16447
  const closeStream = await this.openStream(turn, onEvent, onActivity);
15776
16448
  try {
15777
- const model = this.planner?.model ? splitModelId(this.planner.model) : null;
16449
+ const model = this.planner?.model ? splitModelId2(this.planner.model) : null;
15778
16450
  const body = {
15779
16451
  parts: [{ type: "text", text: message }],
15780
16452
  // The read-only guarantee: OpenCode's own plan agent has no write tools.
@@ -15815,7 +16487,7 @@ ${this.stderrTail.trim()}` : ""}`);
15815
16487
  * {@link recoverReply}, never applies.
15816
16488
  */
15817
16489
  async sendTask(task, message, onEvent, signal, onActivity) {
15818
- const state = newTurnState();
16490
+ const state = newTurnState2();
15819
16491
  this.turnHasText = false;
15820
16492
  this.interruptRequested = false;
15821
16493
  let markDone = () => {
@@ -15831,7 +16503,7 @@ ${this.stderrTail.trim()}` : ""}`);
15831
16503
  const closeStream = await this.openStream(state, onEvent, onActivity);
15832
16504
  const poll = new AbortController();
15833
16505
  try {
15834
- const model = task.model ? splitModelId(task.model) : null;
16506
+ const model = task.model ? splitModelId2(task.model) : null;
15835
16507
  const body = {
15836
16508
  parts: [{ type: "text", text: message }],
15837
16509
  // The manifest's meaning of the task's mode (ADR-0001): for OpenCode a mode is an agent.
@@ -15911,7 +16583,7 @@ ${this.stderrTail.trim()}` : ""}`);
15911
16583
  */
15912
16584
  async pollStatus(turn, signal) {
15913
16585
  while (!turn.done && !signal.aborted) {
15914
- await delay(STATUS_POLL_INTERVAL_MS, signal);
16586
+ await delay2(STATUS_POLL_INTERVAL_MS, signal);
15915
16587
  if (turn.done || signal.aborted) return;
15916
16588
  const busy = await this.sessionBusy();
15917
16589
  if (busy === true) turn.live = true;
@@ -15958,6 +16630,7 @@ ${this.stderrTail.trim()}` : ""}`);
15958
16630
  */
15959
16631
  async interrupt(timeoutMs) {
15960
16632
  if (!this.process || !this.sessionId || this.exited) return false;
16633
+ if (this.v2) return this.v2.interrupt(timeoutMs);
15961
16634
  const turn = this.taskTurn;
15962
16635
  this.interruptRequested = true;
15963
16636
  const posted = await this.json("POST", `/session/${this.sessionId}/abort`).then(() => true, () => false);
@@ -15978,13 +16651,23 @@ ${this.stderrTail.trim()}` : ""}`);
15978
16651
  void this.processEnded.then(() => listener(this.exitCode));
15979
16652
  }
15980
16653
  answerPermission(id, decision) {
16654
+ if (this.v2) return this.process ? this.v2.answerPermission(id, decision) : false;
15981
16655
  const sessionId = this.openPermissions.get(id);
15982
16656
  if (sessionId === void 0 || !this.process) return false;
15983
16657
  this.openPermissions.delete(id);
15984
- void this.replyPermission(id, sessionId, permissionReply(decision)).catch(() => {
16658
+ void this.replyPermission(id, sessionId, permissionReply2(decision)).catch(() => {
15985
16659
  });
15986
16660
  return true;
15987
16661
  }
16662
+ /**
16663
+ * OpenCode 2.x replaced the 1.x HTTP surface rather than extending it, and
16664
+ * only 2.x answers `/api/info` with its version. A 1.x server — or one that
16665
+ * answers with anything else — keeps the 1.x protocol this class speaks.
16666
+ */
16667
+ async speaksV2() {
16668
+ const info = await this.json("GET", "/api/info").catch(() => null);
16669
+ return typeof info === "object" && info !== null && typeof info.version === "string";
16670
+ }
15988
16671
  role() {
15989
16672
  return this.task ? "task" : "planner";
15990
16673
  }
@@ -16035,7 +16718,7 @@ ${this.stderrTail.trim()}` : ""}`);
16035
16718
  if (pending?.info?.time?.completed || pending?.info?.error) return pending;
16036
16719
  if (pending) onActivity?.();
16037
16720
  if (Date.now() >= deadline) return null;
16038
- await delay(RECOVERY_POLL_INTERVAL_MS, signal);
16721
+ await delay2(RECOVERY_POLL_INTERVAL_MS, signal);
16039
16722
  }
16040
16723
  }
16041
16724
  newestAssistantId(messages) {
@@ -16248,7 +16931,7 @@ ${this.stderrTail.trim()}` : ""}`);
16248
16931
  ...ask.always?.length ? { suggestions: ask.always } : {},
16249
16932
  ...ask.tool?.callID ? { toolUseId: ask.tool.callID } : {}
16250
16933
  };
16251
- if (this.task?.flags.modeSettings.approvals === AUTO_APPROVALS) {
16934
+ if (this.task?.flags.modeSettings.approvals === AUTO_APPROVALS2) {
16252
16935
  onEvent({ ...request, decided: { decision: "allow" } });
16253
16936
  void this.replyPermission(id, sessionId, { reply: "once" }).catch(() => {
16254
16937
  });
@@ -16282,7 +16965,7 @@ ${this.stderrTail.trim()}` : ""}`);
16282
16965
  });
16283
16966
  const live = this.streamEvents(streamAbort.signal, (frame) => this.onFrame(frame, turn, onEvent), connected, onActivity);
16284
16967
  await Promise.race([streamReady, new Promise((r) => {
16285
- const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS);
16968
+ const t = setTimeout(r, STREAM_CONNECT_TIMEOUT_MS2);
16286
16969
  t.unref?.();
16287
16970
  })]);
16288
16971
  return async () => {
@@ -16487,6 +17170,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
16487
17170
  observers = [];
16488
17171
  tddEnabled;
16489
17172
  readRunnerTransport;
17173
+ previousAttemptFromLog;
16490
17174
  /**
16491
17175
  * The setting as the latest run copied it when it opened. Every spawn of
16492
17176
  * that run uses this, so flipping the setting mid-run changes the next run
@@ -16506,6 +17190,7 @@ var TaskOrchestrator = class _TaskOrchestrator {
16506
17190
  this.workspaceEnv = deps.workspaceEnv;
16507
17191
  this.tddEnabled = deps.tddEnabled;
16508
17192
  this.readRunnerTransport = deps.runnerTransport;
17193
+ this.previousAttemptFromLog = deps.previousAttemptFromLog;
16509
17194
  this.store.onMutate = () => this.emit("onTaskChanged");
16510
17195
  this.verifier.onVerdict((taskId, verdict) => this.onVerdict(taskId, verdict));
16511
17196
  this.verifier.onCheckpoint((taskId, summary) => {
@@ -16561,7 +17246,8 @@ var TaskOrchestrator = class _TaskOrchestrator {
16561
17246
  workspaceRoot,
16562
17247
  workspaceEnv: options.workspaceEnv ?? ((cwd) => resolveWorkspaceEnv(cwd)),
16563
17248
  tddEnabled: options.tddEnabled ?? (() => false),
16564
- runnerTransport: options.runnerTransport ?? (() => "terminal")
17249
+ runnerTransport: options.runnerTransport ?? (() => "terminal"),
17250
+ previousAttemptFromLog: options.previousAttemptFromLog ?? (() => null)
16565
17251
  });
16566
17252
  return orchestrator;
16567
17253
  }
@@ -16569,6 +17255,18 @@ var TaskOrchestrator = class _TaskOrchestrator {
16569
17255
  getIdleSince(taskId) {
16570
17256
  return this.verifier.getIdleSince(taskId);
16571
17257
  }
17258
+ /**
17259
+ * What an ops retry is told about the attempt before. The saved log is read
17260
+ * whether or not the session was reloaded, so both behave alike; the terminal
17261
+ * buffer is the fallback for a runner that has no log. A reload clears the
17262
+ * attempt count along with the buffer, so a retry made since is what says
17263
+ * there was an attempt to report on.
17264
+ */
17265
+ opsPreviousAttempt(taskId) {
17266
+ const saved = this.previousAttemptFromLog(taskId) ?? this.output.liveTail(taskId, { maxLines: OPS_RETRY_TAIL_LINES })?.text;
17267
+ if (saved !== void 0) return saved;
17268
+ return (this.retryCounts.get(taskId) ?? 0) > 0 ? "Its output is not available: this session was reloaded since it ran." : void 0;
17269
+ }
16572
17270
  /** Recent clean output of a task's latest attempt, running or ended; null if it never ran. */
16573
17271
  getLiveOutput(taskId, opts) {
16574
17272
  return this.output.liveTail(taskId, opts);
@@ -17426,7 +18124,7 @@ ${summary || "(empty \u2014 no output captured)"}`);
17426
18124
  tddEnabled: !attempt.repair && this.tddEnabled(),
17427
18125
  // An ops task's effects outlive a failed attempt and are never rolled
17428
18126
  // back, so the next one is told what the last one did (ADR-0020).
17429
- previousAttempt: attempt.ops ? this.output.liveTail(task.id, { maxLines: OPS_RETRY_TAIL_LINES })?.text : void 0
18127
+ previousAttempt: attempt.ops ? this.opsPreviousAttempt(task.id) : void 0
17430
18128
  });
17431
18129
  this.lingering.close(task.id);
17432
18130
  const env = await this.envForTask(cwd);
@@ -21107,6 +21805,13 @@ var PlanEditError = class extends Error {
21107
21805
  this.name = "PlanEditError";
21108
21806
  }
21109
21807
  };
21808
+ var OPS_RETRY_DIGEST_CALLS = 20;
21809
+ function lastAttemptDigest(location, taskId) {
21810
+ const attempt = listTaskLogAttempts(location, taskId).pop();
21811
+ if (attempt === void 0) return null;
21812
+ const events = readTaskLog(location, taskId, attempt);
21813
+ return events.length > 0 ? digestTaskLog(events, OPS_RETRY_DIGEST_CALLS) : null;
21814
+ }
21110
21815
  function sessionRuntimeSettings(settings) {
21111
21816
  return { ...plannerRuntimeToggles(settings), modelAllowlist: settings.modelAllowlist, runnerTransport: settings.runnerTransport };
21112
21817
  }
@@ -21191,7 +21896,8 @@ function createSession(deps) {
21191
21896
  registry: deps.registry,
21192
21897
  workspaceRoot: deps.workspaceRoot,
21193
21898
  tddEnabled: () => deps.settings().tddEnabled,
21194
- runnerTransport: () => deps.settings().runnerTransport ?? "terminal"
21899
+ runnerTransport: () => deps.settings().runnerTransport ?? "terminal",
21900
+ previousAttemptFromLog: (taskId) => lastAttemptDigest(session.taskLogLocation, taskId)
21195
21901
  });
21196
21902
  const usage = new PlannerUsageLedger();
21197
21903
  const events = new SessionEventRelay({
@@ -24780,6 +25486,7 @@ var PlannerModelMemory = class {
24780
25486
  FsPluginStore,
24781
25487
  GIT_READONLY_SUBCOMMANDS,
24782
25488
  GREP_DEFAULT_HEAD_LIMIT,
25489
+ GREP_PCRE_PROBE_ARGS,
24783
25490
  GeminiService,
24784
25491
  HeadlessRunner,
24785
25492
  HeadlessSession,
@@ -24919,6 +25626,7 @@ var PlannerModelMemory = class {
24919
25626
  dependencyCandidates,
24920
25627
  dependentsOf,
24921
25628
  describeMergeResult,
25629
+ digestTaskLog,
24922
25630
  discoverGeminiModels,
24923
25631
  drainNext,
24924
25632
  effectiveAllowlist,
@@ -24951,6 +25659,7 @@ var PlannerModelMemory = class {
24951
25659
  getStateDir,
24952
25660
  globalDataDir,
24953
25661
  grantScopeFor,
25662
+ grepRegexFromProbe,
24954
25663
  hasHiddenDetail,
24955
25664
  hasTmux,
24956
25665
  holdPrompt,