@ordewell/core 0.4.4 → 0.4.6

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
@@ -10325,6 +10558,28 @@ function hasTmux(probe = defaultProbe) {
10325
10558
  return false;
10326
10559
  }
10327
10560
  }
10561
+ var defaultHasBin = (bin) => {
10562
+ try {
10563
+ execSync2(`${process.platform === "win32" ? "where" : "which"} ${bin}`, { stdio: "ignore" });
10564
+ return true;
10565
+ } catch {
10566
+ return false;
10567
+ }
10568
+ };
10569
+ function clipboardCopyCommand(hasBin = defaultHasBin, platform = process.platform, env = process.env) {
10570
+ const candidates = platform === "darwin" ? [["pbcopy", "pbcopy"]] : platform === "win32" ? [["clip.exe", "clip.exe"]] : [
10571
+ // Only prefer wl-copy when there is a Wayland display to talk to —
10572
+ // on X11 it is often installed but fails at copy time.
10573
+ ...env.WAYLAND_DISPLAY ? [["wl-copy", "wl-copy"]] : [],
10574
+ ["xclip", "xclip -selection clipboard"],
10575
+ ["xsel", "xsel --clipboard --input"],
10576
+ ["wl-copy", "wl-copy"]
10577
+ ];
10578
+ for (const [bin, command] of candidates) {
10579
+ if (hasBin(bin)) return command;
10580
+ }
10581
+ return null;
10582
+ }
10328
10583
 
10329
10584
  // src/services/TmuxRunner.ts
10330
10585
  var EXIT_RE = /<<<ORDEWELL_TMUX_EXIT:(\d+)>>>/;
