@ordewell/core 0.4.5 → 0.4.7

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
@@ -329,6 +329,16 @@ function dialectFor(dialect) {
329
329
  const resolved = dialect ?? (process.platform === "win32" ? "cmd" : "posix");
330
330
  return resolved === "cmd" ? CMD_DIALECT : POSIX_DIALECT;
331
331
  }
332
+ function isDevNullTarget(target) {
333
+ if (!target.startsWith("/")) return false;
334
+ const resolved = [];
335
+ for (const seg of target.split("/")) {
336
+ if (seg === "" || seg === ".") continue;
337
+ if (seg === "..") resolved.pop();
338
+ else resolved.push(seg);
339
+ }
340
+ return `/${resolved.join("/")}` === "/dev/null";
341
+ }
332
342
  function matchParen(s, open) {
333
343
  let depth = 0;
334
344
  for (let i = open; i < s.length; i++) {
@@ -339,7 +349,7 @@ function matchParen(s, open) {
339
349
  }
340
350
  function lex(command, nested, dialect) {
341
351
  const segments = [];
342
- let redirect = false;
352
+ let unsafeRedirect;
343
353
  let processSubstitution = false;
344
354
  let unbalanced = false;
345
355
  let tokens = [];
@@ -348,7 +358,19 @@ function lex(command, nested, dialect) {
348
358
  let started = false;
349
359
  let piped = false;
350
360
  let quote = "";
351
- const endToken = () => {
361
+ let redirectTargetMode = false;
362
+ let redirectOperator = "";
363
+ const endToken = (hardBoundary = true) => {
364
+ if (redirectTargetMode) {
365
+ if (!started && !hardBoundary) return;
366
+ if (!unsafeRedirect && !isDevNullTarget(current)) {
367
+ unsafeRedirect = { operator: redirectOperator, target: current };
368
+ }
369
+ redirectTargetMode = false;
370
+ current = "";
371
+ started = false;
372
+ return;
373
+ }
352
374
  if (started) tokens.push(current);
353
375
  current = "";
354
376
  started = false;
@@ -436,9 +458,34 @@ function lex(command, nested, dialect) {
436
458
  i += 2;
437
459
  continue;
438
460
  }
439
- if (c === ">") redirect = true;
461
+ let fd = "";
462
+ if (started && /^[0-9]+$/.test(current)) {
463
+ fd = current;
464
+ current = "";
465
+ started = false;
466
+ } else {
467
+ endToken();
468
+ }
469
+ const doubled = command[i + 1] === c;
470
+ const opLen = doubled ? 2 : 1;
471
+ if (c === ">") {
472
+ const dup = /^&([0-9]+)(?![0-9])/.exec(command.slice(i + opLen));
473
+ if (dup) {
474
+ i += opLen + dup[0].length;
475
+ continue;
476
+ }
477
+ redirectTargetMode = true;
478
+ redirectOperator = fd + c.repeat(opLen);
479
+ }
480
+ i += opLen;
481
+ continue;
482
+ }
483
+ if (c === "&" && command[i + 1] === ">") {
440
484
  endToken();
441
- i += command[i + 1] === c ? 2 : 1;
485
+ const doubled = command[i + 2] === ">";
486
+ redirectTargetMode = true;
487
+ redirectOperator = doubled ? "&>>" : "&>";
488
+ i += doubled ? 3 : 2;
442
489
  continue;
443
490
  }
444
491
  if (c === "(" || c === ")") {
@@ -458,7 +505,7 @@ function lex(command, nested, dialect) {
458
505
  continue;
459
506
  }
460
507
  if (/\s/.test(c)) {
461
- endToken();
508
+ endToken(false);
462
509
  i++;
463
510
  continue;
464
511
  }
@@ -468,7 +515,7 @@ function lex(command, nested, dialect) {
468
515
  }
469
516
  if (quote !== "") unbalanced = true;
470
517
  endSegment(false);
471
- return { segments, redirect, processSubstitution, unbalanced };
518
+ return { segments, unsafeRedirect, processSubstitution, unbalanced };
472
519
  }
473
520
  function binaryName(token, dialect) {
474
521
  const base = token.split(/[/\\]/).pop() ?? "";
@@ -488,7 +535,7 @@ function lexAll(command, dialect) {
488
535
  for (let depth = 0; depth < 32 && queue.length > 0; depth++) {
489
536
  const inner = lex(queue.shift(), queue, dialect);
490
537
  all.segments.push(...inner.segments.filter((s) => s.binary));
491
- all.redirect ||= inner.redirect;
538
+ all.unsafeRedirect ??= inner.unsafeRedirect;
492
539
  all.processSubstitution ||= inner.processSubstitution;
493
540
  all.unbalanced ||= inner.unbalanced;
494
541
  }
@@ -537,8 +584,9 @@ function refusalFor(seg) {
537
584
  if (seg.binary === "git") {
538
585
  const sub = seg.args.find((a) => !a.startsWith("-"));
539
586
  if (sub === "branch" || sub === "tag") {
540
- if (seg.args.some((a) => ["-D", "-d", "-m", "-M", "--delete", "--move"].includes(a))) {
541
- return `"git ${sub}" with a delete/move flag mutates refs. You are a read-only planner \u2014 describe the change as a task instead.`;
587
+ const flag = seg.args.find((a) => ["-D", "-d", "-m", "-M", "--delete", "--move"].includes(a));
588
+ if (flag) {
589
+ return `"git ${sub} ${flag}" mutates refs. You are a read-only planner \u2014 describe the change as a task instead.`;
542
590
  }
543
591
  }
544
592
  }
@@ -548,8 +596,9 @@ function refusalFor(seg) {
548
596
  return `find with "${flag}" runs an inner command this classifier cannot inspect. Use the read-only research tools on find's output, or describe the change as a task.`;
549
597
  }
550
598
  }
551
- if (seg.binary === "sed" && seg.args.some((a) => a === "-i" || a === "--in-place" || /^-i./.test(a))) {
552
- return `"sed -i" edits files in place. You are a read-only planner \u2014 describe the change as a task instead.`;
599
+ const sedInPlace = seg.binary === "sed" ? seg.args.find((a) => a === "-i" || a === "--in-place" || /^-i./.test(a)) : void 0;
600
+ if (sedInPlace) {
601
+ return `"sed ${sedInPlace}" edits files in place. You are a read-only planner \u2014 describe the change as a task instead.`;
553
602
  }
554
603
  if (seg.binary === "awk" && seg.args.some((a) => a === "inplace")) {
555
604
  return `"awk -i inplace" edits files in place. You are a read-only planner \u2014 describe the change as a task instead.`;
@@ -568,7 +617,7 @@ function classifyCommand(command, opts = {}) {
568
617
  const trimmed = command.trim();
569
618
  if (!trimmed) return { tier: "refuse", scope: "", reason: "Empty command." };
570
619
  const dialect = dialectFor(opts.dialect);
571
- const { segments, redirect, processSubstitution, unbalanced } = lexAll(trimmed, dialect);
620
+ const { segments, unsafeRedirect, processSubstitution, unbalanced } = lexAll(trimmed, dialect);
572
621
  if (unbalanced) {
573
622
  return {
574
623
  tier: "refuse",
@@ -583,11 +632,12 @@ function classifyCommand(command, opts = {}) {
583
632
  reason: "Process substitution runs another command this classifier cannot inspect. Run the command on its own and read its output, or describe it as a task."
584
633
  };
585
634
  }
586
- if (redirect) {
635
+ if (unsafeRedirect) {
636
+ const written = unsafeRedirect.target ? `"${unsafeRedirect.operator} ${unsafeRedirect.target}"` : `"${unsafeRedirect.operator}"`;
587
637
  return {
588
638
  tier: "refuse",
589
639
  scope: "",
590
- reason: "Output redirection writes files. You are a read-only planner \u2014 read the output instead, or describe the write as a task."
640
+ reason: `${written} writes to a file. You are a read-only planner \u2014 read the output instead, or describe the write as a task.`
591
641
  };
592
642
  }
593
643
  if (segments.length === 0) return { tier: "refuse", scope: "", reason: "No command found." };
@@ -907,7 +957,7 @@ ${refs.output.trim()}`);
907
957
  * anything else asks once and is remembered, and writes are refused outright
908
958
  * because a planner that mutates the workspace has stopped being a planner.
909
959
  */
910
- async bash(command) {
960
+ async bash(command, signal) {
911
961
  const { tier, scope, reason } = classifyCommand(command, { dialect: this.researchShell.dialect });
912
962
  if (tier === "refuse") {
913
963
  return { success: false, output: `Command refused: ${reason}`, truncated: false };
@@ -930,7 +980,7 @@ It is outside the auto-allowed read-only set (${scope}). Continue with the read-
930
980
  };
931
981
  }
932
982
  }
933
- const outcome = await this.execBashImpl(command);
983
+ const outcome = await this.execBashImpl(command, signal);
934
984
  if (outcome.success) return outcome;
935
985
  const warning = researchShellWarning(this.researchShell);
936
986
  return warning ? { ...outcome, output: `${outcome.output}
@@ -939,6 +989,82 @@ It is outside the auto-allowed read-only set (${scope}). Continue with the read-
939
989
  }
940
990
  };
941
991
 
992
+ // src/services/executeTool.ts
993
+ var GREP_OUTPUT_MODES = ["content", "files", "count"];
994
+ function num(value) {
995
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
996
+ }
997
+ function bool(value) {
998
+ return typeof value === "boolean" ? value : void 0;
999
+ }
1000
+ function str(value) {
1001
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1002
+ }
1003
+ function grepOptions(args) {
1004
+ const mode = str(args.output_mode ?? args.outputMode);
1005
+ return {
1006
+ include: str(args.include),
1007
+ path: str(args.path),
1008
+ outputMode: GREP_OUTPUT_MODES.includes(mode) ? mode : void 0,
1009
+ contextBefore: num(args.context_before ?? args.contextBefore),
1010
+ contextAfter: num(args.context_after ?? args.contextAfter),
1011
+ literal: bool(args.literal),
1012
+ caseInsensitive: bool(args.case_insensitive ?? args.caseInsensitive),
1013
+ headLimit: num(args.head_limit ?? args.headLimit)
1014
+ };
1015
+ }
1016
+ var STOPPED_TOOL_RESULT = "Stopped by the user \u2014 this call was not executed, and no further tool calls will run this turn.";
1017
+ async function executeTool(name, args, fs10, fetcher, signal) {
1018
+ if (signal?.aborted) return { success: false, output: STOPPED_TOOL_RESULT, truncated: false };
1019
+ switch (name) {
1020
+ case "read_file": {
1021
+ const offset = num(args.offset);
1022
+ const limit = num(args.limit);
1023
+ const maxBytes = num(args.maxBytes);
1024
+ return fs10.readFile(String(args.path ?? ""), { offset, limit, maxBytes });
1025
+ }
1026
+ case "read_files": {
1027
+ const paths = Array.isArray(args.paths) ? args.paths.map(String) : [];
1028
+ return fs10.readFiles(paths);
1029
+ }
1030
+ case "glob":
1031
+ return fs10.glob(String(args.pattern ?? ""), { path: str(args.path), headLimit: num(args.head_limit ?? args.headLimit) });
1032
+ case "grep":
1033
+ return fs10.grep(String(args.pattern ?? ""), grepOptions(args));
1034
+ case "find_symbol":
1035
+ return fs10.findSymbol(String(args.symbol ?? ""), { language: str(args.language), path: str(args.path) });
1036
+ case "list_dir":
1037
+ return fs10.listDir(String(args.path ?? ""), num(args.depth));
1038
+ case "bash":
1039
+ return fs10.bash(String(args.command ?? ""), signal);
1040
+ case "fetch": {
1041
+ if (!fetcher) return { success: false, output: "Web fetching is not available in this environment.", truncated: false };
1042
+ const url = String(args.url ?? "");
1043
+ const allowed = await fetcher.confirm(url);
1044
+ if (!allowed) return { success: false, output: `URL fetch denied by user: ${url}`, truncated: false };
1045
+ return fetcher.fetch(url);
1046
+ }
1047
+ case "web_search": {
1048
+ if (!fetcher?.search) {
1049
+ return { success: false, output: "Web search is not available in this environment. If you already know a documentation URL, use the fetch tool instead.", truncated: false };
1050
+ }
1051
+ const query = String(args.query ?? "").trim();
1052
+ if (!query) return { success: false, output: 'web_search requires a non-empty "query".', truncated: false };
1053
+ return fetcher.search(query);
1054
+ }
1055
+ // Budget models hallucinate two kinds of tools: plan-committing ones
1056
+ // (create_task, submit_plan) and implementation ones (create_file,
1057
+ // write_file). Steer both back: you are a planner, tasks run later,
1058
+ // and the commit channel is raw JSON in the reply text.
1059
+ default:
1060
+ return {
1061
+ success: false,
1062
+ output: `Unknown tool: ${name}. You are a PLANNER \u2014 you never create or edit files yourself; the coding agents executing the plan will do that later. Your tools are read-only research tools: read_file, read_files, glob, grep, find_symbol, list_dir, bash, fetch, web_search. To commit the final plan, output it as a raw JSON object with a "tasks" array directly in your reply text (no tool call).`,
1063
+ truncated: false
1064
+ };
1065
+ }
1066
+ }
1067
+
942
1068
  // src/services/ApprovalPolicy.ts
943
1069
  function scopeMatches(pattern, scope) {
944
1070
  if (pattern === scope) return true;
@@ -2959,8 +3085,8 @@ function savePrdMarkdown(workspace, slug2, markdown) {
2959
3085
 
2960
3086
  // src/services/ContextCollector.ts
2961
3087
  var ORDEWELL_CONTEXT_MAX = 8e3;
2962
- async function collectResearchContext(fs9, runners) {
2963
- const collector = new ContextCollector(fs9);
3088
+ async function collectResearchContext(fs10, runners) {
3089
+ const collector = new ContextCollector(fs10);
2964
3090
  let contextStr = "";
2965
3091
  let firstContext = null;
2966
3092
  for (const r of runners) {
@@ -2987,8 +3113,8 @@ ${firstContext.dirStructure}
2987
3113
  return contextStr;
2988
3114
  }
2989
3115
  var ContextCollector = class {
2990
- constructor(fs9, registry) {
2991
- this.fs = fs9;
3116
+ constructor(fs10, registry) {
3117
+ this.fs = fs10;
2992
3118
  this.registry = registry;
2993
3119
  }
2994
3120
  fs;
@@ -3024,80 +3150,6 @@ var ContextCollector = class {
3024
3150
  }
3025
3151
  };
3026
3152
 
3027
- // src/services/executeTool.ts
3028
- var GREP_OUTPUT_MODES = ["content", "files", "count"];
3029
- function num(value) {
3030
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
3031
- }
3032
- function bool(value) {
3033
- return typeof value === "boolean" ? value : void 0;
3034
- }
3035
- function str(value) {
3036
- return typeof value === "string" && value.length > 0 ? value : void 0;
3037
- }
3038
- function grepOptions(args) {
3039
- const mode = str(args.output_mode ?? args.outputMode);
3040
- return {
3041
- include: str(args.include),
3042
- path: str(args.path),
3043
- outputMode: GREP_OUTPUT_MODES.includes(mode) ? mode : void 0,
3044
- contextBefore: num(args.context_before ?? args.contextBefore),
3045
- contextAfter: num(args.context_after ?? args.contextAfter),
3046
- literal: bool(args.literal),
3047
- caseInsensitive: bool(args.case_insensitive ?? args.caseInsensitive),
3048
- headLimit: num(args.head_limit ?? args.headLimit)
3049
- };
3050
- }
3051
- async function executeTool(name, args, fs9, fetcher) {
3052
- switch (name) {
3053
- case "read_file": {
3054
- const offset = num(args.offset);
3055
- const limit = num(args.limit);
3056
- const maxBytes = num(args.maxBytes);
3057
- return fs9.readFile(String(args.path ?? ""), { offset, limit, maxBytes });
3058
- }
3059
- case "read_files": {
3060
- const paths = Array.isArray(args.paths) ? args.paths.map(String) : [];
3061
- return fs9.readFiles(paths);
3062
- }
3063
- case "glob":
3064
- return fs9.glob(String(args.pattern ?? ""), { path: str(args.path), headLimit: num(args.head_limit ?? args.headLimit) });
3065
- case "grep":
3066
- return fs9.grep(String(args.pattern ?? ""), grepOptions(args));
3067
- case "find_symbol":
3068
- return fs9.findSymbol(String(args.symbol ?? ""), { language: str(args.language), path: str(args.path) });
3069
- case "list_dir":
3070
- return fs9.listDir(String(args.path ?? ""), num(args.depth));
3071
- case "bash":
3072
- return fs9.bash(String(args.command ?? ""));
3073
- case "fetch": {
3074
- if (!fetcher) return { success: false, output: "Web fetching is not available in this environment.", truncated: false };
3075
- const url = String(args.url ?? "");
3076
- const allowed = await fetcher.confirm(url);
3077
- if (!allowed) return { success: false, output: `URL fetch denied by user: ${url}`, truncated: false };
3078
- return fetcher.fetch(url);
3079
- }
3080
- case "web_search": {
3081
- if (!fetcher?.search) {
3082
- return { success: false, output: "Web search is not available in this environment. If you already know a documentation URL, use the fetch tool instead.", truncated: false };
3083
- }
3084
- const query = String(args.query ?? "").trim();
3085
- if (!query) return { success: false, output: 'web_search requires a non-empty "query".', truncated: false };
3086
- return fetcher.search(query);
3087
- }
3088
- // Budget models hallucinate two kinds of tools: plan-committing ones
3089
- // (create_task, submit_plan) and implementation ones (create_file,
3090
- // write_file). Steer both back: you are a planner, tasks run later,
3091
- // and the commit channel is raw JSON in the reply text.
3092
- default:
3093
- return {
3094
- success: false,
3095
- output: `Unknown tool: ${name}. You are a PLANNER \u2014 you never create or edit files yourself; the coding agents executing the plan will do that later. Your tools are read-only research tools: read_file, read_files, glob, grep, find_symbol, list_dir, bash, fetch, web_search. To commit the final plan, output it as a raw JSON object with a "tasks" array directly in your reply text (no tool call).`,
3096
- truncated: false
3097
- };
3098
- }
3099
- }
3100
-
3101
3153
  // src/services/ResearchSubagents.ts
3102
3154
  var SUBAGENT_LIMITS = {
3103
3155
  /** Max spawn calls in flight at once; extra calls in the same turn wait for a slot. */
@@ -3110,23 +3162,23 @@ var ALLOWED_TOOL_NAMES = new Set(subagentToolSpecs().map((t) => t.name));
3110
3162
  function refused(output) {
3111
3163
  return { success: false, output, truncated: false };
3112
3164
  }
3113
- function nonPromptingFs(fs9) {
3114
- const withinWorkspace = (p) => resolveWithin(fs9.getWorkspaceRoot(), p ?? ".").inside;
3165
+ function nonPromptingFs(fs10) {
3166
+ const withinWorkspace = (p) => resolveWithin(fs10.getWorkspaceRoot(), p ?? ".").inside;
3115
3167
  const outsideRefusal = (p) => refused(
3116
3168
  `Path "${p}" is outside the workspace. Research agents are confined to the workspace root \u2014 note the gap in your digest and let the planner request it directly.`
3117
3169
  );
3118
3170
  return {
3119
- getWorkspaceRoot: () => fs9.getWorkspaceRoot(),
3120
- readFile: (p, opts) => withinWorkspace(p) ? fs9.readFile(p, opts) : Promise.resolve(outsideRefusal(p)),
3171
+ getWorkspaceRoot: () => fs10.getWorkspaceRoot(),
3172
+ readFile: (p, opts) => withinWorkspace(p) ? fs10.readFile(p, opts) : Promise.resolve(outsideRefusal(p)),
3121
3173
  readFiles: (paths) => {
3122
3174
  const escaping = paths.find((p) => !withinWorkspace(p));
3123
- return escaping ? Promise.resolve(outsideRefusal(escaping)) : fs9.readFiles(paths);
3175
+ return escaping ? Promise.resolve(outsideRefusal(escaping)) : fs10.readFiles(paths);
3124
3176
  },
3125
- glob: (pattern, opts) => withinWorkspace(opts?.path) ? fs9.glob(pattern, opts) : Promise.resolve(outsideRefusal(opts.path)),
3126
- grep: (pattern, opts) => withinWorkspace(opts?.path) ? fs9.grep(pattern, opts) : Promise.resolve(outsideRefusal(opts.path)),
3127
- findSymbol: (symbol, opts) => withinWorkspace(opts?.path) ? fs9.findSymbol(symbol, opts) : Promise.resolve(outsideRefusal(opts.path)),
3128
- listDir: (p, depth) => withinWorkspace(p) ? fs9.listDir(p, depth) : Promise.resolve(outsideRefusal(p)),
3129
- bash: async (command) => {
3177
+ glob: (pattern, opts) => withinWorkspace(opts?.path) ? fs10.glob(pattern, opts) : Promise.resolve(outsideRefusal(opts.path)),
3178
+ grep: (pattern, opts) => withinWorkspace(opts?.path) ? fs10.grep(pattern, opts) : Promise.resolve(outsideRefusal(opts.path)),
3179
+ findSymbol: (symbol, opts) => withinWorkspace(opts?.path) ? fs10.findSymbol(symbol, opts) : Promise.resolve(outsideRefusal(opts.path)),
3180
+ listDir: (p, depth) => withinWorkspace(p) ? fs10.listDir(p, depth) : Promise.resolve(outsideRefusal(p)),
3181
+ bash: async (command, signal) => {
3130
3182
  const dialect = resolveResearchShell().dialect;
3131
3183
  const { tier, reason } = classifyCommand(command, { dialect });
3132
3184
  if (tier !== "auto") {
@@ -3136,13 +3188,13 @@ function nonPromptingFs(fs9) {
3136
3188
  }
3137
3189
  const escaping = pathLikeArgs(command, { dialect }).find((p) => !withinWorkspace(p));
3138
3190
  if (escaping) return outsideRefusal(escaping);
3139
- return fs9.bash(command);
3191
+ return fs10.bash(command, signal);
3140
3192
  }
3141
3193
  };
3142
3194
  }
3143
3195
  async function runLoop(prompt, deps) {
3144
3196
  if (deps.signal?.aborted) return "[research agent aborted before starting]";
3145
- const fs9 = nonPromptingFs(deps.fs);
3197
+ const fs10 = nonPromptingFs(deps.fs);
3146
3198
  const chat = deps.createChat((delta) => deps.onProgress?.({ type: "thinking", text: delta }));
3147
3199
  let turn = await chat.sendMessage(prompt, deps.signal);
3148
3200
  for (let step = 0; turn.hasToolCalls && step < SUBAGENT_LIMITS.maxSteps; step++) {
@@ -3159,7 +3211,7 @@ ${turn.text}`;
3159
3211
  if (tc.name === "read_file" && !("maxBytes" in args) && !("limit" in args)) args.limit = 2e3;
3160
3212
  const toolArgs = JSON.stringify(tc.args);
3161
3213
  deps.onProgress?.({ type: "tool_call", tool: tc.name, toolArgs, toolCallId: tc.id });
3162
- const res = await executeTool(tc.name, args, fs9);
3214
+ const res = await executeTool(tc.name, args, fs10, void 0, deps.signal);
3163
3215
  const output = res.output.length > SUBAGENT_LIMITS.toolOutputMaxChars ? res.output.slice(0, SUBAGENT_LIMITS.toolOutputMaxChars) + `
3164
3216
  [... truncated to ${SUBAGENT_LIMITS.toolOutputMaxChars} chars, total ${res.output.length}]` : res.output;
3165
3217
  const stepEntry = {
@@ -3274,7 +3326,7 @@ var BaseAiService = class _BaseAiService {
3274
3326
  * steering/failed result and the loop continues sequentially: a subagent can
3275
3327
  * never fail a plan or a turn.
3276
3328
  */
3277
- async executeSpawnAgent(args, fs9, onProgress, signal, subagentId) {
3329
+ async executeSpawnAgent(args, fs10, onProgress, signal, subagentId) {
3278
3330
  if (!this.researchSubagentsEnabled) {
3279
3331
  return { success: false, output: "spawn_research_agent is not available in this session. Continue researching sequentially with your other tools (read_file, glob, grep, list_dir, bash).", truncated: false };
3280
3332
  }
@@ -3288,20 +3340,20 @@ var BaseAiService = class _BaseAiService {
3288
3340
  if (!chat) throw new Error("subagent chats are not available for this provider");
3289
3341
  return chat;
3290
3342
  },
3291
- fs: fs9,
3343
+ fs: fs10,
3292
3344
  signal,
3293
3345
  onProgress: (progress) => onProgress({ ...progress, subagentId })
3294
3346
  });
3295
3347
  }
3296
3348
  /** Collect project context for the planning phase. Shared with the harness backend. */
3297
- static async collectResearchContext(fs9, runners) {
3298
- return collectResearchContext(fs9, runners);
3349
+ static async collectResearchContext(fs10, runners) {
3350
+ return collectResearchContext(fs10, runners);
3299
3351
  }
3300
3352
  /**
3301
3353
  * Execute one round of tool calls and report each through onProgress.
3302
3354
  * Returns the results to feed back plus the log entries produced.
3303
3355
  */
3304
- async executeToolCalls(toolCalls, fs9, onProgress, fetcher, stepIndex, thinking, signal) {
3356
+ async executeToolCalls(toolCalls, fs10, onProgress, fetcher, stepIndex, thinking, signal) {
3305
3357
  const toolResults = [];
3306
3358
  const logEntries = [];
3307
3359
  let resultsText = "";
@@ -3320,18 +3372,18 @@ var BaseAiService = class _BaseAiService {
3320
3372
  const subagentId = uuidv4();
3321
3373
  spawnSubagentIds.set(i, subagentId);
3322
3374
  onProgress({ type: "tool_call", tool: toolCalls[i].name, toolArgs: JSON.stringify(toolCalls[i].args), subagentId, toolCallId: toolCalls[i].id });
3323
- precomputed.set(i, await this.executeSpawnAgent({ ...toolCalls[i].args }, fs9, onProgress, signal, subagentId));
3375
+ precomputed.set(i, await this.executeSpawnAgent({ ...toolCalls[i].args }, fs10, onProgress, signal, subagentId));
3324
3376
  }) : Promise.resolve(),
3325
3377
  parallelIndexes.length > 1 ? mapWithConcurrency(parallelIndexes, TOOL_ROUND_CONCURRENCY, async (i) => {
3326
3378
  onProgress({ type: "tool_call", tool: toolCalls[i].name, toolArgs: JSON.stringify(toolCalls[i].args), toolCallId: toolCalls[i].id });
3327
- precomputed.set(i, await executeTool(toolCalls[i].name, normalizedArgs[i], fs9, fetcher));
3379
+ precomputed.set(i, await executeTool(toolCalls[i].name, normalizedArgs[i], fs10, fetcher, signal));
3328
3380
  }) : Promise.resolve()
3329
3381
  ]);
3330
3382
  for (const [index, tc] of toolCalls.entries()) {
3331
3383
  const toolArgs = normalizedArgs[index];
3332
3384
  const preResult = precomputed.get(index);
3333
3385
  if (!preResult) onProgress({ type: "tool_call", tool: tc.name, toolArgs: JSON.stringify(tc.args), toolCallId: tc.id });
3334
- const toolResult = preResult ?? await executeTool(tc.name, toolArgs, fs9, fetcher);
3386
+ const toolResult = preResult ?? await executeTool(tc.name, toolArgs, fs10, fetcher, signal);
3335
3387
  const MAX_RESPONSE_CHARS = 5e4;
3336
3388
  const truncatedResponse = toolResult.success && (toolResult.truncated || toolResult.output.length > MAX_RESPONSE_CHARS);
3337
3389
  const llmOutput = toolResult.output.length > MAX_RESPONSE_CHARS ? toolResult.output.slice(0, MAX_RESPONSE_CHARS) + `
@@ -3517,7 +3569,7 @@ ${llmOutput}
3517
3569
  * parsed tasks if a plan was emitted mid-loop, or null if the loop
3518
3570
  * exhausted without a valid plan.
3519
3571
  */
3520
- async runResearchLoop(chat, firstMessage, fs9, onProgress, runners, initialLog, fetcher, userGoal, runnerModes, autonomousDefault, signal) {
3572
+ async runResearchLoop(chat, firstMessage, fs10, onProgress, runners, initialLog, fetcher, userGoal, runnerModes, autonomousDefault, signal) {
3521
3573
  const researchLog = initialLog ? [...initialLog] : [];
3522
3574
  if (!initialLog && userGoal) {
3523
3575
  researchLog.push({
@@ -3544,7 +3596,7 @@ ${llmOutput}
3544
3596
  if (!turn.hasToolCalls) break;
3545
3597
  const { toolResults, logEntries, resultsText } = await this.executeToolCalls(
3546
3598
  turn.toolCalls,
3547
- fs9,
3599
+ fs10,
3548
3600
  onProgress,
3549
3601
  fetcher,
3550
3602
  step,
@@ -3782,11 +3834,11 @@ ${repairHint}` }] : parts;
3782
3834
  return this.consumePlanStream(result, onToken);
3783
3835
  }
3784
3836
  // --- IAiService implementation ---
3785
- async researchAndPlan(userDescription, runners, modelsByRunner, fs9, onProgress, fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
3837
+ async researchAndPlan(userDescription, runners, modelsByRunner, fs10, onProgress, fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
3786
3838
  this.ensureInit();
3787
3839
  const { autonomousDefault } = modes;
3788
3840
  const genModel = this.getPlanningModel();
3789
- const contextStr = await BaseAiService.collectResearchContext(fs9, runners);
3841
+ const contextStr = await BaseAiService.collectResearchContext(fs10, runners);
3790
3842
  const systemPrompt = buildResearchPrompt(userDescription, contextStr, modelsByRunner, runners, runnerModes, modes);
3791
3843
  const firstMessage = `${buildResearchToolsPrompt()}
3792
3844
 
@@ -3797,17 +3849,17 @@ Explore the workspace to understand the codebase, then generate the plan.`;
3797
3849
  generationConfig: { temperature: 0.3, topP: 0.95, maxOutputTokens: 16384 }
3798
3850
  });
3799
3851
  const researchChat = new GeminiResearchChat(chat);
3800
- const result = await this.runResearchLoop(researchChat, firstMessage, fs9, onProgress, runners, void 0, fetcher, userDescription, runnerModes, autonomousDefault, signal);
3852
+ const result = await this.runResearchLoop(researchChat, firstMessage, fs10, onProgress, runners, void 0, fetcher, userDescription, runnerModes, autonomousDefault, signal);
3801
3853
  if (result.tasks) return { tasks: result.tasks, researchLog: result.researchLog, researchResults: result.researchResults };
3802
3854
  const fallback = await this.generatePlanFallback(userDescription, contextStr, result.researchResults, modelsByRunner, runners, onProgress, result.researchLog, runnerModes, autonomousDefault, signal, modes);
3803
3855
  return { tasks: fallback.tasks, researchLog: fallback.researchLog, researchResults: result.researchResults };
3804
3856
  }
3805
- async generatePlanDirect(userDescription, runners = ["claude-code"], modelsByRunner = {}, onToken, fs9, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, _signal) {
3857
+ async generatePlanDirect(userDescription, runners = ["claude-code"], modelsByRunner = {}, onToken, fs10, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, _signal) {
3806
3858
  this.ensureInit();
3807
3859
  const { autonomousDefault } = modes;
3808
3860
  let ctx = "";
3809
- if (fs9) {
3810
- const collected = await new ContextCollector(fs9).collect(runners[0] ?? "claude-code");
3861
+ if (fs10) {
3862
+ const collected = await new ContextCollector(fs10).collect(runners[0] ?? "claude-code");
3811
3863
  if (collected.aiflowContext) ctx = `
3812
3864
  <aiflow_context>
3813
3865
  ${collected.aiflowContext}
@@ -3823,12 +3875,12 @@ ${collected.aiflowContext}
3823
3875
  autonomousDefault
3824
3876
  );
3825
3877
  }
3826
- async modifyPlan(existingPlan, userRequest, modelsByRunner, onProgress, fs9, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, _signal) {
3878
+ async modifyPlan(existingPlan, userRequest, modelsByRunner, onProgress, fs10, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, _signal) {
3827
3879
  this.ensureInit();
3828
3880
  const { autonomousDefault } = modes;
3829
3881
  let aiflowContext;
3830
- if (fs9) {
3831
- const collected = await new ContextCollector(fs9).collect(existingPlan.runners[0] ?? "claude-code");
3882
+ if (fs10) {
3883
+ const collected = await new ContextCollector(fs10).collect(existingPlan.runners[0] ?? "claude-code");
3832
3884
  if (collected.aiflowContext) aiflowContext = collected.aiflowContext;
3833
3885
  }
3834
3886
  const prompt = buildModifyPlanPrompt(existingPlan, userRequest, modelsByRunner, aiflowContext, runnerModes, autonomousDefault);
@@ -4068,12 +4120,12 @@ ${buildResearchToolsPrompt(this.researchSubagentsEnabled)}` }
4068
4120
  }
4069
4121
  }
4070
4122
  // --- IAiService implementation ---
4071
- async researchAndPlan(userDescription, runners, modelsByRunner, fs9, onProgress, fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
4123
+ async researchAndPlan(userDescription, runners, modelsByRunner, fs10, onProgress, fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
4072
4124
  this.ensureInit();
4073
4125
  const client = this.getClient();
4074
4126
  const { autonomousDefault, researchSubagents: researchSubagentsEnabled } = modes;
4075
4127
  this.researchSubagentsEnabled = researchSubagentsEnabled;
4076
- const contextStr = await BaseAiService.collectResearchContext(fs9, runners);
4128
+ const contextStr = await BaseAiService.collectResearchContext(fs10, runners);
4077
4129
  const systemPrompt = buildResearchPrompt(userDescription, contextStr, modelsByRunner, runners, runnerModes, modes);
4078
4130
  const userText = `User goal: ${userDescription}`;
4079
4131
  const firstMessage = `${buildResearchToolsPrompt(researchSubagentsEnabled)}
@@ -4091,17 +4143,17 @@ ${userText}`;
4091
4143
  toOpenAiTools(researchSubagentsEnabled),
4092
4144
  (delta) => onProgress({ type: "thinking", text: delta })
4093
4145
  );
4094
- const result = await this.runResearchLoop(researchChat, firstMessage, fs9, onProgress, runners, void 0, fetcher, userDescription, runnerModes, autonomousDefault, signal);
4146
+ const result = await this.runResearchLoop(researchChat, firstMessage, fs10, onProgress, runners, void 0, fetcher, userDescription, runnerModes, autonomousDefault, signal);
4095
4147
  if (result.tasks) return { tasks: result.tasks, researchLog: result.researchLog, researchResults: result.researchResults };
4096
4148
  const fallback = await this.generatePlanFallback(userDescription, contextStr, result.researchResults, modelsByRunner, runners, onProgress, result.researchLog, runnerModes, autonomousDefault, signal, modes);
4097
4149
  return { tasks: fallback.tasks, researchLog: fallback.researchLog, researchResults: result.researchResults };
4098
4150
  }
4099
- async generatePlanDirect(userDescription, runners = ["claude-code"], modelsByRunner = {}, onToken, fs9, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
4151
+ async generatePlanDirect(userDescription, runners = ["claude-code"], modelsByRunner = {}, onToken, fs10, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
4100
4152
  this.ensureInit();
4101
4153
  const { autonomousDefault } = modes;
4102
4154
  let ctx = "";
4103
- if (fs9) {
4104
- const collected = await new ContextCollector(fs9).collect(runners[0] ?? "claude-code");
4155
+ if (fs10) {
4156
+ const collected = await new ContextCollector(fs10).collect(runners[0] ?? "claude-code");
4105
4157
  if (collected.aiflowContext) ctx = `
4106
4158
  <aiflow_context>
4107
4159
  ${collected.aiflowContext}
@@ -4117,12 +4169,12 @@ ${collected.aiflowContext}
4117
4169
  autonomousDefault
4118
4170
  );
4119
4171
  }
4120
- async modifyPlan(existingPlan, userRequest, modelsByRunner, onProgress, fs9, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
4172
+ async modifyPlan(existingPlan, userRequest, modelsByRunner, onProgress, fs10, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
4121
4173
  this.ensureInit();
4122
4174
  const { autonomousDefault } = modes;
4123
4175
  let aiflowContext;
4124
- if (fs9) {
4125
- const collected = await new ContextCollector(fs9).collect(existingPlan.runners[0] ?? "claude-code");
4176
+ if (fs10) {
4177
+ const collected = await new ContextCollector(fs10).collect(existingPlan.runners[0] ?? "claude-code");
4126
4178
  if (collected.aiflowContext) aiflowContext = collected.aiflowContext;
4127
4179
  }
4128
4180
  const prompt = buildModifyPlanPrompt(existingPlan, userRequest, modelsByRunner, aiflowContext, runnerModes, autonomousDefault);
@@ -4740,6 +4792,7 @@ var PlanStore = class {
4740
4792
  }
4741
4793
  remove(taskId) {
4742
4794
  if (!this._taskMap.has(taskId)) return;
4795
+ this.unblockDependents(taskId);
4743
4796
  this._planTasks = removeTaskFromPlan(this._planTasks, taskId);
4744
4797
  this.rebuild();
4745
4798
  this.pruneTerminalSets();
@@ -4794,9 +4847,7 @@ ${taskB.description}`,
4794
4847
  const userIds = /* @__PURE__ */ new Set([taskIdA, taskIdB]);
4795
4848
  const sanitized = this._planTasks.filter((t) => !userIds.has(t.id)).map((t) => ({
4796
4849
  ...t,
4797
- dependencies: t.dependencies.map(
4798
- (depId) => depId === taskIdA || depId === taskIdB ? mergedId : depId
4799
- )
4850
+ dependencies: [...new Set(t.dependencies.map((depId) => userIds.has(depId) ? mergedId : depId))]
4800
4851
  }));
4801
4852
  this._planTasks = renumberTasks([...sanitized, merged]);
4802
4853
  this.rebuild();
@@ -4808,11 +4859,12 @@ ${taskB.description}`,
4808
4859
  const original = this._taskMap.get(taskId);
4809
4860
  if (!original) throw new Error(`Task ${taskId} not found`);
4810
4861
  if (!newTaskSpecs.length) throw new Error("Must provide at least one new task spec");
4811
- const newTasks = newTaskSpecs.map(
4812
- (spec, i) => createTask({
4862
+ const finalized = [];
4863
+ newTaskSpecs.forEach((spec, i) => {
4864
+ finalized.push(createTask({
4813
4865
  ...spec,
4814
4866
  order: original.order + i,
4815
- dependencies: i === 0 ? [...original.dependencies] : [newTaskSpecs[0].id ?? `split-${taskId}-0`],
4867
+ dependencies: i === 0 ? [...original.dependencies] : [finalized[i - 1].id],
4816
4868
  type: spec.type ?? original.type,
4817
4869
  assignedRunner: spec.assignedRunner ?? original.assignedRunner,
4818
4870
  assignedModel: spec.assignedModel ?? original.assignedModel,
@@ -4820,17 +4872,7 @@ ${taskB.description}`,
4820
4872
  autonomy: spec.autonomy ?? original.autonomy,
4821
4873
  sliceType: spec.sliceType ?? original.sliceType,
4822
4874
  userStoriesCovered: spec.userStoriesCovered ?? original.userStoriesCovered
4823
- })
4824
- );
4825
- const newTaskIds = /* @__PURE__ */ new Map();
4826
- newTasks.forEach((t, i) => newTaskIds.set(i, t.id));
4827
- const finalized = newTasks.map((t) => {
4828
- const deps = t.dependencies.map((depId) => {
4829
- const idx = newTaskSpecs.findIndex((s) => (s.id ?? "") === depId);
4830
- if (idx >= 0) return newTaskIds.get(idx) ?? depId;
4831
- return depId;
4832
- });
4833
- return { ...t, dependencies: deps };
4875
+ }));
4834
4876
  });
4835
4877
  const tailId = finalized[finalized.length - 1].id;
4836
4878
  const updatedOthers = this._planTasks.filter((t) => t.id !== taskId).map((t) => ({
@@ -5075,6 +5117,15 @@ var TaskOrchestrator = class {
5075
5117
  get isRunning() {
5076
5118
  return this.running || this.startingTaskIds.size > 0 || this.activeTaskSessions.size > 0;
5077
5119
  }
5120
+ /**
5121
+ * A runner is executing *right now*. Narrower than {@link isRunning}, which
5122
+ * also covers the armed-but-idle scheduler `tick()` deliberately leaves
5123
+ * behind when a plan is paused on a user task, a checkpoint or a hold —
5124
+ * nothing is executing then, so nothing is reading the plan mid-mutation.
5125
+ */
5126
+ get hasLiveWork() {
5127
+ return this.activeTaskSessions.size > 0 || this.startingTaskIds.size > 0;
5128
+ }
5078
5129
  get isReviewApproved() {
5079
5130
  return this.reviewApproved;
5080
5131
  }
@@ -5121,18 +5172,31 @@ var TaskOrchestrator = class {
5121
5172
  this.retryCounts.clear();
5122
5173
  this.onHold.clear();
5123
5174
  }
5175
+ /**
5176
+ * Adopt an edited plan without letting go of the run in progress. Everything
5177
+ * the scheduler owns — live sessions, holds, retry counts, the verifier and
5178
+ * the review approval — survives, because `loadPlan` clears all of it and a
5179
+ * mid-run edit is not a new run. Only the tasks change.
5180
+ *
5181
+ * The ids with live sessions are defended: one dropped from the edited plan is
5182
+ * carried over (its verdict still has to land somewhere), and one whose status
5183
+ * the snapshot predates is put back to `in_progress` — adopting the snapshot's
5184
+ * "pending" would offer the scheduler work a runner is already doing.
5185
+ */
5124
5186
  reconcilePlan(newTasks, planRunners = ["claude-code"]) {
5187
+ const adopted = [...newTasks];
5125
5188
  for (const taskId of this.activeTaskSessions.keys()) {
5126
5189
  const task = this.store.get(taskId);
5127
5190
  if (!task) continue;
5128
- const inNewPlan = newTasks.find((t) => t.id === taskId);
5129
- if (!inNewPlan) {
5130
- newTasks.push({ ...task });
5131
- } else if (inNewPlan.status !== "in_progress") {
5132
- console.warn(`[TaskOrchestrator] Running task "${task.title}" status changed to "${inNewPlan.status}" in modified plan, using orchestrator truth`);
5191
+ const at = adopted.findIndex((t) => t.id === taskId);
5192
+ if (at < 0) {
5193
+ adopted.push({ ...task });
5194
+ } else if (adopted[at].status !== "in_progress") {
5195
+ console.warn(`[TaskOrchestrator] Running task "${task.title}" status changed to "${adopted[at].status}" in modified plan, using orchestrator truth`);
5196
+ adopted[at] = { ...adopted[at], status: "in_progress" };
5133
5197
  }
5134
5198
  }
5135
- this.store.load(newTasks, planRunners);
5199
+ this.store.load(adopted, planRunners);
5136
5200
  this.planStatus = "running";
5137
5201
  }
5138
5202
  async start() {
@@ -5253,6 +5317,21 @@ ${output.slice(-3e3)}`);
5253
5317
  this.emit("onTaskChanged");
5254
5318
  await this.tick();
5255
5319
  }
5320
+ /**
5321
+ * Let go of a task that is leaving the plan. A live runner is cancelled
5322
+ * through {@link cancelTask} so the verifier generation is bumped before the
5323
+ * process dies; a spawn still in flight is dropped from `startingTaskIds`,
5324
+ * which is what makes {@link startTask} kill the session it is about to
5325
+ * receive. The id's remaining bookkeeping goes too — a hold or retry count
5326
+ * kept for a task that no longer exists would be inherited by nothing and
5327
+ * leaves `isRunning` reading true forever.
5328
+ */
5329
+ async releaseTask(taskId) {
5330
+ this.startingTaskIds.delete(taskId);
5331
+ if (this.activeTaskSessions.has(taskId)) await this.cancelTask(taskId);
5332
+ this.onHold.delete(taskId);
5333
+ this.retryCounts.delete(taskId);
5334
+ }
5256
5335
  async markTaskComplete(taskId) {
5257
5336
  const task = this.store.get(taskId);
5258
5337
  if (!task || task.status === "completed") return;
@@ -5712,28 +5791,28 @@ function posixBinDirs() {
5712
5791
  ];
5713
5792
  }
5714
5793
  function windowsBinDirs(env, home) {
5715
- const join13 = (...parts) => path5.win32.join(...parts);
5716
- const under = (base, ...parts) => base ? [join13(base, ...parts)] : [];
5794
+ const join14 = (...parts) => path5.win32.join(...parts);
5795
+ const under = (base, ...parts) => base ? [join14(base, ...parts)] : [];
5717
5796
  const appData = env.APPDATA;
5718
5797
  const localAppData = env.LOCALAPPDATA;
5719
5798
  const programData = env.ProgramData;
5720
5799
  return [
5721
5800
  // Native per-user installers, PowerShell-driven and otherwise.
5722
- join13(home, ".local", "bin"),
5723
- join13(home, ".opencode", "bin"),
5724
- join13(home, ".claude", "bin"),
5725
- join13(home, ".codex", "bin"),
5801
+ join14(home, ".local", "bin"),
5802
+ join14(home, ".opencode", "bin"),
5803
+ join14(home, ".claude", "bin"),
5804
+ join14(home, ".codex", "bin"),
5726
5805
  ...under(localAppData, "Programs", "claude"),
5727
5806
  // Node package managers. npm's global prefix is `%APPDATA%\npm`; the
5728
5807
  // literal spelling is kept as a fallback for a host that does not export
5729
5808
  // APPDATA (a service account, a stripped CI container).
5730
5809
  ...under(appData, "npm"),
5731
- join13(home, "AppData", "Roaming", "npm"),
5810
+ join14(home, "AppData", "Roaming", "npm"),
5732
5811
  ...under(localAppData, "pnpm"),
5733
5812
  ...under(localAppData, "Yarn", "bin"),
5734
- join13(home, ".bun", "bin"),
5813
+ join14(home, ".bun", "bin"),
5735
5814
  // Windows package managers.
5736
- join13(home, "scoop", "shims"),
5815
+ join14(home, "scoop", "shims"),
5737
5816
  ...under(env.SCOOP, "shims"),
5738
5817
  ...under(env.SCOOP_GLOBAL ?? programData, "scoop", "shims"),
5739
5818
  ...under(programData, "chocolatey", "bin"),
@@ -5743,7 +5822,7 @@ function windowsBinDirs(env, home) {
5743
5822
  ...under(localAppData, "Microsoft", "WindowsApps"),
5744
5823
  // Version managers. Volta's Windows home is not the POSIX `~/.volta`.
5745
5824
  ...under(localAppData, "Volta", "bin"),
5746
- join13(home, ".volta", "bin")
5825
+ join14(home, ".volta", "bin")
5747
5826
  ];
5748
5827
  }
5749
5828
  function wellKnownBinDirs(deps = {}) {
@@ -5866,6 +5945,26 @@ Install the native executable \u2014 Claude Code's Windows installer, Codex's re
5866
5945
  function hasLineBreak(parts) {
5867
5946
  return parts.some((part) => /[\r\n]/.test(part));
5868
5947
  }
5948
+ var ExecutableNotFoundError = class extends Error {
5949
+ constructor(command, searchedPath) {
5950
+ super(`Could not find "${command}" on PATH.
5951
+
5952
+ Searched: ${searchedPath || "(empty PATH)"}`);
5953
+ this.command = command;
5954
+ this.searchedPath = searchedPath;
5955
+ this.name = "ExecutableNotFoundError";
5956
+ }
5957
+ command;
5958
+ searchedPath;
5959
+ };
5960
+ function isResolvableOnPosixPath(command, PATH, exists) {
5961
+ if (command.includes("/")) return exists(command);
5962
+ return PATH.split(path6.delimiter).map((d) => d.trim()).filter(Boolean).some((dir) => exists(path6.join(dir, command)));
5963
+ }
5964
+ function isExecutableResolved(command, plan, PATH, deps = {}) {
5965
+ if ((deps.platform ?? process.platform) === "win32") return plan.file !== command;
5966
+ return isResolvableOnPosixPath(command, PATH, deps.exists ?? defaultExists2);
5967
+ }
5869
5968
  function defaultExists2(candidate) {
5870
5969
  try {
5871
5970
  return fsSync2.statSync(candidate).isFile();
@@ -6625,6 +6724,10 @@ var ModelDiscovery = class {
6625
6724
  clear() {
6626
6725
  this.cache.clear();
6627
6726
  }
6727
+ /** Whatever `discover()` has already cached for `runner` — never triggers discovery. */
6728
+ getCached(runner) {
6729
+ return this.cache.get(runner);
6730
+ }
6628
6731
  };
6629
6732
 
6630
6733
  // src/services/ModelCatalog.ts
@@ -6943,6 +7046,17 @@ var ModelResolver = class {
6943
7046
  refreshRunnerModels() {
6944
7047
  this.discovery.clear();
6945
7048
  }
7049
+ /**
7050
+ * Synchronous reads of whatever the last `modelsForRunners`/`pickerOptions`
7051
+ * call already cached — never trigger discovery or a fetch. For callers
7052
+ * (e.g. a settings write) that need a catalog without going async.
7053
+ */
7054
+ getCachedRunnerModels(runner) {
7055
+ return this.discovery.getCached(runner) ?? [];
7056
+ }
7057
+ getCachedPickerOptions() {
7058
+ return this.cachedPickerOptions ?? [];
7059
+ }
6946
7060
  invalidate() {
6947
7061
  this.discovery.clear();
6948
7062
  this.cachedPickerOptions = null;
@@ -7070,6 +7184,28 @@ var RunnerInstallation = class {
7070
7184
  // src/services/harness/CliAgentAiService.ts
7071
7185
  import { spawn as nodeSpawn } from "child_process";
7072
7186
 
7187
+ // src/utils/workspace.ts
7188
+ import * as fs3 from "fs";
7189
+ var WorkspaceNotFoundError = class extends Error {
7190
+ constructor(workspace) {
7191
+ super(`Workspace "${workspace}" does not exist.`);
7192
+ this.workspace = workspace;
7193
+ this.name = "WorkspaceNotFoundError";
7194
+ }
7195
+ workspace;
7196
+ };
7197
+ function defaultIsDirectory(candidate) {
7198
+ try {
7199
+ return fs3.statSync(candidate).isDirectory();
7200
+ } catch {
7201
+ return false;
7202
+ }
7203
+ }
7204
+ function assertWorkspaceExists(workspace, deps = {}) {
7205
+ const isDirectory = deps.isDirectory ?? defaultIsDirectory;
7206
+ if (!isDirectory(workspace)) throw new WorkspaceNotFoundError(workspace);
7207
+ }
7208
+
7073
7209
  // src/services/harness/AgentAdapter.ts
7074
7210
  var LineBuffer = class {
7075
7211
  buffer = "";
@@ -7123,6 +7259,7 @@ var StdioAgentAdapter = class {
7123
7259
  async handshake(_opts) {
7124
7260
  }
7125
7261
  async start(opts) {
7262
+ assertWorkspaceExists(opts.cwd, { isDirectory: this.deps.isDirectory });
7126
7263
  this.processEnded = new Promise((resolve3) => {
7127
7264
  this.markEnded = resolve3;
7128
7265
  });
@@ -7134,6 +7271,9 @@ var StdioAgentAdapter = class {
7134
7271
  platform: this.deps.platform,
7135
7272
  resolvePath
7136
7273
  });
7274
+ if (!isExecutableResolved(spec.command, launch, PATH, { platform: this.deps.platform, exists: this.deps.exists })) {
7275
+ throw new ExecutableNotFoundError(spec.command, PATH);
7276
+ }
7137
7277
  this.process = this.deps.spawn(launch.file, launch.args, {
7138
7278
  env: this.spawnEnv,
7139
7279
  stdio: ["pipe", "pipe", "pipe"],
@@ -7756,12 +7896,16 @@ var OpenCodeAdapter = class {
7756
7896
  opts = null;
7757
7897
  async start(opts) {
7758
7898
  this.opts = opts;
7899
+ assertWorkspaceExists(opts.cwd, { isDirectory: this.deps.isDirectory });
7759
7900
  const resolvePath = this.deps.resolvePath ?? augmentedPath;
7760
7901
  const PATH = await resolvePath();
7761
7902
  const launch = await planDirectLaunch("opencode", ["serve", "--hostname", "127.0.0.1", "--port", "0"], {
7762
7903
  platform: this.deps.platform,
7763
7904
  resolvePath
7764
7905
  });
7906
+ if (!isExecutableResolved("opencode", launch, PATH, { platform: this.deps.platform, exists: this.deps.exists })) {
7907
+ throw new ExecutableNotFoundError("opencode", PATH);
7908
+ }
7765
7909
  this.process = this.deps.spawn(launch.file, launch.args, {
7766
7910
  env: withPath(process.env, PATH),
7767
7911
  stdio: ["pipe", "pipe", "pipe"],
@@ -8075,7 +8219,9 @@ var CliAgentAiService = class {
8075
8219
  spawn: deps.spawn ?? nodeSpawn,
8076
8220
  fetch: deps.fetch ?? globalThis.fetch,
8077
8221
  resolvePath: deps.resolvePath,
8078
- platform: deps.platform
8222
+ platform: deps.platform,
8223
+ isDirectory: deps.isDirectory,
8224
+ exists: deps.exists
8079
8225
  };
8080
8226
  this.makeAdapter = deps.createAdapter ?? defaultAdapter;
8081
8227
  this.workspaceRoot = deps.workspaceRoot ?? (() => process.cwd());
@@ -8420,8 +8566,8 @@ var CliAgentAiService = class {
8420
8566
  this.lastNativeSessionId = previousSessionId;
8421
8567
  }
8422
8568
  }
8423
- async researchAndPlan(userDescription, runners, modelsByRunner, fs9, onProgress, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
8424
- const contextStr = await collectResearchContext(fs9, runners);
8569
+ async researchAndPlan(userDescription, runners, modelsByRunner, fs10, onProgress, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
8570
+ const contextStr = await collectResearchContext(fs10, runners);
8425
8571
  const researchLog = [
8426
8572
  { id: `up-${Date.now()}`, type: "user_prompt", content: userDescription, timestamp: (/* @__PURE__ */ new Date()).toISOString() }
8427
8573
  ];
@@ -8791,16 +8937,16 @@ function executionSummary(tasks) {
8791
8937
  }
8792
8938
 
8793
8939
  // src/utils/sessionStore.ts
8794
- import * as fs4 from "fs";
8940
+ import * as fs5 from "fs";
8795
8941
  import * as path10 from "path";
8796
8942
 
8797
8943
  // src/utils/fsHelpers.ts
8798
- import * as fs3 from "fs";
8944
+ import * as fs4 from "fs";
8799
8945
  import * as path9 from "path";
8800
8946
  var STATE_DIR = ".ordewell";
8801
8947
  function ensureDir(dir) {
8802
- if (!fs3.existsSync(dir)) {
8803
- fs3.mkdirSync(dir, { recursive: true });
8948
+ if (!fs4.existsSync(dir)) {
8949
+ fs4.mkdirSync(dir, { recursive: true });
8804
8950
  }
8805
8951
  }
8806
8952
  function getStateDir(baseDir) {
@@ -8845,16 +8991,16 @@ function saveSession(plan, goal, baseDir, id) {
8845
8991
  };
8846
8992
  const filename = sessionFilename(meta);
8847
8993
  const filePath = path10.join(sessionsDir, filename);
8848
- fs4.writeFileSync(filePath, JSON.stringify(session, null, 2));
8994
+ fs5.writeFileSync(filePath, JSON.stringify(session, null, 2));
8849
8995
  return meta;
8850
8996
  }
8851
8997
  function listSessions(baseDir, logger = defaultLogger) {
8852
8998
  const sessionsDir = getSessionsDir(baseDir);
8853
- if (!fs4.existsSync(sessionsDir)) return [];
8854
- const files = fs4.readdirSync(sessionsDir).filter((f) => f.endsWith(".json"));
8999
+ if (!fs5.existsSync(sessionsDir)) return [];
9000
+ const files = fs5.readdirSync(sessionsDir).filter((f) => f.endsWith(".json"));
8855
9001
  return files.map((f) => {
8856
9002
  try {
8857
- const raw = fs4.readFileSync(path10.join(sessionsDir, f), "utf-8");
9003
+ const raw = fs5.readFileSync(path10.join(sessionsDir, f), "utf-8");
8858
9004
  const parsed = JSON.parse(raw);
8859
9005
  if (hasScalarRunner(parsed)) {
8860
9006
  logger.warn("sessionStore", `session file ${f}: ${REJECTION_MESSAGE}`);
@@ -8869,10 +9015,10 @@ function listSessions(baseDir, logger = defaultLogger) {
8869
9015
  }).filter((m) => m !== null).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
8870
9016
  }
8871
9017
  function findSessionFile(sessionsDir, sessionId, logger) {
8872
- const files = fs4.readdirSync(sessionsDir).filter((f) => f.endsWith(".json"));
9018
+ const files = fs5.readdirSync(sessionsDir).filter((f) => f.endsWith(".json"));
8873
9019
  for (const f of files) {
8874
9020
  try {
8875
- const raw = fs4.readFileSync(path10.join(sessionsDir, f), "utf-8");
9021
+ const raw = fs5.readFileSync(path10.join(sessionsDir, f), "utf-8");
8876
9022
  const session = JSON.parse(raw);
8877
9023
  if (session.meta.id === sessionId) return f;
8878
9024
  } catch (err) {
@@ -8883,11 +9029,11 @@ function findSessionFile(sessionsDir, sessionId, logger) {
8883
9029
  }
8884
9030
  function loadSession(sessionId, baseDir, logger = defaultLogger) {
8885
9031
  const sessionsDir = getSessionsDir(baseDir);
8886
- if (!fs4.existsSync(sessionsDir)) return null;
9032
+ if (!fs5.existsSync(sessionsDir)) return null;
8887
9033
  try {
8888
9034
  const file = findSessionFile(sessionsDir, sessionId, logger);
8889
9035
  if (file) {
8890
- const raw = fs4.readFileSync(path10.join(sessionsDir, file), "utf-8");
9036
+ const raw = fs5.readFileSync(path10.join(sessionsDir, file), "utf-8");
8891
9037
  const parsed = JSON.parse(raw);
8892
9038
  if (hasScalarRunner(parsed)) {
8893
9039
  logger.warn("sessionStore", `session file ${file}: ${REJECTION_MESSAGE}`);
@@ -8917,11 +9063,11 @@ function getLatestSession(baseDir, logger = defaultLogger) {
8917
9063
  }
8918
9064
  function deleteSession(sessionId, baseDir, logger = defaultLogger) {
8919
9065
  const sessionsDir = getSessionsDir(baseDir);
8920
- if (!fs4.existsSync(sessionsDir)) return false;
9066
+ if (!fs5.existsSync(sessionsDir)) return false;
8921
9067
  try {
8922
9068
  const file = findSessionFile(sessionsDir, sessionId, logger);
8923
9069
  if (file) {
8924
- fs4.unlinkSync(path10.join(sessionsDir, file));
9070
+ fs5.unlinkSync(path10.join(sessionsDir, file));
8925
9071
  return true;
8926
9072
  }
8927
9073
  } catch (err) {
@@ -8931,6 +9077,12 @@ function deleteSession(sessionId, baseDir, logger = defaultLogger) {
8931
9077
  }
8932
9078
 
8933
9079
  // src/services/createSession.ts
9080
+ var PlanEditError = class extends Error {
9081
+ constructor(message) {
9082
+ super(message);
9083
+ this.name = "PlanEditError";
9084
+ }
9085
+ };
8934
9086
  function sessionRuntimeSettings(settings) {
8935
9087
  return { ...plannerRuntimeToggles(settings), modelAllowlist: settings.modelAllowlist };
8936
9088
  }
@@ -9203,12 +9355,41 @@ var Session = class {
9203
9355
  get isExecuting() {
9204
9356
  return this.orchestrator.isRunning;
9205
9357
  }
9358
+ /** See {@link TaskOrchestrator.hasLiveWork} — a spawned runner, not merely an armed scheduler. */
9359
+ get hasLiveWork() {
9360
+ return this.orchestrator.hasLiveWork;
9361
+ }
9206
9362
  get status() {
9207
9363
  return this.orchestrator.status;
9208
9364
  }
9209
9365
  get sessionConfig() {
9210
9366
  return this.config;
9211
9367
  }
9368
+ /**
9369
+ * Deny every parked approval as soon as a planning turn is aborted, for the
9370
+ * same reason `beginFreshPlan` does it: a prompt raised by a turn nobody is
9371
+ * waiting on has no one left to serve, and denying it unblocks the research
9372
+ * loop instead of stranding it. Without this the request sat out its
9373
+ * five-minute timeout and the loop then carried on as if nothing had
9374
+ * happened — the abort was real, but invisible until long after the user
9375
+ * pressed stop.
9376
+ *
9377
+ * The listener is returned as a disposer rather than left attached: callers
9378
+ * own the signal and may reuse it, and a leaked listener would deny the
9379
+ * *next* turn's prompts the moment that stale signal aborted.
9380
+ */
9381
+ denyApprovalsOnAbort(signal) {
9382
+ if (!signal) return () => {
9383
+ };
9384
+ if (signal.aborted) {
9385
+ this.approvals.clear();
9386
+ return () => {
9387
+ };
9388
+ }
9389
+ const onAbort = () => this.approvals.clear();
9390
+ signal.addEventListener("abort", onAbort);
9391
+ return () => signal.removeEventListener("abort", onAbort);
9392
+ }
9212
9393
  async startExecution() {
9213
9394
  if (!this.plan || !this.store.planTasks.length) throw new Error("No plan to execute");
9214
9395
  this.store.clearLog();
@@ -9231,19 +9412,25 @@ var Session = class {
9231
9412
  const settings = this.settingsFn();
9232
9413
  const { modelAllowlist } = settings;
9233
9414
  const modes = plannerModesFrom(settings, this.config.autonomousMode);
9234
- const plan = await this.planner.generate({
9235
- goal,
9236
- runners: chosenRunners,
9237
- modelsByRunner,
9238
- runnerModes,
9239
- autonomousDefault: this.config.autonomousMode,
9240
- fs: this.fsAdapter,
9241
- fetcher: this.fetcher,
9242
- onProgress: (p) => this.translateProgress(p),
9243
- signal: options?.signal,
9244
- perRunnerAllowlist: modelAllowlist,
9245
- modes
9246
- });
9415
+ const releaseAbort = this.denyApprovalsOnAbort(options?.signal);
9416
+ let plan;
9417
+ try {
9418
+ plan = await this.planner.generate({
9419
+ goal,
9420
+ runners: chosenRunners,
9421
+ modelsByRunner,
9422
+ runnerModes,
9423
+ autonomousDefault: this.config.autonomousMode,
9424
+ fs: this.fsAdapter,
9425
+ fetcher: this.fetcher,
9426
+ onProgress: (p) => this.translateProgress(p),
9427
+ signal: options?.signal,
9428
+ perRunnerAllowlist: modelAllowlist,
9429
+ modes
9430
+ });
9431
+ } finally {
9432
+ releaseAbort();
9433
+ }
9247
9434
  this.plan = plan;
9248
9435
  this.orchestrator.loadPlan(plan.tasks, plan.runners);
9249
9436
  this.store.resetForRun({ preserveCompleted: false });
@@ -9280,23 +9467,28 @@ var Session = class {
9280
9467
  researchLog: [{ id: `up-${Date.now()}`, type: "user_prompt", content: goal, timestamp: now }],
9281
9468
  conversationHistory: [{ role: "user", content: goal, timestamp: now }]
9282
9469
  };
9283
- const turn = await this.aiService.startConversation({
9284
- goal,
9285
- runners: chosenRunners,
9286
- modelsByRunner: filteredModels,
9287
- runnerModes,
9288
- autonomousDefault: this.config.autonomousMode,
9289
- grillMeEnabled: grillMeEnabled ?? false,
9290
- prdEnabled: prdEnabled ?? false,
9291
- reviewEnabled: reviewEnabled ?? false,
9292
- verificationEnabled: verificationEnabled ?? false,
9293
- researchSubagentsEnabled: researchSubagentsEnabled ?? false,
9294
- fs: this.fsAdapter,
9295
- fetcher: this.fetcher,
9296
- onProgress: (p) => this.translateProgress(p),
9297
- signal: options?.signal
9298
- });
9299
- return this.settleTurn(turn, options);
9470
+ const releaseAbort = this.denyApprovalsOnAbort(options?.signal);
9471
+ try {
9472
+ const turn = await this.aiService.startConversation({
9473
+ goal,
9474
+ runners: chosenRunners,
9475
+ modelsByRunner: filteredModels,
9476
+ runnerModes,
9477
+ autonomousDefault: this.config.autonomousMode,
9478
+ grillMeEnabled: grillMeEnabled ?? false,
9479
+ prdEnabled: prdEnabled ?? false,
9480
+ reviewEnabled: reviewEnabled ?? false,
9481
+ verificationEnabled: verificationEnabled ?? false,
9482
+ researchSubagentsEnabled: researchSubagentsEnabled ?? false,
9483
+ fs: this.fsAdapter,
9484
+ fetcher: this.fetcher,
9485
+ onProgress: (p) => this.translateProgress(p),
9486
+ signal: options?.signal
9487
+ });
9488
+ return await this.settleTurn(turn, options);
9489
+ } finally {
9490
+ releaseAbort();
9491
+ }
9300
9492
  }
9301
9493
  /**
9302
9494
  * Every subsequent user reply in the planning conversation — grill-me
@@ -9314,21 +9506,26 @@ var Session = class {
9314
9506
  ${userMessage}` : userMessage;
9315
9507
  const aiService = this.aiService;
9316
9508
  const canContinueLive = aiService.hasActiveConversation() && (aiService.conversationMatchesConfig?.() ?? true);
9317
- let turn = canContinueLive ? await aiService.continueConversation(
9318
- outgoing,
9319
- (p) => this.translateProgress(p),
9320
- options?.signal
9321
- ) : await this.resumeConversation(outgoing, priorHistory, options);
9322
- if ((turn.kind === "task_ops" || turn.kind === "plan") && this.isExecuting) {
9323
- this.queueMessage(userMessage);
9324
- this.plan.queuedMessages = this.getQueuedMessages();
9325
- turn = {
9326
- kind: "message",
9327
- text: `Execution is running, so I queued your change \u2014 it will be applied between task batches (${this.queuedCount} queued).`,
9328
- researchLog: turn.researchLog
9329
- };
9509
+ const releaseAbort = this.denyApprovalsOnAbort(options?.signal);
9510
+ try {
9511
+ let turn = canContinueLive ? await aiService.continueConversation(
9512
+ outgoing,
9513
+ (p) => this.translateProgress(p),
9514
+ options?.signal
9515
+ ) : await this.resumeConversation(outgoing, priorHistory, options);
9516
+ if ((turn.kind === "task_ops" || turn.kind === "plan") && this.hasLiveWork) {
9517
+ this.queueMessage(userMessage);
9518
+ this.plan.queuedMessages = this.getQueuedMessages();
9519
+ turn = {
9520
+ kind: "message",
9521
+ text: `Execution is running, so I queued your change \u2014 it will be applied between task batches (${this.queuedCount} queued).`,
9522
+ researchLog: turn.researchLog
9523
+ };
9524
+ }
9525
+ return await this.settleTurn(turn, options);
9526
+ } finally {
9527
+ releaseAbort();
9330
9528
  }
9331
- return this.settleTurn(turn, options);
9332
9529
  }
9333
9530
  /**
9334
9531
  * Drive a planner turn to a persisted, broadcast outcome. Task edits apply
@@ -9367,7 +9564,10 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
9367
9564
  maxRepairs: 2,
9368
9565
  onExhausted: ({ reply, errors }) => invalidOps(errors, reply.researchLog)
9369
9566
  });
9370
- if ("plan" in settled) return settled.plan;
9567
+ if ("plan" in settled) {
9568
+ await this.orchestrator.tick();
9569
+ return settled.plan;
9570
+ }
9371
9571
  return this.applyConversationTurn(settled.turn);
9372
9572
  }
9373
9573
  /**
@@ -9382,9 +9582,9 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
9382
9582
  const lines = this.store.planTasks.map(
9383
9583
  (t) => `#${t.order} id=${t.id} "${t.title}" [${t.status}] runner:${t.assignedRunner}${t.assignedModel ? ` model:${t.assignedModel.modelId}` : ""} deps:[${t.dependencies.map((d) => orderOf.get(d) ?? d).join(", ")}]`
9384
9584
  );
9385
- const running = this.store.planTasks.filter((t) => t.status === "in_progress");
9386
- const execNote = this.isExecuting ? `
9387
- Execution is RUNNING${running.length ? ` \u2014 these tasks are locked: ${running.map((t) => `#${t.order}`).join(", ")}` : ""}. Any task edits you emit will be queued and applied between batches.` : "";
9585
+ const locked = this.store.planTasks.filter((t) => t.status === "in_progress");
9586
+ const execNote = this.hasLiveWork ? `
9587
+ Execution is RUNNING${locked.length ? ` \u2014 these tasks are locked: ${locked.map((t) => `#${t.order}`).join(", ")}` : ""}. Any task edits you emit will be queued and applied between batches.` : "";
9388
9588
  const modelLines = [];
9389
9589
  for (const runner of this.plan.runners) {
9390
9590
  const models = this.modelsCache[runner] ?? [];
@@ -9426,8 +9626,12 @@ Execution is RUNNING${running.length ? ` \u2014 these tasks are locked: ${runnin
9426
9626
  this.plan.researchLog = [...this.plan.researchLog ?? [], ...turn.researchLog];
9427
9627
  const allowlist = this.settingsFn().modelAllowlist ?? this.currentAllowlist;
9428
9628
  const coerced = coerceAssignments(result.tasks, allowlist, this.plan.runners, this.modelsCache);
9429
- this.orchestrator.loadPlan(coerced, this.plan.runners);
9430
- this.store.resetForRun();
9629
+ if (this.orchestrator.isRunning) {
9630
+ this.orchestrator.reconcilePlan(coerced, this.plan.runners);
9631
+ } else {
9632
+ this.orchestrator.loadPlan(coerced, this.plan.runners);
9633
+ this.store.resetForRun();
9634
+ }
9431
9635
  this.appendTranscript("assistant", content, now);
9432
9636
  return true;
9433
9637
  },
@@ -9663,8 +9867,33 @@ Execution is RUNNING${running.length ? ` \u2014 these tasks are locked: ${runnin
9663
9867
  notify();
9664
9868
  return this.plan;
9665
9869
  }
9666
- updateTask(taskId, changes) {
9667
- return this.mutatePlan(
9870
+ /**
9871
+ * The direct-edit seam: {@link mutatePlan} plus the reschedule every
9872
+ * structural edit owes an armed scheduler. Nothing else wakes one after a
9873
+ * hand edit — the queue-drain path never runs, because a direct edit never
9874
+ * queues — so a task the edit just unblocked would sit ready and never
9875
+ * start. `tick()` no-ops while the scheduler is idle, so this costs nothing
9876
+ * during plain planning. The planner-driven path re-ticks in
9877
+ * {@link settleTurn} instead; it must not tick twice.
9878
+ */
9879
+ async editPlan(op, notify) {
9880
+ const plan = this.mutatePlan(op, notify);
9881
+ if (!plan) return null;
9882
+ await this.orchestrator.tick();
9883
+ return plan;
9884
+ }
9885
+ /**
9886
+ * Patch one task's fields. A hand-set dependency list is the one patch that
9887
+ * can leave the plan unschedulable, so it goes through the same
9888
+ * {@link canSetDependencies} guard the planner's pickers use rather than a
9889
+ * second copy of the rule — and throws, so the surface can say why.
9890
+ */
9891
+ async updateTask(taskId, changes) {
9892
+ if (changes.dependencies) {
9893
+ const check = canSetDependencies(this.store.planTasks, taskId, changes.dependencies);
9894
+ if (!check.ok) throw new PlanEditError(check.error ?? "Those dependencies are not valid");
9895
+ }
9896
+ return this.editPlan(
9668
9897
  () => Boolean(this.store.update(taskId, changes)),
9669
9898
  () => this.broadcast({ type: "task_updated", taskId, changes })
9670
9899
  );
@@ -9674,8 +9903,7 @@ Execution is RUNNING${running.length ? ` \u2014 these tasks are locked: ${runnin
9674
9903
  * because a runner change is never a single-field edit: the task's model,
9675
9904
  * thinking effort and mode are all scoped to its runner, so they are
9676
9905
  * re-derived from the new runner's catalog (see {@link retargetTaskRunner}).
9677
- * That needs discovery, which is async — hence a method of its own rather
9678
- * than a branch inside the sync `updateTask`.
9906
+ * That needs discovery, which is why it is not a branch inside `updateTask`.
9679
9907
  *
9680
9908
  * The runner is also admitted into `plan.runners` — see {@link admitRunner}.
9681
9909
  */
@@ -9687,7 +9915,7 @@ Execution is RUNNING${running.length ? ` \u2014 these tasks are locked: ${runnin
9687
9915
  const catalog = await this.catalogFor(runner);
9688
9916
  const changes = retargetTaskRunner(task, runner, this.allowedCatalog(catalog, runner));
9689
9917
  if (Object.keys(changes).length === 0) return this.plan;
9690
- return this.mutatePlan(() => {
9918
+ return this.editPlan(() => {
9691
9919
  if (!this.store.update(taskId, changes)) return false;
9692
9920
  this.admitRunner(runner, catalog.models);
9693
9921
  return true;
@@ -9734,24 +9962,29 @@ Execution is RUNNING${running.length ? ` \u2014 these tasks are locked: ${runnin
9734
9962
  if (models.length > 0) this.modelsCache = { ...this.modelsCache, [runner]: models };
9735
9963
  }
9736
9964
  /**
9737
- * Replace one task's dependency list. Separate from {@link updateTask}
9738
- * because a hand-edited graph is the one task edit that can leave a plan
9739
- * unschedulable: `canSetDependencies` is the guard, and it lives behind this
9740
- * one method so the API and both surfaces' pickers reject the same edits
9741
- * rather than each carrying a copy of the rule. Throws so a surface can say
9742
- * why the edit was refused.
9965
+ * Replace one task's dependency list — the named entry point the surfaces'
9966
+ * dependency pickers call. The guard itself lives in {@link updateTask}, so
9967
+ * a dependency list arriving as a plain field patch is rejected by the same
9968
+ * rule instead of slipping past it.
9743
9969
  */
9744
- setTaskDependencies(taskId, dependencies) {
9970
+ async setTaskDependencies(taskId, dependencies) {
9745
9971
  if (!this.plan) return null;
9746
- const check = canSetDependencies(this.store.planTasks, taskId, dependencies);
9747
- if (!check.ok) throw new Error(check.error ?? "Those dependencies are not valid");
9748
9972
  return this.updateTask(taskId, { dependencies });
9749
9973
  }
9750
9974
  async completeTask(taskId) {
9751
9975
  await this.markTaskComplete(taskId);
9752
9976
  }
9753
- removeTask(taskId) {
9754
- return this.mutatePlan(() => (this.store.remove(taskId), true));
9977
+ /**
9978
+ * Delete one task. A running task is cancelled first: the plan can drop it
9979
+ * either way, but nothing can reach its runner afterwards — the tmux session
9980
+ * outlives the plan and the orchestrator keeps counting it as active. The
9981
+ * planner-driven path refuses instead (see {@link applyTaskOps}); a user
9982
+ * deleting their own task means it.
9983
+ */
9984
+ async removeTask(taskId) {
9985
+ if (!this.plan || !this.store.get(taskId)) return null;
9986
+ await this.orchestrator.releaseTask(taskId);
9987
+ return this.editPlan(() => (this.store.remove(taskId), true));
9755
9988
  }
9756
9989
  /**
9757
9990
  * Add one task, filling in whatever the caller left unset. A task with no
@@ -9771,20 +10004,20 @@ Execution is RUNNING${running.length ? ` \u2014 these tasks are locked: ${runnin
9771
10004
  const runner = draft.assignedRunner ?? this.plan.runners[0];
9772
10005
  const dependencies = (draft.dependencies ?? []).filter((id) => this.store.get(id));
9773
10006
  const catalog = draft.type === "user" ? null : await this.catalogFor(runner);
9774
- return this.mutatePlan(() => {
10007
+ return this.editPlan(() => {
9775
10008
  this.store.add({ ...draft, ...catalog ? runnerAssignment(this.allowedCatalog(catalog, runner), draft) : {}, assignedRunner: runner, dependencies });
9776
10009
  if (catalog) this.admitRunner(runner, catalog.models);
9777
10010
  return true;
9778
10011
  });
9779
10012
  }
9780
- mergeTasks(taskIdA, taskIdB) {
9781
- return this.mutatePlan(() => (this.store.merge(taskIdA, taskIdB), true));
10013
+ async mergeTasks(taskIdA, taskIdB) {
10014
+ return this.editPlan(() => (this.store.merge(taskIdA, taskIdB), true));
9782
10015
  }
9783
- mergeMultipleTasks(taskIds) {
9784
- return this.mutatePlan(() => (this.store.mergeMultiple(taskIds), true));
10016
+ async mergeMultipleTasks(taskIds) {
10017
+ return this.editPlan(() => (this.store.mergeMultiple(taskIds), true));
9785
10018
  }
9786
- splitTask(taskId, newTasks) {
9787
- return this.mutatePlan(() => (this.store.split(taskId, newTasks), true));
10019
+ async splitTask(taskId, newTasks) {
10020
+ return this.editPlan(() => (this.store.split(taskId, newTasks), true));
9788
10021
  }
9789
10022
  /**
9790
10023
  * Planner-driven merge: validate compatibility up front, then ask the planner
@@ -10298,7 +10531,7 @@ var HeadlessRunner = class extends AbstractRunner {
10298
10531
  import { execFile as execFile2 } from "child_process";
10299
10532
  import { promisify as promisify4 } from "util";
10300
10533
  import { tmpdir } from "os";
10301
- import { join as join8 } from "path";
10534
+ import { join as join9 } from "path";
10302
10535
  import { existsSync as existsSync5, readFileSync as readFileSync4, unlinkSync as unlinkSync2, writeFileSync as writeFileSync5 } from "fs";
10303
10536
 
10304
10537
  // src/utils/tmux.ts
@@ -10583,7 +10816,7 @@ var TmuxRunner = class extends AbstractRunner {
10583
10816
  windowName,
10584
10817
  this.execFileImpl,
10585
10818
  this.pollIntervalMs,
10586
- join8(this.logDir, `${id}.log`),
10819
+ join9(this.logDir, `${id}.log`),
10587
10820
  this.socket
10588
10821
  );
10589
10822
  invocation.env = { ...invocation.env, PATH: await this.resolvePath() };
@@ -10594,13 +10827,13 @@ var TmuxRunner = class extends AbstractRunner {
10594
10827
  };
10595
10828
 
10596
10829
  // src/plugins/RunnerRegistry.ts
10597
- import * as fs6 from "fs";
10830
+ import * as fs7 from "fs";
10598
10831
  import * as path12 from "path";
10599
10832
  import * as os5 from "os";
10600
10833
  import { execSync as execSync3 } from "child_process";
10601
10834
 
10602
10835
  // src/plugins/FsPluginStore.ts
10603
- import * as fs5 from "fs";
10836
+ import * as fs6 from "fs";
10604
10837
  import * as path11 from "path";
10605
10838
  import * as os4 from "os";
10606
10839
  function userPluginsDir() {
@@ -10613,9 +10846,9 @@ function isValidManifest(obj) {
10613
10846
  }
10614
10847
  function loadManifestFromDir(dir) {
10615
10848
  const manifestPath = path11.join(dir, "manifest.json");
10616
- if (!fs5.existsSync(manifestPath)) return null;
10849
+ if (!fs6.existsSync(manifestPath)) return null;
10617
10850
  try {
10618
- const raw = fs5.readFileSync(manifestPath, "utf8");
10851
+ const raw = fs6.readFileSync(manifestPath, "utf8");
10619
10852
  const parsed = JSON.parse(raw);
10620
10853
  if (!isValidManifest(parsed)) return null;
10621
10854
  return parsed;
@@ -10625,29 +10858,29 @@ function loadManifestFromDir(dir) {
10625
10858
  }
10626
10859
  function copyDirSync(src, dest) {
10627
10860
  ensureDir(dest);
10628
- const entries = fs5.readdirSync(src, { withFileTypes: true });
10861
+ const entries = fs6.readdirSync(src, { withFileTypes: true });
10629
10862
  for (const entry of entries) {
10630
10863
  const srcPath = path11.join(src, entry.name);
10631
10864
  const destPath = path11.join(dest, entry.name);
10632
10865
  if (entry.isDirectory()) {
10633
10866
  copyDirSync(srcPath, destPath);
10634
10867
  } else {
10635
- fs5.copyFileSync(srcPath, destPath);
10868
+ fs6.copyFileSync(srcPath, destPath);
10636
10869
  }
10637
10870
  }
10638
10871
  }
10639
10872
  function rmDirSync(dir) {
10640
- if (!fs5.existsSync(dir)) return;
10641
- const entries = fs5.readdirSync(dir, { withFileTypes: true });
10873
+ if (!fs6.existsSync(dir)) return;
10874
+ const entries = fs6.readdirSync(dir, { withFileTypes: true });
10642
10875
  for (const entry of entries) {
10643
10876
  const p = path11.join(dir, entry.name);
10644
10877
  if (entry.isDirectory()) {
10645
10878
  rmDirSync(p);
10646
10879
  } else {
10647
- fs5.unlinkSync(p);
10880
+ fs6.unlinkSync(p);
10648
10881
  }
10649
10882
  }
10650
- fs5.rmdirSync(dir);
10883
+ fs6.rmdirSync(dir);
10651
10884
  }
10652
10885
  var FsPluginStore = class {
10653
10886
  getUserPluginsDir() {
@@ -10655,10 +10888,10 @@ var FsPluginStore = class {
10655
10888
  }
10656
10889
  listUserPluginDirs() {
10657
10890
  const dir = userPluginsDir();
10658
- if (!fs5.existsSync(dir)) return [];
10891
+ if (!fs6.existsSync(dir)) return [];
10659
10892
  let entries;
10660
10893
  try {
10661
- entries = fs5.readdirSync(dir, { withFileTypes: true });
10894
+ entries = fs6.readdirSync(dir, { withFileTypes: true });
10662
10895
  } catch {
10663
10896
  return [];
10664
10897
  }
@@ -10677,20 +10910,20 @@ var FsPluginStore = class {
10677
10910
  ensureDir(dir);
10678
10911
  }
10679
10912
  writeFile(filePath, content) {
10680
- fs5.writeFileSync(filePath, content);
10913
+ fs6.writeFileSync(filePath, content);
10681
10914
  }
10682
10915
  readFile(filePath) {
10683
10916
  try {
10684
- return fs5.readFileSync(filePath, "utf8");
10917
+ return fs6.readFileSync(filePath, "utf8");
10685
10918
  } catch {
10686
10919
  return null;
10687
10920
  }
10688
10921
  }
10689
10922
  dirExists(path15) {
10690
- return fs5.existsSync(path15) && fs5.statSync(path15).isDirectory();
10923
+ return fs6.existsSync(path15) && fs6.statSync(path15).isDirectory();
10691
10924
  }
10692
10925
  exists(path15) {
10693
- return fs5.existsSync(path15);
10926
+ return fs6.existsSync(path15);
10694
10927
  }
10695
10928
  };
10696
10929
 
@@ -11159,20 +11392,20 @@ echo "Running ${name} with prompt: $prompt"
11159
11392
  };
11160
11393
  function fsReaddirSync(dir) {
11161
11394
  try {
11162
- return fs6.readdirSync(dir);
11395
+ return fs7.readdirSync(dir);
11163
11396
  } catch {
11164
11397
  return [];
11165
11398
  }
11166
11399
  }
11167
11400
  function fsChmodSync(fpath, mode) {
11168
11401
  try {
11169
- fs6.chmodSync(fpath, mode);
11402
+ fs7.chmodSync(fpath, mode);
11170
11403
  } catch {
11171
11404
  }
11172
11405
  }
11173
11406
 
11174
11407
  // src/utils/stateStore.ts
11175
- import * as fs7 from "fs";
11408
+ import * as fs8 from "fs";
11176
11409
  import * as path13 from "path";
11177
11410
  var STATE_FILE = "state.json";
11178
11411
  function getStatePath(baseDir) {
@@ -11182,14 +11415,14 @@ function saveState(plan, baseDir) {
11182
11415
  const statePath = getStatePath(baseDir ?? process.cwd());
11183
11416
  ensureDir(path13.dirname(statePath));
11184
11417
  plan.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
11185
- fs7.writeFileSync(statePath, JSON.stringify(plan, null, 2));
11418
+ fs8.writeFileSync(statePath, JSON.stringify(plan, null, 2));
11186
11419
  }
11187
11420
  function loadState(baseDir, logger = defaultLogger) {
11188
11421
  const dir = baseDir ?? process.cwd();
11189
11422
  const statePath = getStatePath(dir);
11190
- if (!fs7.existsSync(statePath)) return null;
11423
+ if (!fs8.existsSync(statePath)) return null;
11191
11424
  try {
11192
- const raw = fs7.readFileSync(statePath, "utf-8");
11425
+ const raw = fs8.readFileSync(statePath, "utf-8");
11193
11426
  const parsed = JSON.parse(raw);
11194
11427
  if (!Array.isArray(parsed.runners)) {
11195
11428
  logger.warn("stateStore", `rejecting state from ${statePath}: old format (scalar "runner" instead of "runners" array). Please re-plan.`);
@@ -11204,15 +11437,15 @@ function loadState(baseDir, logger = defaultLogger) {
11204
11437
  function clearState(baseDir) {
11205
11438
  const dir = baseDir ?? process.cwd();
11206
11439
  const statePath = getStatePath(dir);
11207
- if (fs7.existsSync(statePath)) fs7.unlinkSync(statePath);
11440
+ if (fs8.existsSync(statePath)) fs8.unlinkSync(statePath);
11208
11441
  }
11209
11442
  function stateExists(baseDir) {
11210
11443
  const dir = baseDir ?? process.cwd();
11211
- return fs7.existsSync(getStatePath(dir));
11444
+ return fs8.existsSync(getStatePath(dir));
11212
11445
  }
11213
11446
 
11214
11447
  // src/services/SettingsService.ts
11215
- import * as fs8 from "fs";
11448
+ import * as fs9 from "fs";
11216
11449
  import * as path14 from "path";
11217
11450
  import * as os6 from "os";
11218
11451
  var DEFAULTS = {
@@ -11228,6 +11461,17 @@ function getSettingsPath() {
11228
11461
  if (override && override.trim()) return override.trim();
11229
11462
  return path14.join(os6.homedir(), ".config", "ordewell", "settings.json");
11230
11463
  }
11464
+ function readPlannerModels(raw) {
11465
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
11466
+ const out = {};
11467
+ for (const [provider, entry] of Object.entries(raw)) {
11468
+ if (!entry || typeof entry !== "object") continue;
11469
+ const { model, effort } = entry;
11470
+ if (typeof model !== "string" || !model.trim()) continue;
11471
+ out[provider] = typeof effort === "string" && effort.trim() ? { model, effort } : { model };
11472
+ }
11473
+ return Object.keys(out).length > 0 ? out : void 0;
11474
+ }
11231
11475
  var SettingsService = class {
11232
11476
  filePath;
11233
11477
  cache = null;
@@ -11248,7 +11492,7 @@ var SettingsService = class {
11248
11492
  }
11249
11493
  fileMtimeMs() {
11250
11494
  try {
11251
- return fs8.statSync(this.filePath).mtimeMs;
11495
+ return fs9.statSync(this.filePath).mtimeMs;
11252
11496
  } catch {
11253
11497
  return null;
11254
11498
  }
@@ -11321,6 +11565,26 @@ var SettingsService = class {
11321
11565
  }
11322
11566
  this.persist();
11323
11567
  }
11568
+ getPlannerModel(provider) {
11569
+ return this.getAll().plannerModels?.[provider];
11570
+ }
11571
+ setPlannerModel(provider, entry) {
11572
+ this.getAll();
11573
+ if (entry === void 0) {
11574
+ if (this.cache.plannerModels) {
11575
+ delete this.cache.plannerModels[provider];
11576
+ if (Object.keys(this.cache.plannerModels).length === 0) {
11577
+ this.cache.plannerModels = void 0;
11578
+ }
11579
+ }
11580
+ } else {
11581
+ if (!this.cache.plannerModels) {
11582
+ this.cache.plannerModels = {};
11583
+ }
11584
+ this.cache.plannerModels[provider] = entry.effort ? { model: entry.model, effort: entry.effort } : { model: entry.model };
11585
+ }
11586
+ this.persist();
11587
+ }
11324
11588
  getEnabledRunners() {
11325
11589
  return this.getAll().enabledRunners;
11326
11590
  }
@@ -11331,8 +11595,8 @@ var SettingsService = class {
11331
11595
  }
11332
11596
  load() {
11333
11597
  try {
11334
- if (fs8.existsSync(this.filePath)) {
11335
- const raw = JSON.parse(fs8.readFileSync(this.filePath, "utf-8"));
11598
+ if (fs9.existsSync(this.filePath)) {
11599
+ const raw = JSON.parse(fs9.readFileSync(this.filePath, "utf-8"));
11336
11600
  const settings = {
11337
11601
  grillMe: { enabled: raw.grillMe?.enabled ?? DEFAULTS.grillMe.enabled },
11338
11602
  tdd: { enabled: raw.tdd?.enabled ?? DEFAULTS.tdd.enabled },
@@ -11346,6 +11610,10 @@ var SettingsService = class {
11346
11610
  if (raw.modelAllowlist !== void 0) {
11347
11611
  settings.modelAllowlist = raw.modelAllowlist;
11348
11612
  }
11613
+ const plannerModels = readPlannerModels(raw.plannerModels);
11614
+ if (plannerModels) {
11615
+ settings.plannerModels = plannerModels;
11616
+ }
11349
11617
  if (Array.isArray(raw.enabledRunners)) {
11350
11618
  settings.enabledRunners = raw.enabledRunners.map(String);
11351
11619
  }
@@ -11357,13 +11625,48 @@ var SettingsService = class {
11357
11625
  }
11358
11626
  persist() {
11359
11627
  const dir = path14.dirname(this.filePath);
11360
- if (!fs8.existsSync(dir)) {
11361
- fs8.mkdirSync(dir, { recursive: true });
11628
+ if (!fs9.existsSync(dir)) {
11629
+ fs9.mkdirSync(dir, { recursive: true });
11362
11630
  }
11363
- fs8.writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2));
11631
+ fs9.writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2));
11364
11632
  this.cachedMtimeMs = this.fileMtimeMs();
11365
11633
  }
11366
11634
  };
11635
+
11636
+ // src/services/PlannerModelMemory.ts
11637
+ var PlannerModelMemory = class {
11638
+ constructor(store) {
11639
+ this.store = store;
11640
+ }
11641
+ store;
11642
+ /** Record what the user picked for `provider`; a blank model forgets it instead. */
11643
+ remember(provider, model, effort) {
11644
+ const id = model.trim();
11645
+ if (!id) {
11646
+ this.store.setPlannerModel(provider, void 0);
11647
+ return;
11648
+ }
11649
+ const level = effort?.trim();
11650
+ this.store.setPlannerModel(provider, level ? { model: id, effort: level } : { model: id });
11651
+ }
11652
+ /**
11653
+ * What the model and effort should become when `provider` takes over the
11654
+ * planner, given the models it actually serves. The `source` is what the
11655
+ * three surfaces word their notice from — restored, defaulted, or nothing.
11656
+ */
11657
+ recall(provider, catalog) {
11658
+ const remembered = this.store.getPlannerModel(provider);
11659
+ const match = remembered && catalog.find((c) => c.id === remembered.model);
11660
+ if (remembered && match) {
11661
+ const level = remembered.effort ?? "";
11662
+ const survives = (match.variants ?? []).some((v) => v.id === level);
11663
+ return { model: match.id, effort: survives ? level : "", source: "remembered" };
11664
+ }
11665
+ const first = catalog[0];
11666
+ if (first) return { model: first.id, effort: "", source: "catalog-default" };
11667
+ return { model: "", effort: "", source: "none" };
11668
+ }
11669
+ };
11367
11670
  export {
11368
11671
  ALL_PROVIDERS,
11369
11672
  AUTO_COMMANDS,
@@ -11386,6 +11689,7 @@ export {
11386
11689
  DENY_ALL,
11387
11690
  EmbeddedNewlineError,
11388
11691
  EnvConfig,
11692
+ ExecutableNotFoundError,
11389
11693
  FsPluginStore,
11390
11694
  GIT_READONLY_SUBCOMMANDS,
11391
11695
  GREP_DEFAULT_HEAD_LIMIT,
@@ -11406,14 +11710,17 @@ export {
11406
11710
  PROVIDER_PRIORITY,
11407
11711
  PROVIDER_SHORT_LABEL,
11408
11712
  PendingApprovals,
11713
+ PlanEditError,
11409
11714
  PlanParseError,
11410
11715
  PlanStore,
11411
11716
  Planner,
11717
+ PlannerModelMemory,
11412
11718
  REFUSED_COMMANDS,
11413
11719
  RunnerInstallation,
11414
11720
  RunnerRegistry,
11415
11721
  SEARCH_EXCLUSIONS,
11416
11722
  STATE_DIR,
11723
+ STOPPED_TOOL_RESULT,
11417
11724
  SYMBOL_LANGUAGES,
11418
11725
  Session,
11419
11726
  SettingsService,
@@ -11424,9 +11731,11 @@ export {
11424
11731
  TmuxRunner,
11425
11732
  VerdictEngine,
11426
11733
  WINDOWS_MAX_COMMAND_LINE,
11734
+ WorkspaceNotFoundError,
11427
11735
  addTaskToPlan,
11428
11736
  applyHeadLimit,
11429
11737
  applyTaskOps,
11738
+ assertWorkspaceExists,
11430
11739
  augmentPromptWithPriorOutputs,
11431
11740
  augmentedPath,
11432
11741
  buildConversationSystemPrompt,
@@ -11500,6 +11809,7 @@ export {
11500
11809
  hasTmux,
11501
11810
  includeGlobFor,
11502
11811
  isCliProvider,
11812
+ isExecutableResolved,
11503
11813
  isOpenAiProvider,
11504
11814
  killTree,
11505
11815
  knownModelId,