@@ -10434,6 +10689,7 @@ var TmuxRunner = class extends AbstractRunner {
10434
10689
  resolvePath;
10435
10690
  pollIntervalMs;
10436
10691
  logDir;
10692
+ clipboardCommand;
10437
10693
  /** Retries get a freshly named window so a failed attempt's output stays inspectable. */
10438
10694
  attempts = /* @__PURE__ */ new Map();
10439
10695
  ready = null;
@@ -10445,6 +10701,7 @@ var TmuxRunner = class extends AbstractRunner {
10445
10701
  this.resolvePath = deps.resolvePath ?? augmentedPath;
10446
10702
  this.pollIntervalMs = deps.pollIntervalMs ?? 500;
10447
10703
  this.logDir = deps.logDir ?? tmpdir();
10704
+ this.clipboardCommand = deps.clipboardCommand ?? (() => clipboardCopyCommand());
10448
10705
  }
10449
10706
  /**
10450
10707
  * Reaps a session orphaned by a crashed prior daemon on the same port, then
@@ -10473,25 +10730,44 @@ var TmuxRunner = class extends AbstractRunner {
10473
10730
  await this.configureScrolling();
10474
10731
  }
10475
10732
  /**
10476
- * Makes an attached runner terminal scrollable with the mouse wheel and
10477
- * Page Up/Down. A fresh tmux session ships with `mouse off`, a 2000-line
10478
- * scrollback, and no PageUp binding — none of which lets a user page back
10479
- * through a finished task's output, which is the whole point of opening its
10480
- * window. Scoping is harmless: the daemon owns a private socket
10481
- * (`tmuxSocketName`), so these server-wide options touch nothing but its
10482
- * own session. Best-effort so an ancient or stripped tmux cannot stop tasks
10483
- * from spawning — the session still works without the scroll comforts.
10733
+ * Gives an attached runner terminal wheel/PageUp scrolling over a deep
10734
+ * scrollback, and a mouse selection that reaches the system clipboard — none
10735
+ * of which a default tmux session does. Server-wide scoping is harmless: the
10736
+ * daemon owns a private socket (`tmuxSocketName`).
10737
+ *
10738
+ * `mouse on` is what costs the emulator's own drag-select, so tmux must hand
10739
+ * the selection back out itself: via a clipboard binary when there is one,
10740
+ * and via OSC 52 (`set-clipboard`) for what that cannot cover, such as
10741
+ * viewing over SSH. Naming the binary on the binding as well as in
10742
+ * `copy-command` keeps drag-to-copy working on tmux older than 3.2, which has
10743
+ * no `copy-command`.
10484
10744
  */
10485
10745
  async configureScrolling() {
10486
- try {
10487
- await this.tmux(["set-option", "-g", "history-limit", "100000"]);
10488
- await this.tmux(["set-option", "-g", "mouse", "on"]);
10489
- await this.tmux(["bind-key", "-n", "PageUp", "copy-mode", "-u"]);
10490
- await this.tmux(["bind-key", "-T", "copy-mode", "PageUp", "send-keys", "-X", "page-up"]);
10491
- await this.tmux(["bind-key", "-T", "copy-mode", "PageDown", "send-keys", "-X", "page-down"]);
10492
- await this.tmux(["bind-key", "-T", "copy-mode-vi", "PageUp", "send-keys", "-X", "page-up"]);
10493
- await this.tmux(["bind-key", "-T", "copy-mode-vi", "PageDown", "send-keys", "-X", "page-down"]);
10494
- } catch {
10746
+ const clip = this.clipboardCommand();
10747
+ const copySelection = clip ? ["copy-pipe-and-cancel", clip] : ["copy-pipe-and-cancel"];
10748
+ const commands = [
10749
+ // `history-limit` only governs windows created after it is set, so it
10750
+ // must run before any `new-window` — once, here at session creation.
10751
+ ["set-option", "-g", "history-limit", "100000"],
10752
+ ["set-option", "-g", "mouse", "on"],
10753
+ ["set-option", "-g", "set-clipboard", "on"],
10754
+ ...clip ? [["set-option", "-g", "copy-command", clip]] : [],
10755
+ ["bind-key", "-T", "copy-mode", "MouseDragEnd1Pane", "send-keys", "-X", ...copySelection],
10756
+ ["bind-key", "-T", "copy-mode-vi", "MouseDragEnd1Pane", "send-keys", "-X", ...copySelection],
10757
+ // Root-table PageUp; otherwise the key goes to the inner app and never
10758
+ // reaches tmux's scrollback. Both mode tables, so `mode-keys` can't
10759
+ // decide whether paging works.
10760
+ ["bind-key", "-n", "PageUp", "copy-mode", "-u"],
10761
+ ["bind-key", "-T", "copy-mode", "PageUp", "send-keys", "-X", "page-up"],
10762
+ ["bind-key", "-T", "copy-mode", "PageDown", "send-keys", "-X", "page-down"],
10763
+ ["bind-key", "-T", "copy-mode-vi", "PageUp", "send-keys", "-X", "page-up"],
10764
+ ["bind-key", "-T", "copy-mode-vi", "PageDown", "send-keys", "-X", "page-down"]
10765
+ ];
10766
+ for (const args of commands) {
10767
+ try {
10768
+ await this.tmux(args);
10769
+ } catch {
10770
+ }
10495
10771
  }
10496
10772
  }
10497
10773
  /**
@@ -10540,7 +10816,7 @@ var TmuxRunner = class extends AbstractRunner {
10540
10816
  windowName,
10541
10817
  this.execFileImpl,
10542
10818
  this.pollIntervalMs,
10543
- join8(this.logDir, `${id}.log`),
10819
+ join9(this.logDir, `${id}.log`),
10544
10820
  this.socket
10545
10821
  );
10546
10822
  invocation.env = { ...invocation.env, PATH: await this.resolvePath() };
@@ -10551,13 +10827,13 @@ var TmuxRunner = class extends AbstractRunner {
10551
10827
  };
10552
10828
 
10553
10829
  // src/plugins/RunnerRegistry.ts
10554
- import * as fs6 from "fs";
10830
+ import * as fs7 from "fs";
10555
10831
  import * as path12 from "path";
10556
10832
  import * as os5 from "os";
10557
10833
  import { execSync as execSync3 } from "child_process";
10558
10834
 
10559
10835
  // src/plugins/FsPluginStore.ts
10560
- import * as fs5 from "fs";
10836
+ import * as fs6 from "fs";
10561
10837
  import * as path11 from "path";
10562
10838
  import * as os4 from "os";
10563
10839
  function userPluginsDir() {
@@ -10570,9 +10846,9 @@ function isValidManifest(obj) {
10570
10846
  }
10571
10847
  function loadManifestFromDir(dir) {
10572
10848
  const manifestPath = path11.join(dir, "manifest.json");
10573
- if (!fs5.existsSync(manifestPath)) return null;
10849
+ if (!fs6.existsSync(manifestPath)) return null;
10574
10850
  try {
10575
- const raw = fs5.readFileSync(manifestPath, "utf8");
10851
+ const raw = fs6.readFileSync(manifestPath, "utf8");
10576
10852
  const parsed = JSON.parse(raw);
10577
10853
  if (!isValidManifest(parsed)) return null;
10578
10854
  return parsed;
@@ -10582,29 +10858,29 @@ function loadManifestFromDir(dir) {
10582
10858
  }
10583
10859
  function copyDirSync(src, dest) {
10584
10860
  ensureDir(dest);
10585
- const entries = fs5.readdirSync(src, { withFileTypes: true });
10861
+ const entries = fs6.readdirSync(src, { withFileTypes: true });
10586
10862
  for (const entry of entries) {
10587
10863
  const srcPath = path11.join(src, entry.name);
10588
10864
  const destPath = path11.join(dest, entry.name);
10589
10865
  if (entry.isDirectory()) {
10590
10866
  copyDirSync(srcPath, destPath);
10591
10867
  } else {
10592
- fs5.copyFileSync(srcPath, destPath);
10868
+ fs6.copyFileSync(srcPath, destPath);
10593
10869
  }
10594
10870
  }
10595
10871
  }
10596
10872
  function rmDirSync(dir) {
10597
- if (!fs5.existsSync(dir)) return;
10598
- const entries = fs5.readdirSync(dir, { withFileTypes: true });
10873
+ if (!fs6.existsSync(dir)) return;
10874
+ const entries = fs6.readdirSync(dir, { withFileTypes: true });
10599
10875
  for (const entry of entries) {
10600
10876
  const p = path11.join(dir, entry.name);
10601
10877
  if (entry.isDirectory()) {
10602
10878
  rmDirSync(p);
10603
10879
  } else {
10604
- fs5.unlinkSync(p);
10880
+ fs6.unlinkSync(p);
10605
10881
  }
10606
10882
  }
10607
- fs5.rmdirSync(dir);
10883
+ fs6.rmdirSync(dir);
10608
10884
  }
10609
10885
  var FsPluginStore = class {
10610
10886
  getUserPluginsDir() {
@@ -10612,10 +10888,10 @@ var FsPluginStore = class {
10612
10888
  }
10613
10889
  listUserPluginDirs() {
10614
10890
  const dir = userPluginsDir();
10615
- if (!fs5.existsSync(dir)) return [];
10891
+ if (!fs6.existsSync(dir)) return [];
10616
10892
  let entries;
10617
10893
  try {
10618
- entries = fs5.readdirSync(dir, { withFileTypes: true });
10894
+ entries = fs6.readdirSync(dir, { withFileTypes: true });
10619
10895
  } catch {
10620
10896
  return [];
10621
10897
  }
@@ -10634,20 +10910,20 @@ var FsPluginStore = class {
10634
10910
  ensureDir(dir);
10635
10911
  }
10636
10912
  writeFile(filePath, content) {
10637
- fs5.writeFileSync(filePath, content);
10913
+ fs6.writeFileSync(filePath, content);
10638
10914
  }
10639
10915
  readFile(filePath) {
10640
10916
  try {
10641
- return fs5.readFileSync(filePath, "utf8");
10917
+ return fs6.readFileSync(filePath, "utf8");
10642
10918
  } catch {
10643
10919
  return null;
10644
10920
  }
10645
10921
  }
10646
10922
  dirExists(path15) {
10647
- return fs5.existsSync(path15) && fs5.statSync(path15).isDirectory();
10923
+ return fs6.existsSync(path15) && fs6.statSync(path15).isDirectory();
10648
10924
  }
10649
10925
  exists(path15) {
10650
- return fs5.existsSync(path15);
10926
+ return fs6.existsSync(path15);
10651
10927
  }
10652
10928
  };
10653
10929
 
@@ -11116,20 +11392,20 @@ echo "Running ${name} with prompt: $prompt"
11116
11392
  };
11117
11393
  function fsReaddirSync(dir) {
11118
11394
  try {
11119
- return fs6.readdirSync(dir);
11395
+ return fs7.readdirSync(dir);
11120
11396
  } catch {
11121
11397
  return [];
11122
11398
  }
11123
11399
  }
11124
11400
  function fsChmodSync(fpath, mode) {
11125
11401
  try {
11126
- fs6.chmodSync(fpath, mode);
11402
+ fs7.chmodSync(fpath, mode);
11127
11403
  } catch {
11128
11404
  }
11129
11405
  }
11130
11406
 
11131
11407
  // src/utils/stateStore.ts
11132
- import * as fs7 from "fs";
11408
+ import * as fs8 from "fs";
11133
11409
  import * as path13 from "path";
11134
11410
  var STATE_FILE = "state.json";
11135
11411
  function getStatePath(baseDir) {
@@ -11139,14 +11415,14 @@ function saveState(plan, baseDir) {
11139
11415
  const statePath = getStatePath(baseDir ?? process.cwd());
11140
11416
  ensureDir(path13.dirname(statePath));
11141
11417
  plan.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
11142
- fs7.writeFileSync(statePath, JSON.stringify(plan, null, 2));
11418
+ fs8.writeFileSync(statePath, JSON.stringify(plan, null, 2));
11143
11419
  }
11144
11420
  function loadState(baseDir, logger = defaultLogger) {
11145
11421
  const dir = baseDir ?? process.cwd();
11146
11422
  const statePath = getStatePath(dir);
11147
- if (!fs7.existsSync(statePath)) return null;
11423
+ if (!fs8.existsSync(statePath)) return null;
11148
11424
  try {
11149
- const raw = fs7.readFileSync(statePath, "utf-8");
11425
+ const raw = fs8.readFileSync(statePath, "utf-8");
11150
11426
  const parsed = JSON.parse(raw);
11151
11427
  if (!Array.isArray(parsed.runners)) {
11152
11428
  logger.warn("stateStore", `rejecting state from ${statePath}: old format (scalar "runner" instead of "runners" array). Please re-plan.`);
@@ -11161,15 +11437,15 @@ function loadState(baseDir, logger = defaultLogger) {
11161
11437
  function clearState(baseDir) {
11162
11438
  const dir = baseDir ?? process.cwd();
11163
11439
  const statePath = getStatePath(dir);
11164
- if (fs7.existsSync(statePath)) fs7.unlinkSync(statePath);
11440
+ if (fs8.existsSync(statePath)) fs8.unlinkSync(statePath);
11165
11441
  }
11166
11442
  function stateExists(baseDir) {
11167
11443
  const dir = baseDir ?? process.cwd();
11168
- return fs7.existsSync(getStatePath(dir));
11444
+ return fs8.existsSync(getStatePath(dir));
11169
11445
  }
11170
11446
 
11171
11447
  // src/services/SettingsService.ts
11172
- import * as fs8 from "fs";
11448
+ import * as fs9 from "fs";
11173
11449
  import * as path14 from "path";
11174
11450
  import * as os6 from "os";
11175
11451
  var DEFAULTS = {
@@ -11185,6 +11461,17 @@ function getSettingsPath() {
11185
11461
  if (override && override.trim()) return override.trim();
11186
11462
  return path14.join(os6.homedir(), ".config", "ordewell", "settings.json");
11187
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
+ }
11188
11475
  var SettingsService = class {
11189
11476
  filePath;
11190
11477
  cache = null;
@@ -11205,7 +11492,7 @@ var SettingsService = class {
11205
11492
  }
11206
11493
  fileMtimeMs() {
11207
11494
  try {
11208
- return fs8.statSync(this.filePath).mtimeMs;
11495
+ return fs9.statSync(this.filePath).mtimeMs;
11209
11496
  } catch {
11210
11497
  return null;
11211
11498
  }
@@ -11278,6 +11565,26 @@ var SettingsService = class {
11278
11565
  }
11279
11566
  this.persist();
11280
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
+ }
11281
11588
  getEnabledRunners() {
11282
11589
  return this.getAll().enabledRunners;
11283
11590
  }
@@ -11288,8 +11595,8 @@ var SettingsService = class {
11288
11595
  }
11289
11596
  load() {
11290
11597
  try {
11291
- if (fs8.existsSync(this.filePath)) {
11292
- 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"));
11293
11600
  const settings = {
11294
11601
  grillMe: { enabled: raw.grillMe?.enabled ?? DEFAULTS.grillMe.enabled },
11295
11602
  tdd: { enabled: raw.tdd?.enabled ?? DEFAULTS.tdd.enabled },
@@ -11303,6 +11610,10 @@ var SettingsService = class {
11303
11610
  if (raw.modelAllowlist !== void 0) {
11304
11611
  settings.modelAllowlist = raw.modelAllowlist;
11305
11612
  }
11613
+ const plannerModels = readPlannerModels(raw.plannerModels);
11614
+ if (plannerModels) {
11615
+ settings.plannerModels = plannerModels;
11616
+ }
11306
11617
  if (Array.isArray(raw.enabledRunners)) {
11307
11618
  settings.enabledRunners = raw.enabledRunners.map(String);
11308
11619
  }
@@ -11314,13 +11625,48 @@ var SettingsService = class {
11314
11625
  }
11315
11626
  persist() {
11316
11627
  const dir = path14.dirname(this.filePath);
11317
- if (!fs8.existsSync(dir)) {
11318
- fs8.mkdirSync(dir, { recursive: true });
11628
+ if (!fs9.existsSync(dir)) {
11629
+ fs9.mkdirSync(dir, { recursive: true });
11319
11630
  }
11320
- fs8.writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2));
11631
+ fs9.writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2));
11321
11632
  this.cachedMtimeMs = this.fileMtimeMs();
11322
11633
  }
11323
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
+ };
11324
11670
  export {
11325
11671
  ALL_PROVIDERS,
11326
11672
  AUTO_COMMANDS,
@@ -11343,6 +11689,7 @@ export {
11343
11689
  DENY_ALL,
11344
11690
  EmbeddedNewlineError,
11345
11691
  EnvConfig,
11692
+ ExecutableNotFoundError,
11346
11693
  FsPluginStore,
11347
11694
  GIT_READONLY_SUBCOMMANDS,
11348
11695
  GREP_DEFAULT_HEAD_LIMIT,
@@ -11363,14 +11710,17 @@ export {
11363
11710
  PROVIDER_PRIORITY,
11364
11711
  PROVIDER_SHORT_LABEL,
11365
11712
  PendingApprovals,
11713
+ PlanEditError,
11366
11714
  PlanParseError,
11367
11715
  PlanStore,
11368
11716
  Planner,
11717
+ PlannerModelMemory,
11369
11718
  REFUSED_COMMANDS,
11370
11719
  RunnerInstallation,
11371
11720
  RunnerRegistry,
11372
11721
  SEARCH_EXCLUSIONS,
11373
11722
  STATE_DIR,
11723
+ STOPPED_TOOL_RESULT,
11374
11724
  SYMBOL_LANGUAGES,
11375
11725
  Session,
11376
11726
  SettingsService,
@@ -11381,9 +11731,11 @@ export {
11381
11731
  TmuxRunner,
11382
11732
  VerdictEngine,
11383
11733
  WINDOWS_MAX_COMMAND_LINE,
11734
+ WorkspaceNotFoundError,
11384
11735
  addTaskToPlan,
11385
11736
  applyHeadLimit,
11386
11737
  applyTaskOps,
11738
+ assertWorkspaceExists,
11387
11739
  augmentPromptWithPriorOutputs,
11388
11740
  augmentedPath,
11389
11741
  buildConversationSystemPrompt,
@@ -11416,6 +11768,7 @@ export {
11416
11768
  clearAugmentedPathCache,
11417
11769
  clearResearchShellCache,
11418
11770
  clearState,
11771
+ clipboardCopyCommand,
11419
11772
  coerceAssignments,
11420
11773
  collectDirectDependencyOutputs,
11421
11774
  collectProviderCredentials,
@@ -11456,6 +11809,7 @@ export {
11456
11809
  hasTmux,
11457
11810
  includeGlobFor,
11458
11811
  isCliProvider,
11812
+ isExecutableResolved,
11459
11813
  isOpenAiProvider,
11460
11814
  killTree,
11461
11815
  knownModelId,