@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.js CHANGED
@@ -51,6 +51,7 @@ __export(index_exports, {
51
51
  DENY_ALL: () => DENY_ALL,
52
52
  EmbeddedNewlineError: () => EmbeddedNewlineError,
53
53
  EnvConfig: () => EnvConfig,
54
+ ExecutableNotFoundError: () => ExecutableNotFoundError,
54
55
  FsPluginStore: () => FsPluginStore,
55
56
  GIT_READONLY_SUBCOMMANDS: () => GIT_READONLY_SUBCOMMANDS,
56
57
  GREP_DEFAULT_HEAD_LIMIT: () => GREP_DEFAULT_HEAD_LIMIT,
@@ -71,14 +72,17 @@ __export(index_exports, {
71
72
  PROVIDER_PRIORITY: () => PROVIDER_PRIORITY,
72
73
  PROVIDER_SHORT_LABEL: () => PROVIDER_SHORT_LABEL,
73
74
  PendingApprovals: () => PendingApprovals,
75
+ PlanEditError: () => PlanEditError,
74
76
  PlanParseError: () => PlanParseError,
75
77
  PlanStore: () => PlanStore,
76
78
  Planner: () => Planner,
79
+ PlannerModelMemory: () => PlannerModelMemory,
77
80
  REFUSED_COMMANDS: () => REFUSED_COMMANDS,
78
81
  RunnerInstallation: () => RunnerInstallation,
79
82
  RunnerRegistry: () => RunnerRegistry,
80
83
  SEARCH_EXCLUSIONS: () => SEARCH_EXCLUSIONS,
81
84
  STATE_DIR: () => STATE_DIR,
85
+ STOPPED_TOOL_RESULT: () => STOPPED_TOOL_RESULT,
82
86
  SYMBOL_LANGUAGES: () => SYMBOL_LANGUAGES,
83
87
  Session: () => Session,
84
88
  SettingsService: () => SettingsService,
@@ -89,9 +93,11 @@ __export(index_exports, {
89
93
  TmuxRunner: () => TmuxRunner,
90
94
  VerdictEngine: () => VerdictEngine,
91
95
  WINDOWS_MAX_COMMAND_LINE: () => WINDOWS_MAX_COMMAND_LINE,
96
+ WorkspaceNotFoundError: () => WorkspaceNotFoundError,
92
97
  addTaskToPlan: () => addTaskToPlan,
93
98
  applyHeadLimit: () => applyHeadLimit,
94
99
  applyTaskOps: () => applyTaskOps,
100
+ assertWorkspaceExists: () => assertWorkspaceExists,
95
101
  augmentPromptWithPriorOutputs: () => augmentPromptWithPriorOutputs,
96
102
  augmentedPath: () => augmentedPath,
97
103
  buildConversationSystemPrompt: () => buildConversationSystemPrompt,
@@ -124,6 +130,7 @@ __export(index_exports, {
124
130
  clearAugmentedPathCache: () => clearAugmentedPathCache,
125
131
  clearResearchShellCache: () => clearResearchShellCache,
126
132
  clearState: () => clearState,
133
+ clipboardCopyCommand: () => clipboardCopyCommand,
127
134
  coerceAssignments: () => coerceAssignments,
128
135
  collectDirectDependencyOutputs: () => collectDirectDependencyOutputs,
129
136
  collectProviderCredentials: () => collectProviderCredentials,
@@ -164,6 +171,7 @@ __export(index_exports, {
164
171
  hasTmux: () => hasTmux,
165
172
  includeGlobFor: () => includeGlobFor,
166
173
  isCliProvider: () => isCliProvider,
174
+ isExecutableResolved: () => isExecutableResolved,
167
175
  isOpenAiProvider: () => isOpenAiProvider,
168
176
  killTree: () => killTree,
169
177
  knownModelId: () => knownModelId,
@@ -818,6 +826,16 @@ function dialectFor(dialect) {
818
826
  const resolved = dialect ?? (process.platform === "win32" ? "cmd" : "posix");
819
827
  return resolved === "cmd" ? CMD_DIALECT : POSIX_DIALECT;
820
828
  }
829
+ function isDevNullTarget(target) {
830
+ if (!target.startsWith("/")) return false;
831
+ const resolved = [];
832
+ for (const seg of target.split("/")) {
833
+ if (seg === "" || seg === ".") continue;
834
+ if (seg === "..") resolved.pop();
835
+ else resolved.push(seg);
836
+ }
837
+ return `/${resolved.join("/")}` === "/dev/null";
838
+ }
821
839
  function matchParen(s, open) {
822
840
  let depth = 0;
823
841
  for (let i = open; i < s.length; i++) {
@@ -828,7 +846,7 @@ function matchParen(s, open) {
828
846
  }
829
847
  function lex(command, nested, dialect) {
830
848
  const segments = [];
831
- let redirect = false;
849
+ let unsafeRedirect;
832
850
  let processSubstitution = false;
833
851
  let unbalanced = false;
834
852
  let tokens = [];
@@ -837,7 +855,19 @@ function lex(command, nested, dialect) {
837
855
  let started = false;
838
856
  let piped = false;
839
857
  let quote = "";
840
- const endToken = () => {
858
+ let redirectTargetMode = false;
859
+ let redirectOperator = "";
860
+ const endToken = (hardBoundary = true) => {
861
+ if (redirectTargetMode) {
862
+ if (!started && !hardBoundary) return;
863
+ if (!unsafeRedirect && !isDevNullTarget(current)) {
864
+ unsafeRedirect = { operator: redirectOperator, target: current };
865
+ }
866
+ redirectTargetMode = false;
867
+ current = "";
868
+ started = false;
869
+ return;
870
+ }
841
871
  if (started) tokens.push(current);
842
872
  current = "";
843
873
  started = false;
@@ -925,9 +955,34 @@ function lex(command, nested, dialect) {
925
955
  i += 2;
926
956
  continue;
927
957
  }
928
- if (c === ">") redirect = true;
958
+ let fd = "";
959
+ if (started && /^[0-9]+$/.test(current)) {
960
+ fd = current;
961
+ current = "";
962
+ started = false;
963
+ } else {
964
+ endToken();
965
+ }
966
+ const doubled = command[i + 1] === c;
967
+ const opLen = doubled ? 2 : 1;
968
+ if (c === ">") {
969
+ const dup = /^&([0-9]+)(?![0-9])/.exec(command.slice(i + opLen));
970
+ if (dup) {
971
+ i += opLen + dup[0].length;
972
+ continue;
973
+ }
974
+ redirectTargetMode = true;
975
+ redirectOperator = fd + c.repeat(opLen);
976
+ }
977
+ i += opLen;
978
+ continue;
979
+ }
980
+ if (c === "&" && command[i + 1] === ">") {
929
981
  endToken();
930
- i += command[i + 1] === c ? 2 : 1;
982
+ const doubled = command[i + 2] === ">";
983
+ redirectTargetMode = true;
984
+ redirectOperator = doubled ? "&>>" : "&>";
985
+ i += doubled ? 3 : 2;
931
986
  continue;
932
987
  }
933
988
  if (c === "(" || c === ")") {
@@ -947,7 +1002,7 @@ function lex(command, nested, dialect) {
947
1002
  continue;
948
1003
  }
949
1004
  if (/\s/.test(c)) {
950
- endToken();
1005
+ endToken(false);
951
1006
  i++;
952
1007
  continue;
953
1008
  }
@@ -957,7 +1012,7 @@ function lex(command, nested, dialect) {
957
1012
  }
958
1013
  if (quote !== "") unbalanced = true;
959
1014
  endSegment(false);
960
- return { segments, redirect, processSubstitution, unbalanced };
1015
+ return { segments, unsafeRedirect, processSubstitution, unbalanced };
961
1016
  }
962
1017
  function binaryName(token, dialect) {
963
1018
  const base = token.split(/[/\\]/).pop() ?? "";
@@ -977,7 +1032,7 @@ function lexAll(command, dialect) {
977
1032
  for (let depth = 0; depth < 32 && queue.length > 0; depth++) {
978
1033
  const inner = lex(queue.shift(), queue, dialect);
979
1034
  all.segments.push(...inner.segments.filter((s) => s.binary));
980
- all.redirect ||= inner.redirect;
1035
+ all.unsafeRedirect ??= inner.unsafeRedirect;
981
1036
  all.processSubstitution ||= inner.processSubstitution;
982
1037
  all.unbalanced ||= inner.unbalanced;
983
1038
  }
@@ -1026,8 +1081,9 @@ function refusalFor(seg) {
1026
1081
  if (seg.binary === "git") {
1027
1082
  const sub = seg.args.find((a) => !a.startsWith("-"));
1028
1083
  if (sub === "branch" || sub === "tag") {
1029
- if (seg.args.some((a) => ["-D", "-d", "-m", "-M", "--delete", "--move"].includes(a))) {
1030
- return `"git ${sub}" with a delete/move flag mutates refs. You are a read-only planner \u2014 describe the change as a task instead.`;
1084
+ const flag = seg.args.find((a) => ["-D", "-d", "-m", "-M", "--delete", "--move"].includes(a));
1085
+ if (flag) {
1086
+ return `"git ${sub} ${flag}" mutates refs. You are a read-only planner \u2014 describe the change as a task instead.`;
1031
1087
  }
1032
1088
  }
1033
1089
  }
@@ -1037,8 +1093,9 @@ function refusalFor(seg) {
1037
1093
  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.`;
1038
1094
  }
1039
1095
  }
1040
- if (seg.binary === "sed" && seg.args.some((a) => a === "-i" || a === "--in-place" || /^-i./.test(a))) {
1041
- return `"sed -i" edits files in place. You are a read-only planner \u2014 describe the change as a task instead.`;
1096
+ const sedInPlace = seg.binary === "sed" ? seg.args.find((a) => a === "-i" || a === "--in-place" || /^-i./.test(a)) : void 0;
1097
+ if (sedInPlace) {
1098
+ return `"sed ${sedInPlace}" edits files in place. You are a read-only planner \u2014 describe the change as a task instead.`;
1042
1099
  }
1043
1100
  if (seg.binary === "awk" && seg.args.some((a) => a === "inplace")) {
1044
1101
  return `"awk -i inplace" edits files in place. You are a read-only planner \u2014 describe the change as a task instead.`;
@@ -1057,7 +1114,7 @@ function classifyCommand(command, opts = {}) {
1057
1114
  const trimmed = command.trim();
1058
1115
  if (!trimmed) return { tier: "refuse", scope: "", reason: "Empty command." };
1059
1116
  const dialect = dialectFor(opts.dialect);
1060
- const { segments, redirect, processSubstitution, unbalanced } = lexAll(trimmed, dialect);
1117
+ const { segments, unsafeRedirect, processSubstitution, unbalanced } = lexAll(trimmed, dialect);
1061
1118
  if (unbalanced) {
1062
1119
  return {
1063
1120
  tier: "refuse",
@@ -1072,11 +1129,12 @@ function classifyCommand(command, opts = {}) {
1072
1129
  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."
1073
1130
  };
1074
1131
  }
1075
- if (redirect) {
1132
+ if (unsafeRedirect) {
1133
+ const written = unsafeRedirect.target ? `"${unsafeRedirect.operator} ${unsafeRedirect.target}"` : `"${unsafeRedirect.operator}"`;
1076
1134
  return {
1077
1135
  tier: "refuse",
1078
1136
  scope: "",
1079
- reason: "Output redirection writes files. You are a read-only planner \u2014 read the output instead, or describe the write as a task."
1137
+ reason: `${written} writes to a file. You are a read-only planner \u2014 read the output instead, or describe the write as a task.`
1080
1138
  };
1081
1139
  }
1082
1140
  if (segments.length === 0) return { tier: "refuse", scope: "", reason: "No command found." };
@@ -1396,7 +1454,7 @@ ${refs.output.trim()}`);
1396
1454
  * anything else asks once and is remembered, and writes are refused outright
1397
1455
  * because a planner that mutates the workspace has stopped being a planner.
1398
1456
  */
1399
- async bash(command) {
1457
+ async bash(command, signal) {
1400
1458
  const { tier, scope, reason } = classifyCommand(command, { dialect: this.researchShell.dialect });
1401
1459
  if (tier === "refuse") {
1402
1460
  return { success: false, output: `Command refused: ${reason}`, truncated: false };
@@ -1419,7 +1477,7 @@ It is outside the auto-allowed read-only set (${scope}). Continue with the read-
1419
1477
  };
1420
1478
  }
1421
1479
  }
1422
- const outcome = await this.execBashImpl(command);
1480
+ const outcome = await this.execBashImpl(command, signal);
1423
1481
  if (outcome.success) return outcome;
1424
1482
  const warning = researchShellWarning(this.researchShell);
1425
1483
  return warning ? { ...outcome, output: `${outcome.output}
@@ -1428,6 +1486,82 @@ It is outside the auto-allowed read-only set (${scope}). Continue with the read-
1428
1486
  }
1429
1487
  };
1430
1488
 
1489
+ // src/services/executeTool.ts
1490
+ var GREP_OUTPUT_MODES = ["content", "files", "count"];
1491
+ function num(value) {
1492
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1493
+ }
1494
+ function bool(value) {
1495
+ return typeof value === "boolean" ? value : void 0;
1496
+ }
1497
+ function str(value) {
1498
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1499
+ }
1500
+ function grepOptions(args) {
1501
+ const mode = str(args.output_mode ?? args.outputMode);
1502
+ return {
1503
+ include: str(args.include),
1504
+ path: str(args.path),
1505
+ outputMode: GREP_OUTPUT_MODES.includes(mode) ? mode : void 0,
1506
+ contextBefore: num(args.context_before ?? args.contextBefore),
1507
+ contextAfter: num(args.context_after ?? args.contextAfter),
1508
+ literal: bool(args.literal),
1509
+ caseInsensitive: bool(args.case_insensitive ?? args.caseInsensitive),
1510
+ headLimit: num(args.head_limit ?? args.headLimit)
1511
+ };
1512
+ }
1513
+ var STOPPED_TOOL_RESULT = "Stopped by the user \u2014 this call was not executed, and no further tool calls will run this turn.";
1514
+ async function executeTool(name, args, fs10, fetcher, signal) {
1515
+ if (signal?.aborted) return { success: false, output: STOPPED_TOOL_RESULT, truncated: false };
1516
+ switch (name) {
1517
+ case "read_file": {
1518
+ const offset = num(args.offset);
1519
+ const limit = num(args.limit);
1520
+ const maxBytes = num(args.maxBytes);
1521
+ return fs10.readFile(String(args.path ?? ""), { offset, limit, maxBytes });
1522
+ }
1523
+ case "read_files": {
1524
+ const paths = Array.isArray(args.paths) ? args.paths.map(String) : [];
1525
+ return fs10.readFiles(paths);
1526
+ }
1527
+ case "glob":
1528
+ return fs10.glob(String(args.pattern ?? ""), { path: str(args.path), headLimit: num(args.head_limit ?? args.headLimit) });
1529
+ case "grep":
1530
+ return fs10.grep(String(args.pattern ?? ""), grepOptions(args));
1531
+ case "find_symbol":
1532
+ return fs10.findSymbol(String(args.symbol ?? ""), { language: str(args.language), path: str(args.path) });
1533
+ case "list_dir":
1534
+ return fs10.listDir(String(args.path ?? ""), num(args.depth));
1535
+ case "bash":
1536
+ return fs10.bash(String(args.command ?? ""), signal);
1537
+ case "fetch": {
1538
+ if (!fetcher) return { success: false, output: "Web fetching is not available in this environment.", truncated: false };
1539
+ const url = String(args.url ?? "");
1540
+ const allowed = await fetcher.confirm(url);
1541
+ if (!allowed) return { success: false, output: `URL fetch denied by user: ${url}`, truncated: false };
1542
+ return fetcher.fetch(url);
1543
+ }
1544
+ case "web_search": {
1545
+ if (!fetcher?.search) {
1546
+ 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 };
1547
+ }
1548
+ const query = String(args.query ?? "").trim();
1549
+ if (!query) return { success: false, output: 'web_search requires a non-empty "query".', truncated: false };
1550
+ return fetcher.search(query);
1551
+ }
1552
+ // Budget models hallucinate two kinds of tools: plan-committing ones
1553
+ // (create_task, submit_plan) and implementation ones (create_file,
1554
+ // write_file). Steer both back: you are a planner, tasks run later,
1555
+ // and the commit channel is raw JSON in the reply text.
1556
+ default:
1557
+ return {
1558
+ success: false,
1559
+ 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).`,
1560
+ truncated: false
1561
+ };
1562
+ }
1563
+ }
1564
+
1431
1565
  // src/services/ApprovalPolicy.ts
1432
1566
  function scopeMatches(pattern, scope) {
1433
1567
  if (pattern === scope) return true;
@@ -4314,8 +4448,8 @@ function savePrdMarkdown(workspace, slug2, markdown) {
4314
4448
 
4315
4449
  // src/services/ContextCollector.ts
4316
4450
  var ORDEWELL_CONTEXT_MAX = 8e3;
4317
- async function collectResearchContext(fs9, runners) {
4318
- const collector = new ContextCollector(fs9);
4451
+ async function collectResearchContext(fs10, runners) {
4452
+ const collector = new ContextCollector(fs10);
4319
4453
  let contextStr = "";
4320
4454
  let firstContext = null;
4321
4455
  for (const r of runners) {
@@ -4342,8 +4476,8 @@ ${firstContext.dirStructure}
4342
4476
  return contextStr;
4343
4477
  }
4344
4478
  var ContextCollector = class {
4345
- constructor(fs9, registry) {
4346
- this.fs = fs9;
4479
+ constructor(fs10, registry) {
4480
+ this.fs = fs10;
4347
4481
  this.registry = registry;
4348
4482
  }
4349
4483
  fs;
@@ -4379,80 +4513,6 @@ var ContextCollector = class {
4379
4513
  }
4380
4514
  };
4381
4515
 
4382
- // src/services/executeTool.ts
4383
- var GREP_OUTPUT_MODES = ["content", "files", "count"];
4384
- function num(value) {
4385
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
4386
- }
4387
- function bool(value) {
4388
- return typeof value === "boolean" ? value : void 0;
4389
- }
4390
- function str(value) {
4391
- return typeof value === "string" && value.length > 0 ? value : void 0;
4392
- }
4393
- function grepOptions(args) {
4394
- const mode = str(args.output_mode ?? args.outputMode);
4395
- return {
4396
- include: str(args.include),
4397
- path: str(args.path),
4398
- outputMode: GREP_OUTPUT_MODES.includes(mode) ? mode : void 0,
4399
- contextBefore: num(args.context_before ?? args.contextBefore),
4400
- contextAfter: num(args.context_after ?? args.contextAfter),
4401
- literal: bool(args.literal),
4402
- caseInsensitive: bool(args.case_insensitive ?? args.caseInsensitive),
4403
- headLimit: num(args.head_limit ?? args.headLimit)
4404
- };
4405
- }
4406
- async function executeTool(name, args, fs9, fetcher) {
4407
- switch (name) {
4408
- case "read_file": {
4409
- const offset = num(args.offset);
4410
- const limit = num(args.limit);
4411
- const maxBytes = num(args.maxBytes);
4412
- return fs9.readFile(String(args.path ?? ""), { offset, limit, maxBytes });
4413
- }
4414
- case "read_files": {
4415
- const paths = Array.isArray(args.paths) ? args.paths.map(String) : [];
4416
- return fs9.readFiles(paths);
4417
- }
4418
- case "glob":
4419
- return fs9.glob(String(args.pattern ?? ""), { path: str(args.path), headLimit: num(args.head_limit ?? args.headLimit) });
4420
- case "grep":
4421
- return fs9.grep(String(args.pattern ?? ""), grepOptions(args));
4422
- case "find_symbol":
4423
- return fs9.findSymbol(String(args.symbol ?? ""), { language: str(args.language), path: str(args.path) });
4424
- case "list_dir":
4425
- return fs9.listDir(String(args.path ?? ""), num(args.depth));
4426
- case "bash":
4427
- return fs9.bash(String(args.command ?? ""));
4428
- case "fetch": {
4429
- if (!fetcher) return { success: false, output: "Web fetching is not available in this environment.", truncated: false };
4430
- const url = String(args.url ?? "");
4431
- const allowed = await fetcher.confirm(url);
4432
- if (!allowed) return { success: false, output: `URL fetch denied by user: ${url}`, truncated: false };
4433
- return fetcher.fetch(url);
4434
- }
4435
- case "web_search": {
4436
- if (!fetcher?.search) {
4437
- 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 };
4438
- }
4439
- const query = String(args.query ?? "").trim();
4440
- if (!query) return { success: false, output: 'web_search requires a non-empty "query".', truncated: false };
4441
- return fetcher.search(query);
4442
- }
4443
- // Budget models hallucinate two kinds of tools: plan-committing ones
4444
- // (create_task, submit_plan) and implementation ones (create_file,
4445
- // write_file). Steer both back: you are a planner, tasks run later,
4446
- // and the commit channel is raw JSON in the reply text.
4447
- default:
4448
- return {
4449
- success: false,
4450
- 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).`,
4451
- truncated: false
4452
- };
4453
- }
4454
- }
4455
-
4456
4516
  // src/services/researchStepSummary.ts
4457
4517
  function ellipsize(text, max) {
4458
4518
  const oneLine = text.replace(/\s+/g, " ").trim();
@@ -4534,23 +4594,23 @@ var ALLOWED_TOOL_NAMES = new Set(subagentToolSpecs().map((t) => t.name));
4534
4594
  function refused(output) {
4535
4595
  return { success: false, output, truncated: false };
4536
4596
  }
4537
- function nonPromptingFs(fs9) {
4538
- const withinWorkspace = (p) => resolveWithin(fs9.getWorkspaceRoot(), p ?? ".").inside;
4597
+ function nonPromptingFs(fs10) {
4598
+ const withinWorkspace = (p) => resolveWithin(fs10.getWorkspaceRoot(), p ?? ".").inside;
4539
4599
  const outsideRefusal = (p) => refused(
4540
4600
  `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.`
4541
4601
  );
4542
4602
  return {
4543
- getWorkspaceRoot: () => fs9.getWorkspaceRoot(),
4544
- readFile: (p, opts) => withinWorkspace(p) ? fs9.readFile(p, opts) : Promise.resolve(outsideRefusal(p)),
4603
+ getWorkspaceRoot: () => fs10.getWorkspaceRoot(),
4604
+ readFile: (p, opts) => withinWorkspace(p) ? fs10.readFile(p, opts) : Promise.resolve(outsideRefusal(p)),
4545
4605
  readFiles: (paths) => {
4546
4606
  const escaping = paths.find((p) => !withinWorkspace(p));
4547
- return escaping ? Promise.resolve(outsideRefusal(escaping)) : fs9.readFiles(paths);
4607
+ return escaping ? Promise.resolve(outsideRefusal(escaping)) : fs10.readFiles(paths);
4548
4608
  },
4549
- glob: (pattern, opts) => withinWorkspace(opts?.path) ? fs9.glob(pattern, opts) : Promise.resolve(outsideRefusal(opts.path)),
4550
- grep: (pattern, opts) => withinWorkspace(opts?.path) ? fs9.grep(pattern, opts) : Promise.resolve(outsideRefusal(opts.path)),
4551
- findSymbol: (symbol, opts) => withinWorkspace(opts?.path) ? fs9.findSymbol(symbol, opts) : Promise.resolve(outsideRefusal(opts.path)),
4552
- listDir: (p, depth) => withinWorkspace(p) ? fs9.listDir(p, depth) : Promise.resolve(outsideRefusal(p)),
4553
- bash: async (command) => {
4609
+ glob: (pattern, opts) => withinWorkspace(opts?.path) ? fs10.glob(pattern, opts) : Promise.resolve(outsideRefusal(opts.path)),
4610
+ grep: (pattern, opts) => withinWorkspace(opts?.path) ? fs10.grep(pattern, opts) : Promise.resolve(outsideRefusal(opts.path)),
4611
+ findSymbol: (symbol, opts) => withinWorkspace(opts?.path) ? fs10.findSymbol(symbol, opts) : Promise.resolve(outsideRefusal(opts.path)),
4612
+ listDir: (p, depth) => withinWorkspace(p) ? fs10.listDir(p, depth) : Promise.resolve(outsideRefusal(p)),
4613
+ bash: async (command, signal) => {
4554
4614
  const dialect = resolveResearchShell().dialect;
4555
4615
  const { tier, reason } = classifyCommand(command, { dialect });
4556
4616
  if (tier !== "auto") {
@@ -4560,13 +4620,13 @@ function nonPromptingFs(fs9) {
4560
4620
  }
4561
4621
  const escaping = pathLikeArgs(command, { dialect }).find((p) => !withinWorkspace(p));
4562
4622
  if (escaping) return outsideRefusal(escaping);
4563
- return fs9.bash(command);
4623
+ return fs10.bash(command, signal);
4564
4624
  }
4565
4625
  };
4566
4626
  }
4567
4627
  async function runLoop(prompt, deps) {
4568
4628
  if (deps.signal?.aborted) return "[research agent aborted before starting]";
4569
- const fs9 = nonPromptingFs(deps.fs);
4629
+ const fs10 = nonPromptingFs(deps.fs);
4570
4630
  const chat = deps.createChat((delta) => deps.onProgress?.({ type: "thinking", text: delta }));
4571
4631
  let turn = await chat.sendMessage(prompt, deps.signal);
4572
4632
  for (let step = 0; turn.hasToolCalls && step < SUBAGENT_LIMITS.maxSteps; step++) {
@@ -4583,7 +4643,7 @@ ${turn.text}`;
4583
4643
  if (tc.name === "read_file" && !("maxBytes" in args) && !("limit" in args)) args.limit = 2e3;
4584
4644
  const toolArgs = JSON.stringify(tc.args);
4585
4645
  deps.onProgress?.({ type: "tool_call", tool: tc.name, toolArgs, toolCallId: tc.id });
4586
- const res = await executeTool(tc.name, args, fs9);
4646
+ const res = await executeTool(tc.name, args, fs10, void 0, deps.signal);
4587
4647
  const output = res.output.length > SUBAGENT_LIMITS.toolOutputMaxChars ? res.output.slice(0, SUBAGENT_LIMITS.toolOutputMaxChars) + `
4588
4648
  [... truncated to ${SUBAGENT_LIMITS.toolOutputMaxChars} chars, total ${res.output.length}]` : res.output;
4589
4649
  const stepEntry = {
@@ -4698,7 +4758,7 @@ var BaseAiService = class _BaseAiService {
4698
4758
  * steering/failed result and the loop continues sequentially: a subagent can
4699
4759
  * never fail a plan or a turn.
4700
4760
  */
4701
- async executeSpawnAgent(args, fs9, onProgress, signal, subagentId) {
4761
+ async executeSpawnAgent(args, fs10, onProgress, signal, subagentId) {
4702
4762
  if (!this.researchSubagentsEnabled) {
4703
4763
  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 };
4704
4764
  }
@@ -4712,20 +4772,20 @@ var BaseAiService = class _BaseAiService {
4712
4772
  if (!chat) throw new Error("subagent chats are not available for this provider");
4713
4773
  return chat;
4714
4774
  },
4715
- fs: fs9,
4775
+ fs: fs10,
4716
4776
  signal,
4717
4777
  onProgress: (progress) => onProgress({ ...progress, subagentId })
4718
4778
  });
4719
4779
  }
4720
4780
  /** Collect project context for the planning phase. Shared with the harness backend. */
4721
- static async collectResearchContext(fs9, runners) {
4722
- return collectResearchContext(fs9, runners);
4781
+ static async collectResearchContext(fs10, runners) {
4782
+ return collectResearchContext(fs10, runners);
4723
4783
  }
4724
4784
  /**
4725
4785
  * Execute one round of tool calls and report each through onProgress.
4726
4786
  * Returns the results to feed back plus the log entries produced.
4727
4787
  */
4728
- async executeToolCalls(toolCalls, fs9, onProgress, fetcher, stepIndex, thinking, signal) {
4788
+ async executeToolCalls(toolCalls, fs10, onProgress, fetcher, stepIndex, thinking, signal) {
4729
4789
  const toolResults = [];
4730
4790
  const logEntries = [];
4731
4791
  let resultsText = "";
@@ -4744,18 +4804,18 @@ var BaseAiService = class _BaseAiService {
4744
4804
  const subagentId = (0, import_uuid2.v4)();
4745
4805
  spawnSubagentIds.set(i, subagentId);
4746
4806
  onProgress({ type: "tool_call", tool: toolCalls[i].name, toolArgs: JSON.stringify(toolCalls[i].args), subagentId, toolCallId: toolCalls[i].id });
4747
- precomputed.set(i, await this.executeSpawnAgent({ ...toolCalls[i].args }, fs9, onProgress, signal, subagentId));
4807
+ precomputed.set(i, await this.executeSpawnAgent({ ...toolCalls[i].args }, fs10, onProgress, signal, subagentId));
4748
4808
  }) : Promise.resolve(),
4749
4809
  parallelIndexes.length > 1 ? mapWithConcurrency(parallelIndexes, TOOL_ROUND_CONCURRENCY, async (i) => {
4750
4810
  onProgress({ type: "tool_call", tool: toolCalls[i].name, toolArgs: JSON.stringify(toolCalls[i].args), toolCallId: toolCalls[i].id });
4751
- precomputed.set(i, await executeTool(toolCalls[i].name, normalizedArgs[i], fs9, fetcher));
4811
+ precomputed.set(i, await executeTool(toolCalls[i].name, normalizedArgs[i], fs10, fetcher, signal));
4752
4812
  }) : Promise.resolve()
4753
4813
  ]);
4754
4814
  for (const [index, tc] of toolCalls.entries()) {
4755
4815
  const toolArgs = normalizedArgs[index];
4756
4816
  const preResult = precomputed.get(index);
4757
4817
  if (!preResult) onProgress({ type: "tool_call", tool: tc.name, toolArgs: JSON.stringify(tc.args), toolCallId: tc.id });
4758
- const toolResult = preResult ?? await executeTool(tc.name, toolArgs, fs9, fetcher);
4818
+ const toolResult = preResult ?? await executeTool(tc.name, toolArgs, fs10, fetcher, signal);
4759
4819
  const MAX_RESPONSE_CHARS = 5e4;
4760
4820
  const truncatedResponse = toolResult.success && (toolResult.truncated || toolResult.output.length > MAX_RESPONSE_CHARS);
4761
4821
  const llmOutput = toolResult.output.length > MAX_RESPONSE_CHARS ? toolResult.output.slice(0, MAX_RESPONSE_CHARS) + `
@@ -4941,7 +5001,7 @@ ${llmOutput}
4941
5001
  * parsed tasks if a plan was emitted mid-loop, or null if the loop
4942
5002
  * exhausted without a valid plan.
4943
5003
  */
4944
- async runResearchLoop(chat, firstMessage, fs9, onProgress, runners, initialLog, fetcher, userGoal, runnerModes, autonomousDefault, signal) {
5004
+ async runResearchLoop(chat, firstMessage, fs10, onProgress, runners, initialLog, fetcher, userGoal, runnerModes, autonomousDefault, signal) {
4945
5005
  const researchLog = initialLog ? [...initialLog] : [];
4946
5006
  if (!initialLog && userGoal) {
4947
5007
  researchLog.push({
@@ -4968,7 +5028,7 @@ ${llmOutput}
4968
5028
  if (!turn.hasToolCalls) break;
4969
5029
  const { toolResults, logEntries, resultsText } = await this.executeToolCalls(
4970
5030
  turn.toolCalls,
4971
- fs9,
5031
+ fs10,
4972
5032
  onProgress,
4973
5033
  fetcher,
4974
5034
  step,
@@ -5204,11 +5264,11 @@ ${repairHint}` }] : parts;
5204
5264
  return this.consumePlanStream(result, onToken);
5205
5265
  }
5206
5266
  // --- IAiService implementation ---
5207
- async researchAndPlan(userDescription, runners, modelsByRunner, fs9, onProgress, fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
5267
+ async researchAndPlan(userDescription, runners, modelsByRunner, fs10, onProgress, fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
5208
5268
  this.ensureInit();
5209
5269
  const { autonomousDefault } = modes;
5210
5270
  const genModel = this.getPlanningModel();
5211
- const contextStr = await BaseAiService.collectResearchContext(fs9, runners);
5271
+ const contextStr = await BaseAiService.collectResearchContext(fs10, runners);
5212
5272
  const systemPrompt = buildResearchPrompt(userDescription, contextStr, modelsByRunner, runners, runnerModes, modes);
5213
5273
  const firstMessage = `${buildResearchToolsPrompt()}
5214
5274
 
@@ -5219,17 +5279,17 @@ Explore the workspace to understand the codebase, then generate the plan.`;
5219
5279
  generationConfig: { temperature: 0.3, topP: 0.95, maxOutputTokens: 16384 }
5220
5280
  });
5221
5281
  const researchChat = new GeminiResearchChat(chat);
5222
- const result = await this.runResearchLoop(researchChat, firstMessage, fs9, onProgress, runners, void 0, fetcher, userDescription, runnerModes, autonomousDefault, signal);
5282
+ const result = await this.runResearchLoop(researchChat, firstMessage, fs10, onProgress, runners, void 0, fetcher, userDescription, runnerModes, autonomousDefault, signal);
5223
5283
  if (result.tasks) return { tasks: result.tasks, researchLog: result.researchLog, researchResults: result.researchResults };
5224
5284
  const fallback = await this.generatePlanFallback(userDescription, contextStr, result.researchResults, modelsByRunner, runners, onProgress, result.researchLog, runnerModes, autonomousDefault, signal, modes);
5225
5285
  return { tasks: fallback.tasks, researchLog: fallback.researchLog, researchResults: result.researchResults };
5226
5286
  }
5227
- async generatePlanDirect(userDescription, runners = ["claude-code"], modelsByRunner = {}, onToken, fs9, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, _signal) {
5287
+ async generatePlanDirect(userDescription, runners = ["claude-code"], modelsByRunner = {}, onToken, fs10, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, _signal) {
5228
5288
  this.ensureInit();
5229
5289
  const { autonomousDefault } = modes;
5230
5290
  let ctx = "";
5231
- if (fs9) {
5232
- const collected = await new ContextCollector(fs9).collect(runners[0] ?? "claude-code");
5291
+ if (fs10) {
5292
+ const collected = await new ContextCollector(fs10).collect(runners[0] ?? "claude-code");
5233
5293
  if (collected.aiflowContext) ctx = `
5234
5294
  <aiflow_context>
5235
5295
  ${collected.aiflowContext}
@@ -5245,12 +5305,12 @@ ${collected.aiflowContext}
5245
5305
  autonomousDefault
5246
5306
  );
5247
5307
  }
5248
- async modifyPlan(existingPlan, userRequest, modelsByRunner, onProgress, fs9, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, _signal) {
5308
+ async modifyPlan(existingPlan, userRequest, modelsByRunner, onProgress, fs10, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, _signal) {
5249
5309
  this.ensureInit();
5250
5310
  const { autonomousDefault } = modes;
5251
5311
  let aiflowContext;
5252
- if (fs9) {
5253
- const collected = await new ContextCollector(fs9).collect(existingPlan.runners[0] ?? "claude-code");
5312
+ if (fs10) {
5313
+ const collected = await new ContextCollector(fs10).collect(existingPlan.runners[0] ?? "claude-code");
5254
5314
  if (collected.aiflowContext) aiflowContext = collected.aiflowContext;
5255
5315
  }
5256
5316
  const prompt = buildModifyPlanPrompt(existingPlan, userRequest, modelsByRunner, aiflowContext, runnerModes, autonomousDefault);
@@ -5490,12 +5550,12 @@ ${buildResearchToolsPrompt(this.researchSubagentsEnabled)}` }
5490
5550
  }
5491
5551
  }
5492
5552
  // --- IAiService implementation ---
5493
- async researchAndPlan(userDescription, runners, modelsByRunner, fs9, onProgress, fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
5553
+ async researchAndPlan(userDescription, runners, modelsByRunner, fs10, onProgress, fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
5494
5554
  this.ensureInit();
5495
5555
  const client = this.getClient();
5496
5556
  const { autonomousDefault, researchSubagents: researchSubagentsEnabled } = modes;
5497
5557
  this.researchSubagentsEnabled = researchSubagentsEnabled;
5498
- const contextStr = await BaseAiService.collectResearchContext(fs9, runners);
5558
+ const contextStr = await BaseAiService.collectResearchContext(fs10, runners);
5499
5559
  const systemPrompt = buildResearchPrompt(userDescription, contextStr, modelsByRunner, runners, runnerModes, modes);
5500
5560
  const userText = `User goal: ${userDescription}`;
5501
5561
  const firstMessage = `${buildResearchToolsPrompt(researchSubagentsEnabled)}
@@ -5513,17 +5573,17 @@ ${userText}`;
5513
5573
  toOpenAiTools(researchSubagentsEnabled),
5514
5574
  (delta) => onProgress({ type: "thinking", text: delta })
5515
5575
  );
5516
- const result = await this.runResearchLoop(researchChat, firstMessage, fs9, onProgress, runners, void 0, fetcher, userDescription, runnerModes, autonomousDefault, signal);
5576
+ const result = await this.runResearchLoop(researchChat, firstMessage, fs10, onProgress, runners, void 0, fetcher, userDescription, runnerModes, autonomousDefault, signal);
5517
5577
  if (result.tasks) return { tasks: result.tasks, researchLog: result.researchLog, researchResults: result.researchResults };
5518
5578
  const fallback = await this.generatePlanFallback(userDescription, contextStr, result.researchResults, modelsByRunner, runners, onProgress, result.researchLog, runnerModes, autonomousDefault, signal, modes);
5519
5579
  return { tasks: fallback.tasks, researchLog: fallback.researchLog, researchResults: result.researchResults };
5520
5580
  }
5521
- async generatePlanDirect(userDescription, runners = ["claude-code"], modelsByRunner = {}, onToken, fs9, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
5581
+ async generatePlanDirect(userDescription, runners = ["claude-code"], modelsByRunner = {}, onToken, fs10, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
5522
5582
  this.ensureInit();
5523
5583
  const { autonomousDefault } = modes;
5524
5584
  let ctx = "";
5525
- if (fs9) {
5526
- const collected = await new ContextCollector(fs9).collect(runners[0] ?? "claude-code");
5585
+ if (fs10) {
5586
+ const collected = await new ContextCollector(fs10).collect(runners[0] ?? "claude-code");
5527
5587
  if (collected.aiflowContext) ctx = `
5528
5588
  <aiflow_context>
5529
5589
  ${collected.aiflowContext}
@@ -5539,12 +5599,12 @@ ${collected.aiflowContext}
5539
5599
  autonomousDefault
5540
5600
  );
5541
5601
  }
5542
- async modifyPlan(existingPlan, userRequest, modelsByRunner, onProgress, fs9, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
5602
+ async modifyPlan(existingPlan, userRequest, modelsByRunner, onProgress, fs10, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
5543
5603
  this.ensureInit();
5544
5604
  const { autonomousDefault } = modes;
5545
5605
  let aiflowContext;
5546
- if (fs9) {
5547
- const collected = await new ContextCollector(fs9).collect(existingPlan.runners[0] ?? "claude-code");
5606
+ if (fs10) {
5607
+ const collected = await new ContextCollector(fs10).collect(existingPlan.runners[0] ?? "claude-code");
5548
5608
  if (collected.aiflowContext) aiflowContext = collected.aiflowContext;
5549
5609
  }
5550
5610
  const prompt = buildModifyPlanPrompt(existingPlan, userRequest, modelsByRunner, aiflowContext, runnerModes, autonomousDefault);
@@ -6162,6 +6222,7 @@ var PlanStore = class {
6162
6222
  }
6163
6223
  remove(taskId) {
6164
6224
  if (!this._taskMap.has(taskId)) return;
6225
+ this.unblockDependents(taskId);
6165
6226
  this._planTasks = removeTaskFromPlan(this._planTasks, taskId);
6166
6227
  this.rebuild();
6167
6228
  this.pruneTerminalSets();
@@ -6216,9 +6277,7 @@ ${taskB.description}`,
6216
6277
  const userIds = /* @__PURE__ */ new Set([taskIdA, taskIdB]);
6217
6278
  const sanitized = this._planTasks.filter((t) => !userIds.has(t.id)).map((t) => ({
6218
6279
  ...t,
6219
- dependencies: t.dependencies.map(
6220
- (depId) => depId === taskIdA || depId === taskIdB ? mergedId : depId
6221
- )
6280
+ dependencies: [...new Set(t.dependencies.map((depId) => userIds.has(depId) ? mergedId : depId))]
6222
6281
  }));
6223
6282
  this._planTasks = renumberTasks([...sanitized, merged]);
6224
6283
  this.rebuild();
@@ -6230,11 +6289,12 @@ ${taskB.description}`,
6230
6289
  const original = this._taskMap.get(taskId);
6231
6290
  if (!original) throw new Error(`Task ${taskId} not found`);
6232
6291
  if (!newTaskSpecs.length) throw new Error("Must provide at least one new task spec");
6233
- const newTasks = newTaskSpecs.map(
6234
- (spec, i) => createTask({
6292
+ const finalized = [];
6293
+ newTaskSpecs.forEach((spec, i) => {
6294
+ finalized.push(createTask({
6235
6295
  ...spec,
6236
6296
  order: original.order + i,
6237
- dependencies: i === 0 ? [...original.dependencies] : [newTaskSpecs[0].id ?? `split-${taskId}-0`],
6297
+ dependencies: i === 0 ? [...original.dependencies] : [finalized[i - 1].id],
6238
6298
  type: spec.type ?? original.type,
6239
6299
  assignedRunner: spec.assignedRunner ?? original.assignedRunner,
6240
6300
  assignedModel: spec.assignedModel ?? original.assignedModel,
@@ -6242,17 +6302,7 @@ ${taskB.description}`,
6242
6302
  autonomy: spec.autonomy ?? original.autonomy,
6243
6303
  sliceType: spec.sliceType ?? original.sliceType,
6244
6304
  userStoriesCovered: spec.userStoriesCovered ?? original.userStoriesCovered
6245
- })
6246
- );
6247
- const newTaskIds = /* @__PURE__ */ new Map();
6248
- newTasks.forEach((t, i) => newTaskIds.set(i, t.id));
6249
- const finalized = newTasks.map((t) => {
6250
- const deps = t.dependencies.map((depId) => {
6251
- const idx = newTaskSpecs.findIndex((s) => (s.id ?? "") === depId);
6252
- if (idx >= 0) return newTaskIds.get(idx) ?? depId;
6253
- return depId;
6254
- });
6255
- return { ...t, dependencies: deps };
6305
+ }));
6256
6306
  });
6257
6307
  const tailId = finalized[finalized.length - 1].id;
6258
6308
  const updatedOthers = this._planTasks.filter((t) => t.id !== taskId).map((t) => ({
@@ -6497,6 +6547,15 @@ var TaskOrchestrator = class {
6497
6547
  get isRunning() {
6498
6548
  return this.running || this.startingTaskIds.size > 0 || this.activeTaskSessions.size > 0;
6499
6549
  }
6550
+ /**
6551
+ * A runner is executing *right now*. Narrower than {@link isRunning}, which
6552
+ * also covers the armed-but-idle scheduler `tick()` deliberately leaves
6553
+ * behind when a plan is paused on a user task, a checkpoint or a hold —
6554
+ * nothing is executing then, so nothing is reading the plan mid-mutation.
6555
+ */
6556
+ get hasLiveWork() {
6557
+ return this.activeTaskSessions.size > 0 || this.startingTaskIds.size > 0;
6558
+ }
6500
6559
  get isReviewApproved() {
6501
6560
  return this.reviewApproved;
6502
6561
  }
@@ -6543,18 +6602,31 @@ var TaskOrchestrator = class {
6543
6602
  this.retryCounts.clear();
6544
6603
  this.onHold.clear();
6545
6604
  }
6605
+ /**
6606
+ * Adopt an edited plan without letting go of the run in progress. Everything
6607
+ * the scheduler owns — live sessions, holds, retry counts, the verifier and
6608
+ * the review approval — survives, because `loadPlan` clears all of it and a
6609
+ * mid-run edit is not a new run. Only the tasks change.
6610
+ *
6611
+ * The ids with live sessions are defended: one dropped from the edited plan is
6612
+ * carried over (its verdict still has to land somewhere), and one whose status
6613
+ * the snapshot predates is put back to `in_progress` — adopting the snapshot's
6614
+ * "pending" would offer the scheduler work a runner is already doing.
6615
+ */
6546
6616
  reconcilePlan(newTasks, planRunners = ["claude-code"]) {
6617
+ const adopted = [...newTasks];
6547
6618
  for (const taskId of this.activeTaskSessions.keys()) {
6548
6619
  const task = this.store.get(taskId);
6549
6620
  if (!task) continue;
6550
- const inNewPlan = newTasks.find((t) => t.id === taskId);
6551
- if (!inNewPlan) {
6552
- newTasks.push({ ...task });
6553
- } else if (inNewPlan.status !== "in_progress") {
6554
- console.warn(`[TaskOrchestrator] Running task "${task.title}" status changed to "${inNewPlan.status}" in modified plan, using orchestrator truth`);
6621
+ const at = adopted.findIndex((t) => t.id === taskId);
6622
+ if (at < 0) {
6623
+ adopted.push({ ...task });
6624
+ } else if (adopted[at].status !== "in_progress") {
6625
+ console.warn(`[TaskOrchestrator] Running task "${task.title}" status changed to "${adopted[at].status}" in modified plan, using orchestrator truth`);
6626
+ adopted[at] = { ...adopted[at], status: "in_progress" };
6555
6627
  }
6556
6628
  }
6557
- this.store.load(newTasks, planRunners);
6629
+ this.store.load(adopted, planRunners);
6558
6630
  this.planStatus = "running";
6559
6631
  }
6560
6632
  async start() {
@@ -6675,6 +6747,21 @@ ${output.slice(-3e3)}`);
6675
6747
  this.emit("onTaskChanged");
6676
6748
  await this.tick();
6677
6749
  }
6750
+ /**
6751
+ * Let go of a task that is leaving the plan. A live runner is cancelled
6752
+ * through {@link cancelTask} so the verifier generation is bumped before the
6753
+ * process dies; a spawn still in flight is dropped from `startingTaskIds`,
6754
+ * which is what makes {@link startTask} kill the session it is about to
6755
+ * receive. The id's remaining bookkeeping goes too — a hold or retry count
6756
+ * kept for a task that no longer exists would be inherited by nothing and
6757
+ * leaves `isRunning` reading true forever.
6758
+ */
6759
+ async releaseTask(taskId) {
6760
+ this.startingTaskIds.delete(taskId);
6761
+ if (this.activeTaskSessions.has(taskId)) await this.cancelTask(taskId);
6762
+ this.onHold.delete(taskId);
6763
+ this.retryCounts.delete(taskId);
6764
+ }
6678
6765
  async markTaskComplete(taskId) {
6679
6766
  const task = this.store.get(taskId);
6680
6767
  if (!task || task.status === "completed") return;
@@ -7134,28 +7221,28 @@ function posixBinDirs() {
7134
7221
  ];
7135
7222
  }
7136
7223
  function windowsBinDirs(env, home) {
7137
- const join13 = (...parts) => path5.win32.join(...parts);
7138
- const under = (base, ...parts) => base ? [join13(base, ...parts)] : [];
7224
+ const join14 = (...parts) => path5.win32.join(...parts);
7225
+ const under = (base, ...parts) => base ? [join14(base, ...parts)] : [];
7139
7226
  const appData = env.APPDATA;
7140
7227
  const localAppData = env.LOCALAPPDATA;
7141
7228
  const programData = env.ProgramData;
7142
7229
  return [
7143
7230
  // Native per-user installers, PowerShell-driven and otherwise.
7144
- join13(home, ".local", "bin"),
7145
- join13(home, ".opencode", "bin"),
7146
- join13(home, ".claude", "bin"),
7147
- join13(home, ".codex", "bin"),
7231
+ join14(home, ".local", "bin"),
7232
+ join14(home, ".opencode", "bin"),
7233
+ join14(home, ".claude", "bin"),
7234
+ join14(home, ".codex", "bin"),
7148
7235
  ...under(localAppData, "Programs", "claude"),
7149
7236
  // Node package managers. npm's global prefix is `%APPDATA%\npm`; the
7150
7237
  // literal spelling is kept as a fallback for a host that does not export
7151
7238
  // APPDATA (a service account, a stripped CI container).
7152
7239
  ...under(appData, "npm"),
7153
- join13(home, "AppData", "Roaming", "npm"),
7240
+ join14(home, "AppData", "Roaming", "npm"),
7154
7241
  ...under(localAppData, "pnpm"),
7155
7242
  ...under(localAppData, "Yarn", "bin"),
7156
- join13(home, ".bun", "bin"),
7243
+ join14(home, ".bun", "bin"),
7157
7244
  // Windows package managers.
7158
- join13(home, "scoop", "shims"),
7245
+ join14(home, "scoop", "shims"),
7159
7246
  ...under(env.SCOOP, "shims"),
7160
7247
  ...under(env.SCOOP_GLOBAL ?? programData, "scoop", "shims"),
7161
7248
  ...under(programData, "chocolatey", "bin"),
@@ -7165,7 +7252,7 @@ function windowsBinDirs(env, home) {
7165
7252
  ...under(localAppData, "Microsoft", "WindowsApps"),
7166
7253
  // Version managers. Volta's Windows home is not the POSIX `~/.volta`.
7167
7254
  ...under(localAppData, "Volta", "bin"),
7168
- join13(home, ".volta", "bin")
7255
+ join14(home, ".volta", "bin")
7169
7256
  ];
7170
7257
  }
7171
7258
  function wellKnownBinDirs(deps = {}) {
@@ -7288,6 +7375,26 @@ Install the native executable \u2014 Claude Code's Windows installer, Codex's re
7288
7375
  function hasLineBreak(parts) {
7289
7376
  return parts.some((part) => /[\r\n]/.test(part));
7290
7377
  }
7378
+ var ExecutableNotFoundError = class extends Error {
7379
+ constructor(command, searchedPath) {
7380
+ super(`Could not find "${command}" on PATH.
7381
+
7382
+ Searched: ${searchedPath || "(empty PATH)"}`);
7383
+ this.command = command;
7384
+ this.searchedPath = searchedPath;
7385
+ this.name = "ExecutableNotFoundError";
7386
+ }
7387
+ command;
7388
+ searchedPath;
7389
+ };
7390
+ function isResolvableOnPosixPath(command, PATH, exists) {
7391
+ if (command.includes("/")) return exists(command);
7392
+ return PATH.split(path6.delimiter).map((d) => d.trim()).filter(Boolean).some((dir) => exists(path6.join(dir, command)));
7393
+ }
7394
+ function isExecutableResolved(command, plan, PATH, deps = {}) {
7395
+ if ((deps.platform ?? process.platform) === "win32") return plan.file !== command;
7396
+ return isResolvableOnPosixPath(command, PATH, deps.exists ?? defaultExists2);
7397
+ }
7291
7398
  function defaultExists2(candidate) {
7292
7399
  try {
7293
7400
  return fsSync2.statSync(candidate).isFile();
@@ -8047,6 +8154,10 @@ var ModelDiscovery = class {
8047
8154
  clear() {
8048
8155
  this.cache.clear();
8049
8156
  }
8157
+ /** Whatever `discover()` has already cached for `runner` — never triggers discovery. */
8158
+ getCached(runner) {
8159
+ return this.cache.get(runner);
8160
+ }
8050
8161
  };
8051
8162
 
8052
8163
  // src/services/ModelCatalog.ts
@@ -8365,6 +8476,17 @@ var ModelResolver = class {
8365
8476
  refreshRunnerModels() {
8366
8477
  this.discovery.clear();
8367
8478
  }
8479
+ /**
8480
+ * Synchronous reads of whatever the last `modelsForRunners`/`pickerOptions`
8481
+ * call already cached — never trigger discovery or a fetch. For callers
8482
+ * (e.g. a settings write) that need a catalog without going async.
8483
+ */
8484
+ getCachedRunnerModels(runner) {
8485
+ return this.discovery.getCached(runner) ?? [];
8486
+ }
8487
+ getCachedPickerOptions() {
8488
+ return this.cachedPickerOptions ?? [];
8489
+ }
8368
8490
  invalidate() {
8369
8491
  this.discovery.clear();
8370
8492
  this.cachedPickerOptions = null;
@@ -8492,6 +8614,28 @@ var RunnerInstallation = class {
8492
8614
  // src/services/harness/CliAgentAiService.ts
8493
8615
  var import_child_process5 = require("child_process");
8494
8616
 
8617
+ // src/utils/workspace.ts
8618
+ var fs3 = __toESM(require("fs"));
8619
+ var WorkspaceNotFoundError = class extends Error {
8620
+ constructor(workspace) {
8621
+ super(`Workspace "${workspace}" does not exist.`);
8622
+ this.workspace = workspace;
8623
+ this.name = "WorkspaceNotFoundError";
8624
+ }
8625
+ workspace;
8626
+ };
8627
+ function defaultIsDirectory(candidate) {
8628
+ try {
8629
+ return fs3.statSync(candidate).isDirectory();
8630
+ } catch {
8631
+ return false;
8632
+ }
8633
+ }
8634
+ function assertWorkspaceExists(workspace, deps = {}) {
8635
+ const isDirectory = deps.isDirectory ?? defaultIsDirectory;
8636
+ if (!isDirectory(workspace)) throw new WorkspaceNotFoundError(workspace);
8637
+ }
8638
+
8495
8639
  // src/services/harness/AgentAdapter.ts
8496
8640
  var LineBuffer = class {
8497
8641
  buffer = "";
@@ -8545,6 +8689,7 @@ var StdioAgentAdapter = class {
8545
8689
  async handshake(_opts) {
8546
8690
  }
8547
8691
  async start(opts) {
8692
+ assertWorkspaceExists(opts.cwd, { isDirectory: this.deps.isDirectory });
8548
8693
  this.processEnded = new Promise((resolve3) => {
8549
8694
  this.markEnded = resolve3;
8550
8695
  });
@@ -8556,6 +8701,9 @@ var StdioAgentAdapter = class {
8556
8701
  platform: this.deps.platform,
8557
8702
  resolvePath
8558
8703
  });
8704
+ if (!isExecutableResolved(spec.command, launch, PATH, { platform: this.deps.platform, exists: this.deps.exists })) {
8705
+ throw new ExecutableNotFoundError(spec.command, PATH);
8706
+ }
8559
8707
  this.process = this.deps.spawn(launch.file, launch.args, {
8560
8708
  env: this.spawnEnv,
8561
8709
  stdio: ["pipe", "pipe", "pipe"],
@@ -9178,12 +9326,16 @@ var OpenCodeAdapter = class {
9178
9326
  opts = null;
9179
9327
  async start(opts) {
9180
9328
  this.opts = opts;
9329
+ assertWorkspaceExists(opts.cwd, { isDirectory: this.deps.isDirectory });
9181
9330
  const resolvePath = this.deps.resolvePath ?? augmentedPath;
9182
9331
  const PATH = await resolvePath();
9183
9332
  const launch = await planDirectLaunch("opencode", ["serve", "--hostname", "127.0.0.1", "--port", "0"], {
9184
9333
  platform: this.deps.platform,
9185
9334
  resolvePath
9186
9335
  });
9336
+ if (!isExecutableResolved("opencode", launch, PATH, { platform: this.deps.platform, exists: this.deps.exists })) {
9337
+ throw new ExecutableNotFoundError("opencode", PATH);
9338
+ }
9187
9339
  this.process = this.deps.spawn(launch.file, launch.args, {
9188
9340
  env: withPath(process.env, PATH),
9189
9341
  stdio: ["pipe", "pipe", "pipe"],
@@ -9497,7 +9649,9 @@ var CliAgentAiService = class {
9497
9649
  spawn: deps.spawn ?? import_child_process5.spawn,
9498
9650
  fetch: deps.fetch ?? globalThis.fetch,
9499
9651
  resolvePath: deps.resolvePath,
9500
- platform: deps.platform
9652
+ platform: deps.platform,
9653
+ isDirectory: deps.isDirectory,
9654
+ exists: deps.exists
9501
9655
  };
9502
9656
  this.makeAdapter = deps.createAdapter ?? defaultAdapter;
9503
9657
  this.workspaceRoot = deps.workspaceRoot ?? (() => process.cwd());
@@ -9842,8 +9996,8 @@ var CliAgentAiService = class {
9842
9996
  this.lastNativeSessionId = previousSessionId;
9843
9997
  }
9844
9998
  }
9845
- async researchAndPlan(userDescription, runners, modelsByRunner, fs9, onProgress, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
9846
- const contextStr = await collectResearchContext(fs9, runners);
9999
+ async researchAndPlan(userDescription, runners, modelsByRunner, fs10, onProgress, _fetcher, runnerModes, modes = DEFAULT_PLANNER_MODES, signal) {
10000
+ const contextStr = await collectResearchContext(fs10, runners);
9847
10001
  const researchLog = [
9848
10002
  { id: `up-${Date.now()}`, type: "user_prompt", content: userDescription, timestamp: (/* @__PURE__ */ new Date()).toISOString() }
9849
10003
  ];
@@ -10213,16 +10367,16 @@ function executionSummary(tasks) {
10213
10367
  }
10214
10368
 
10215
10369
  // src/utils/sessionStore.ts
10216
- var fs4 = __toESM(require("fs"));
10370
+ var fs5 = __toESM(require("fs"));
10217
10371
  var path10 = __toESM(require("path"));
10218
10372
 
10219
10373
  // src/utils/fsHelpers.ts
10220
- var fs3 = __toESM(require("fs"));
10374
+ var fs4 = __toESM(require("fs"));
10221
10375
  var path9 = __toESM(require("path"));
10222
10376
  var STATE_DIR = ".ordewell";
10223
10377
  function ensureDir(dir) {
10224
- if (!fs3.existsSync(dir)) {
10225
- fs3.mkdirSync(dir, { recursive: true });
10378
+ if (!fs4.existsSync(dir)) {
10379
+ fs4.mkdirSync(dir, { recursive: true });
10226
10380
  }
10227
10381
  }
10228
10382
  function getStateDir(baseDir) {
@@ -10267,16 +10421,16 @@ function saveSession(plan, goal, baseDir, id) {
10267
10421
  };
10268
10422
  const filename = sessionFilename(meta);
10269
10423
  const filePath = path10.join(sessionsDir, filename);
10270
- fs4.writeFileSync(filePath, JSON.stringify(session, null, 2));
10424
+ fs5.writeFileSync(filePath, JSON.stringify(session, null, 2));
10271
10425
  return meta;
10272
10426
  }
10273
10427
  function listSessions(baseDir, logger = defaultLogger) {
10274
10428
  const sessionsDir = getSessionsDir(baseDir);
10275
- if (!fs4.existsSync(sessionsDir)) return [];
10276
- const files = fs4.readdirSync(sessionsDir).filter((f) => f.endsWith(".json"));
10429
+ if (!fs5.existsSync(sessionsDir)) return [];
10430
+ const files = fs5.readdirSync(sessionsDir).filter((f) => f.endsWith(".json"));
10277
10431
  return files.map((f) => {
10278
10432
  try {
10279
- const raw = fs4.readFileSync(path10.join(sessionsDir, f), "utf-8");
10433
+ const raw = fs5.readFileSync(path10.join(sessionsDir, f), "utf-8");
10280
10434
  const parsed = JSON.parse(raw);
10281
10435
  if (hasScalarRunner(parsed)) {
10282
10436
  logger.warn("sessionStore", `session file ${f}: ${REJECTION_MESSAGE}`);
@@ -10291,10 +10445,10 @@ function listSessions(baseDir, logger = defaultLogger) {
10291
10445
  }).filter((m) => m !== null).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
10292
10446
  }
10293
10447
  function findSessionFile(sessionsDir, sessionId, logger) {
10294
- const files = fs4.readdirSync(sessionsDir).filter((f) => f.endsWith(".json"));
10448
+ const files = fs5.readdirSync(sessionsDir).filter((f) => f.endsWith(".json"));
10295
10449
  for (const f of files) {
10296
10450
  try {
10297
- const raw = fs4.readFileSync(path10.join(sessionsDir, f), "utf-8");
10451
+ const raw = fs5.readFileSync(path10.join(sessionsDir, f), "utf-8");
10298
10452
  const session = JSON.parse(raw);
10299
10453
  if (session.meta.id === sessionId) return f;
10300
10454
  } catch (err) {
@@ -10305,11 +10459,11 @@ function findSessionFile(sessionsDir, sessionId, logger) {
10305
10459
  }
10306
10460
  function loadSession(sessionId, baseDir, logger = defaultLogger) {
10307
10461
  const sessionsDir = getSessionsDir(baseDir);
10308
- if (!fs4.existsSync(sessionsDir)) return null;
10462
+ if (!fs5.existsSync(sessionsDir)) return null;
10309
10463
  try {
10310
10464
  const file = findSessionFile(sessionsDir, sessionId, logger);
10311
10465
  if (file) {
10312
- const raw = fs4.readFileSync(path10.join(sessionsDir, file), "utf-8");
10466
+ const raw = fs5.readFileSync(path10.join(sessionsDir, file), "utf-8");
10313
10467
  const parsed = JSON.parse(raw);
10314
10468
  if (hasScalarRunner(parsed)) {
10315
10469
  logger.warn("sessionStore", `session file ${file}: ${REJECTION_MESSAGE}`);
@@ -10339,11 +10493,11 @@ function getLatestSession(baseDir, logger = defaultLogger) {
10339
10493
  }
10340
10494
  function deleteSession(sessionId, baseDir, logger = defaultLogger) {
10341
10495
  const sessionsDir = getSessionsDir(baseDir);
10342
- if (!fs4.existsSync(sessionsDir)) return false;
10496
+ if (!fs5.existsSync(sessionsDir)) return false;
10343
10497
  try {
10344
10498
  const file = findSessionFile(sessionsDir, sessionId, logger);
10345
10499
  if (file) {
10346
- fs4.unlinkSync(path10.join(sessionsDir, file));
10500
+ fs5.unlinkSync(path10.join(sessionsDir, file));
10347
10501
  return true;
10348
10502
  }
10349
10503
  } catch (err) {
@@ -10353,6 +10507,12 @@ function deleteSession(sessionId, baseDir, logger = defaultLogger) {
10353
10507
  }
10354
10508
 
10355
10509
  // src/services/createSession.ts
10510
+ var PlanEditError = class extends Error {
10511
+ constructor(message) {
10512
+ super(message);
10513
+ this.name = "PlanEditError";
10514
+ }
10515
+ };
10356
10516
  function sessionRuntimeSettings(settings) {
10357
10517
  return { ...plannerRuntimeToggles(settings), modelAllowlist: settings.modelAllowlist };
10358
10518
  }
@@ -10625,12 +10785,41 @@ var Session = class {
10625
10785
  get isExecuting() {
10626
10786
  return this.orchestrator.isRunning;
10627
10787
  }
10788
+ /** See {@link TaskOrchestrator.hasLiveWork} — a spawned runner, not merely an armed scheduler. */
10789
+ get hasLiveWork() {
10790
+ return this.orchestrator.hasLiveWork;
10791
+ }
10628
10792
  get status() {
10629
10793
  return this.orchestrator.status;
10630
10794
  }
10631
10795
  get sessionConfig() {
10632
10796
  return this.config;
10633
10797
  }
10798
+ /**
10799
+ * Deny every parked approval as soon as a planning turn is aborted, for the
10800
+ * same reason `beginFreshPlan` does it: a prompt raised by a turn nobody is
10801
+ * waiting on has no one left to serve, and denying it unblocks the research
10802
+ * loop instead of stranding it. Without this the request sat out its
10803
+ * five-minute timeout and the loop then carried on as if nothing had
10804
+ * happened — the abort was real, but invisible until long after the user
10805
+ * pressed stop.
10806
+ *
10807
+ * The listener is returned as a disposer rather than left attached: callers
10808
+ * own the signal and may reuse it, and a leaked listener would deny the
10809
+ * *next* turn's prompts the moment that stale signal aborted.
10810
+ */
10811
+ denyApprovalsOnAbort(signal) {
10812
+ if (!signal) return () => {
10813
+ };
10814
+ if (signal.aborted) {
10815
+ this.approvals.clear();
10816
+ return () => {
10817
+ };
10818
+ }
10819
+ const onAbort = () => this.approvals.clear();
10820
+ signal.addEventListener("abort", onAbort);
10821
+ return () => signal.removeEventListener("abort", onAbort);
10822
+ }
10634
10823
  async startExecution() {
10635
10824
  if (!this.plan || !this.store.planTasks.length) throw new Error("No plan to execute");
10636
10825
  this.store.clearLog();
@@ -10653,19 +10842,25 @@ var Session = class {
10653
10842
  const settings = this.settingsFn();
10654
10843
  const { modelAllowlist } = settings;
10655
10844
  const modes = plannerModesFrom(settings, this.config.autonomousMode);
10656
- const plan = await this.planner.generate({
10657
- goal,
10658
- runners: chosenRunners,
10659
- modelsByRunner,
10660
- runnerModes,
10661
- autonomousDefault: this.config.autonomousMode,
10662
- fs: this.fsAdapter,
10663
- fetcher: this.fetcher,
10664
- onProgress: (p) => this.translateProgress(p),
10665
- signal: options?.signal,
10666
- perRunnerAllowlist: modelAllowlist,
10667
- modes
10668
- });
10845
+ const releaseAbort = this.denyApprovalsOnAbort(options?.signal);
10846
+ let plan;
10847
+ try {
10848
+ plan = await this.planner.generate({
10849
+ goal,
10850
+ runners: chosenRunners,
10851
+ modelsByRunner,
10852
+ runnerModes,
10853
+ autonomousDefault: this.config.autonomousMode,
10854
+ fs: this.fsAdapter,
10855
+ fetcher: this.fetcher,
10856
+ onProgress: (p) => this.translateProgress(p),
10857
+ signal: options?.signal,
10858
+ perRunnerAllowlist: modelAllowlist,
10859
+ modes
10860
+ });
10861
+ } finally {
10862
+ releaseAbort();
10863
+ }
10669
10864
  this.plan = plan;
10670
10865
  this.orchestrator.loadPlan(plan.tasks, plan.runners);
10671
10866
  this.store.resetForRun({ preserveCompleted: false });
@@ -10702,23 +10897,28 @@ var Session = class {
10702
10897
  researchLog: [{ id: `up-${Date.now()}`, type: "user_prompt", content: goal, timestamp: now }],
10703
10898
  conversationHistory: [{ role: "user", content: goal, timestamp: now }]
10704
10899
  };
10705
- const turn = await this.aiService.startConversation({
10706
- goal,
10707
- runners: chosenRunners,
10708
- modelsByRunner: filteredModels,
10709
- runnerModes,
10710
- autonomousDefault: this.config.autonomousMode,
10711
- grillMeEnabled: grillMeEnabled ?? false,
10712
- prdEnabled: prdEnabled ?? false,
10713
- reviewEnabled: reviewEnabled ?? false,
10714
- verificationEnabled: verificationEnabled ?? false,
10715
- researchSubagentsEnabled: researchSubagentsEnabled ?? false,
10716
- fs: this.fsAdapter,
10717
- fetcher: this.fetcher,
10718
- onProgress: (p) => this.translateProgress(p),
10719
- signal: options?.signal
10720
- });
10721
- return this.settleTurn(turn, options);
10900
+ const releaseAbort = this.denyApprovalsOnAbort(options?.signal);
10901
+ try {
10902
+ const turn = await this.aiService.startConversation({
10903
+ goal,
10904
+ runners: chosenRunners,
10905
+ modelsByRunner: filteredModels,
10906
+ runnerModes,
10907
+ autonomousDefault: this.config.autonomousMode,
10908
+ grillMeEnabled: grillMeEnabled ?? false,
10909
+ prdEnabled: prdEnabled ?? false,
10910
+ reviewEnabled: reviewEnabled ?? false,
10911
+ verificationEnabled: verificationEnabled ?? false,
10912
+ researchSubagentsEnabled: researchSubagentsEnabled ?? false,
10913
+ fs: this.fsAdapter,
10914
+ fetcher: this.fetcher,
10915
+ onProgress: (p) => this.translateProgress(p),
10916
+ signal: options?.signal
10917
+ });
10918
+ return await this.settleTurn(turn, options);
10919
+ } finally {
10920
+ releaseAbort();
10921
+ }
10722
10922
  }
10723
10923
  /**
10724
10924
  * Every subsequent user reply in the planning conversation — grill-me
@@ -10736,21 +10936,26 @@ var Session = class {
10736
10936
  ${userMessage}` : userMessage;
10737
10937
  const aiService = this.aiService;
10738
10938
  const canContinueLive = aiService.hasActiveConversation() && (aiService.conversationMatchesConfig?.() ?? true);
10739
- let turn = canContinueLive ? await aiService.continueConversation(
10740
- outgoing,
10741
- (p) => this.translateProgress(p),
10742
- options?.signal
10743
- ) : await this.resumeConversation(outgoing, priorHistory, options);
10744
- if ((turn.kind === "task_ops" || turn.kind === "plan") && this.isExecuting) {
10745
- this.queueMessage(userMessage);
10746
- this.plan.queuedMessages = this.getQueuedMessages();
10747
- turn = {
10748
- kind: "message",
10749
- text: `Execution is running, so I queued your change \u2014 it will be applied between task batches (${this.queuedCount} queued).`,
10750
- researchLog: turn.researchLog
10751
- };
10939
+ const releaseAbort = this.denyApprovalsOnAbort(options?.signal);
10940
+ try {
10941
+ let turn = canContinueLive ? await aiService.continueConversation(
10942
+ outgoing,
10943
+ (p) => this.translateProgress(p),
10944
+ options?.signal
10945
+ ) : await this.resumeConversation(outgoing, priorHistory, options);
10946
+ if ((turn.kind === "task_ops" || turn.kind === "plan") && this.hasLiveWork) {
10947
+ this.queueMessage(userMessage);
10948
+ this.plan.queuedMessages = this.getQueuedMessages();
10949
+ turn = {
10950
+ kind: "message",
10951
+ text: `Execution is running, so I queued your change \u2014 it will be applied between task batches (${this.queuedCount} queued).`,
10952
+ researchLog: turn.researchLog
10953
+ };
10954
+ }
10955
+ return await this.settleTurn(turn, options);
10956
+ } finally {
10957
+ releaseAbort();
10752
10958
  }
10753
- return this.settleTurn(turn, options);
10754
10959
  }
10755
10960
  /**
10756
10961
  * Drive a planner turn to a persisted, broadcast outcome. Task edits apply
@@ -10789,7 +10994,10 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
10789
10994
  maxRepairs: 2,
10790
10995
  onExhausted: ({ reply, errors }) => invalidOps(errors, reply.researchLog)
10791
10996
  });
10792
- if ("plan" in settled) return settled.plan;
10997
+ if ("plan" in settled) {
10998
+ await this.orchestrator.tick();
10999
+ return settled.plan;
11000
+ }
10793
11001
  return this.applyConversationTurn(settled.turn);
10794
11002
  }
10795
11003
  /**
@@ -10804,9 +11012,9 @@ The plan is unchanged. Rephrase the request, or adjust the tasks manually.`,
10804
11012
  const lines = this.store.planTasks.map(
10805
11013
  (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(", ")}]`
10806
11014
  );
10807
- const running = this.store.planTasks.filter((t) => t.status === "in_progress");
10808
- const execNote = this.isExecuting ? `
10809
- 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.` : "";
11015
+ const locked = this.store.planTasks.filter((t) => t.status === "in_progress");
11016
+ const execNote = this.hasLiveWork ? `
11017
+ 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.` : "";
10810
11018
  const modelLines = [];
10811
11019
  for (const runner of this.plan.runners) {
10812
11020
  const models = this.modelsCache[runner] ?? [];
@@ -10848,8 +11056,12 @@ Execution is RUNNING${running.length ? ` \u2014 these tasks are locked: ${runnin
10848
11056
  this.plan.researchLog = [...this.plan.researchLog ?? [], ...turn.researchLog];
10849
11057
  const allowlist = this.settingsFn().modelAllowlist ?? this.currentAllowlist;
10850
11058
  const coerced = coerceAssignments(result.tasks, allowlist, this.plan.runners, this.modelsCache);
10851
- this.orchestrator.loadPlan(coerced, this.plan.runners);
10852
- this.store.resetForRun();
11059
+ if (this.orchestrator.isRunning) {
11060
+ this.orchestrator.reconcilePlan(coerced, this.plan.runners);
11061
+ } else {
11062
+ this.orchestrator.loadPlan(coerced, this.plan.runners);
11063
+ this.store.resetForRun();
11064
+ }
10853
11065
  this.appendTranscript("assistant", content, now);
10854
11066
  return true;
10855
11067
  },
@@ -11085,8 +11297,33 @@ Execution is RUNNING${running.length ? ` \u2014 these tasks are locked: ${runnin
11085
11297
  notify();
11086
11298
  return this.plan;
11087
11299
  }
11088
- updateTask(taskId, changes) {
11089
- return this.mutatePlan(
11300
+ /**
11301
+ * The direct-edit seam: {@link mutatePlan} plus the reschedule every
11302
+ * structural edit owes an armed scheduler. Nothing else wakes one after a
11303
+ * hand edit — the queue-drain path never runs, because a direct edit never
11304
+ * queues — so a task the edit just unblocked would sit ready and never
11305
+ * start. `tick()` no-ops while the scheduler is idle, so this costs nothing
11306
+ * during plain planning. The planner-driven path re-ticks in
11307
+ * {@link settleTurn} instead; it must not tick twice.
11308
+ */
11309
+ async editPlan(op, notify) {
11310
+ const plan = this.mutatePlan(op, notify);
11311
+ if (!plan) return null;
11312
+ await this.orchestrator.tick();
11313
+ return plan;
11314
+ }
11315
+ /**
11316
+ * Patch one task's fields. A hand-set dependency list is the one patch that
11317
+ * can leave the plan unschedulable, so it goes through the same
11318
+ * {@link canSetDependencies} guard the planner's pickers use rather than a
11319
+ * second copy of the rule — and throws, so the surface can say why.
11320
+ */
11321
+ async updateTask(taskId, changes) {
11322
+ if (changes.dependencies) {
11323
+ const check = canSetDependencies(this.store.planTasks, taskId, changes.dependencies);
11324
+ if (!check.ok) throw new PlanEditError(check.error ?? "Those dependencies are not valid");
11325
+ }
11326
+ return this.editPlan(
11090
11327
  () => Boolean(this.store.update(taskId, changes)),
11091
11328
  () => this.broadcast({ type: "task_updated", taskId, changes })
11092
11329
  );
@@ -11096,8 +11333,7 @@ Execution is RUNNING${running.length ? ` \u2014 these tasks are locked: ${runnin
11096
11333
  * because a runner change is never a single-field edit: the task's model,
11097
11334
  * thinking effort and mode are all scoped to its runner, so they are
11098
11335
  * re-derived from the new runner's catalog (see {@link retargetTaskRunner}).
11099
- * That needs discovery, which is async — hence a method of its own rather
11100
- * than a branch inside the sync `updateTask`.
11336
+ * That needs discovery, which is why it is not a branch inside `updateTask`.
11101
11337
  *
11102
11338
  * The runner is also admitted into `plan.runners` — see {@link admitRunner}.
11103
11339
  */
@@ -11109,7 +11345,7 @@ Execution is RUNNING${running.length ? ` \u2014 these tasks are locked: ${runnin
11109
11345
  const catalog = await this.catalogFor(runner);
11110
11346
  const changes = retargetTaskRunner(task, runner, this.allowedCatalog(catalog, runner));
11111
11347
  if (Object.keys(changes).length === 0) return this.plan;
11112
- return this.mutatePlan(() => {
11348
+ return this.editPlan(() => {
11113
11349
  if (!this.store.update(taskId, changes)) return false;
11114
11350
  this.admitRunner(runner, catalog.models);
11115
11351
  return true;
@@ -11156,24 +11392,29 @@ Execution is RUNNING${running.length ? ` \u2014 these tasks are locked: ${runnin
11156
11392
  if (models.length > 0) this.modelsCache = { ...this.modelsCache, [runner]: models };
11157
11393
  }
11158
11394
  /**
11159
- * Replace one task's dependency list. Separate from {@link updateTask}
11160
- * because a hand-edited graph is the one task edit that can leave a plan
11161
- * unschedulable: `canSetDependencies` is the guard, and it lives behind this
11162
- * one method so the API and both surfaces' pickers reject the same edits
11163
- * rather than each carrying a copy of the rule. Throws so a surface can say
11164
- * why the edit was refused.
11395
+ * Replace one task's dependency list — the named entry point the surfaces'
11396
+ * dependency pickers call. The guard itself lives in {@link updateTask}, so
11397
+ * a dependency list arriving as a plain field patch is rejected by the same
11398
+ * rule instead of slipping past it.
11165
11399
  */
11166
- setTaskDependencies(taskId, dependencies) {
11400
+ async setTaskDependencies(taskId, dependencies) {
11167
11401
  if (!this.plan) return null;
11168
- const check = canSetDependencies(this.store.planTasks, taskId, dependencies);
11169
- if (!check.ok) throw new Error(check.error ?? "Those dependencies are not valid");
11170
11402
  return this.updateTask(taskId, { dependencies });
11171
11403
  }
11172
11404
  async completeTask(taskId) {
11173
11405
  await this.markTaskComplete(taskId);
11174
11406
  }
11175
- removeTask(taskId) {
11176
- return this.mutatePlan(() => (this.store.remove(taskId), true));
11407
+ /**
11408
+ * Delete one task. A running task is cancelled first: the plan can drop it
11409
+ * either way, but nothing can reach its runner afterwards — the tmux session
11410
+ * outlives the plan and the orchestrator keeps counting it as active. The
11411
+ * planner-driven path refuses instead (see {@link applyTaskOps}); a user
11412
+ * deleting their own task means it.
11413
+ */
11414
+ async removeTask(taskId) {
11415
+ if (!this.plan || !this.store.get(taskId)) return null;
11416
+ await this.orchestrator.releaseTask(taskId);
11417
+ return this.editPlan(() => (this.store.remove(taskId), true));
11177
11418
  }
11178
11419
  /**
11179
11420
  * Add one task, filling in whatever the caller left unset. A task with no
@@ -11193,20 +11434,20 @@ Execution is RUNNING${running.length ? ` \u2014 these tasks are locked: ${runnin
11193
11434
  const runner = draft.assignedRunner ?? this.plan.runners[0];
11194
11435
  const dependencies = (draft.dependencies ?? []).filter((id) => this.store.get(id));
11195
11436
  const catalog = draft.type === "user" ? null : await this.catalogFor(runner);
11196
- return this.mutatePlan(() => {
11437
+ return this.editPlan(() => {
11197
11438
  this.store.add({ ...draft, ...catalog ? runnerAssignment(this.allowedCatalog(catalog, runner), draft) : {}, assignedRunner: runner, dependencies });
11198
11439
  if (catalog) this.admitRunner(runner, catalog.models);
11199
11440
  return true;
11200
11441
  });
11201
11442
  }
11202
- mergeTasks(taskIdA, taskIdB) {
11203
- return this.mutatePlan(() => (this.store.merge(taskIdA, taskIdB), true));
11443
+ async mergeTasks(taskIdA, taskIdB) {
11444
+ return this.editPlan(() => (this.store.merge(taskIdA, taskIdB), true));
11204
11445
  }
11205
- mergeMultipleTasks(taskIds) {
11206
- return this.mutatePlan(() => (this.store.mergeMultiple(taskIds), true));
11446
+ async mergeMultipleTasks(taskIds) {
11447
+ return this.editPlan(() => (this.store.mergeMultiple(taskIds), true));
11207
11448
  }
11208
- splitTask(taskId, newTasks) {
11209
- return this.mutatePlan(() => (this.store.split(taskId, newTasks), true));
11449
+ async splitTask(taskId, newTasks) {
11450
+ return this.editPlan(() => (this.store.split(taskId, newTasks), true));
11210
11451
  }
11211
11452
  /**
11212
11453
  * Planner-driven merge: validate compatibility up front, then ask the planner
@@ -11809,6 +12050,28 @@ function hasTmux(probe = defaultProbe) {
11809
12050
  return false;
11810
12051
  }
11811
12052
  }
12053
+ var defaultHasBin = (bin) => {
12054
+ try {
12055
+ (0, import_child_process7.execSync)(`${process.platform === "win32" ? "where" : "which"} ${bin}`, { stdio: "ignore" });
12056
+ return true;
12057
+ } catch {
12058
+ return false;
12059
+ }
12060
+ };
12061
+ function clipboardCopyCommand(hasBin = defaultHasBin, platform = process.platform, env = process.env) {
12062
+ const candidates = platform === "darwin" ? [["pbcopy", "pbcopy"]] : platform === "win32" ? [["clip.exe", "clip.exe"]] : [
12063
+ // Only prefer wl-copy when there is a Wayland display to talk to —
12064
+ // on X11 it is often installed but fails at copy time.
12065
+ ...env.WAYLAND_DISPLAY ? [["wl-copy", "wl-copy"]] : [],
12066
+ ["xclip", "xclip -selection clipboard"],
12067
+ ["xsel", "xsel --clipboard --input"],
12068
+ ["wl-copy", "wl-copy"]
12069
+ ];
12070
+ for (const [bin, command] of candidates) {
12071
+ if (hasBin(bin)) return command;
12072
+ }
12073
+ return null;
12074
+ }
11812
12075
 
11813
12076
  // src/services/TmuxRunner.ts
11814
12077
  var EXIT_RE = /<<<ORDEWELL_TMUX_EXIT:(\d+)>>>/;
@@ -11918,6 +12181,7 @@ var TmuxRunner = class extends AbstractRunner {
11918
12181
  resolvePath;
11919
12182
  pollIntervalMs;
11920
12183
  logDir;
12184
+ clipboardCommand;
11921
12185
  /** Retries get a freshly named window so a failed attempt's output stays inspectable. */
11922
12186
  attempts = /* @__PURE__ */ new Map();
11923
12187
  ready = null;
@@ -11929,6 +12193,7 @@ var TmuxRunner = class extends AbstractRunner {
11929
12193
  this.resolvePath = deps.resolvePath ?? augmentedPath;
11930
12194
  this.pollIntervalMs = deps.pollIntervalMs ?? 500;
11931
12195
  this.logDir = deps.logDir ?? (0, import_os.tmpdir)();
12196
+ this.clipboardCommand = deps.clipboardCommand ?? (() => clipboardCopyCommand());
11932
12197
  }
11933
12198
  /**
11934
12199
  * Reaps a session orphaned by a crashed prior daemon on the same port, then
@@ -11957,25 +12222,44 @@ var TmuxRunner = class extends AbstractRunner {
11957
12222
  await this.configureScrolling();
11958
12223
  }
11959
12224
  /**
11960
- * Makes an attached runner terminal scrollable with the mouse wheel and
11961
- * Page Up/Down. A fresh tmux session ships with `mouse off`, a 2000-line
11962
- * scrollback, and no PageUp binding — none of which lets a user page back
11963
- * through a finished task's output, which is the whole point of opening its
11964
- * window. Scoping is harmless: the daemon owns a private socket
11965
- * (`tmuxSocketName`), so these server-wide options touch nothing but its
11966
- * own session. Best-effort so an ancient or stripped tmux cannot stop tasks
11967
- * from spawning — the session still works without the scroll comforts.
12225
+ * Gives an attached runner terminal wheel/PageUp scrolling over a deep
12226
+ * scrollback, and a mouse selection that reaches the system clipboard — none
12227
+ * of which a default tmux session does. Server-wide scoping is harmless: the
12228
+ * daemon owns a private socket (`tmuxSocketName`).
12229
+ *
12230
+ * `mouse on` is what costs the emulator's own drag-select, so tmux must hand
12231
+ * the selection back out itself: via a clipboard binary when there is one,
12232
+ * and via OSC 52 (`set-clipboard`) for what that cannot cover, such as
12233
+ * viewing over SSH. Naming the binary on the binding as well as in
12234
+ * `copy-command` keeps drag-to-copy working on tmux older than 3.2, which has
12235
+ * no `copy-command`.
11968
12236
  */
11969
12237
  async configureScrolling() {
11970
- try {
11971
- await this.tmux(["set-option", "-g", "history-limit", "100000"]);
11972
- await this.tmux(["set-option", "-g", "mouse", "on"]);
11973
- await this.tmux(["bind-key", "-n", "PageUp", "copy-mode", "-u"]);
11974
- await this.tmux(["bind-key", "-T", "copy-mode", "PageUp", "send-keys", "-X", "page-up"]);
11975
- await this.tmux(["bind-key", "-T", "copy-mode", "PageDown", "send-keys", "-X", "page-down"]);
11976
- await this.tmux(["bind-key", "-T", "copy-mode-vi", "PageUp", "send-keys", "-X", "page-up"]);
11977
- await this.tmux(["bind-key", "-T", "copy-mode-vi", "PageDown", "send-keys", "-X", "page-down"]);
11978
- } catch {
12238
+ const clip = this.clipboardCommand();
12239
+ const copySelection = clip ? ["copy-pipe-and-cancel", clip] : ["copy-pipe-and-cancel"];
12240
+ const commands = [
12241
+ // `history-limit` only governs windows created after it is set, so it
12242
+ // must run before any `new-window` — once, here at session creation.
12243
+ ["set-option", "-g", "history-limit", "100000"],
12244
+ ["set-option", "-g", "mouse", "on"],
12245
+ ["set-option", "-g", "set-clipboard", "on"],
12246
+ ...clip ? [["set-option", "-g", "copy-command", clip]] : [],
12247
+ ["bind-key", "-T", "copy-mode", "MouseDragEnd1Pane", "send-keys", "-X", ...copySelection],
12248
+ ["bind-key", "-T", "copy-mode-vi", "MouseDragEnd1Pane", "send-keys", "-X", ...copySelection],
12249
+ // Root-table PageUp; otherwise the key goes to the inner app and never
12250
+ // reaches tmux's scrollback. Both mode tables, so `mode-keys` can't
12251
+ // decide whether paging works.
12252
+ ["bind-key", "-n", "PageUp", "copy-mode", "-u"],
12253
+ ["bind-key", "-T", "copy-mode", "PageUp", "send-keys", "-X", "page-up"],
12254
+ ["bind-key", "-T", "copy-mode", "PageDown", "send-keys", "-X", "page-down"],
12255
+ ["bind-key", "-T", "copy-mode-vi", "PageUp", "send-keys", "-X", "page-up"],
12256
+ ["bind-key", "-T", "copy-mode-vi", "PageDown", "send-keys", "-X", "page-down"]
12257
+ ];
12258
+ for (const args of commands) {
12259
+ try {
12260
+ await this.tmux(args);
12261
+ } catch {
12262
+ }
11979
12263
  }
11980
12264
  }
11981
12265
  /**
@@ -12035,13 +12319,13 @@ var TmuxRunner = class extends AbstractRunner {
12035
12319
  };
12036
12320
 
12037
12321
  // src/plugins/RunnerRegistry.ts
12038
- var fs6 = __toESM(require("fs"));
12322
+ var fs7 = __toESM(require("fs"));
12039
12323
  var path12 = __toESM(require("path"));
12040
12324
  var os5 = __toESM(require("os"));
12041
12325
  var import_child_process9 = require("child_process");
12042
12326
 
12043
12327
  // src/plugins/FsPluginStore.ts
12044
- var fs5 = __toESM(require("fs"));
12328
+ var fs6 = __toESM(require("fs"));
12045
12329
  var path11 = __toESM(require("path"));
12046
12330
  var os4 = __toESM(require("os"));
12047
12331
  function userPluginsDir() {
@@ -12054,9 +12338,9 @@ function isValidManifest(obj) {
12054
12338
  }
12055
12339
  function loadManifestFromDir(dir) {
12056
12340
  const manifestPath = path11.join(dir, "manifest.json");
12057
- if (!fs5.existsSync(manifestPath)) return null;
12341
+ if (!fs6.existsSync(manifestPath)) return null;
12058
12342
  try {
12059
- const raw = fs5.readFileSync(manifestPath, "utf8");
12343
+ const raw = fs6.readFileSync(manifestPath, "utf8");
12060
12344
  const parsed = JSON.parse(raw);
12061
12345
  if (!isValidManifest(parsed)) return null;
12062
12346
  return parsed;
@@ -12066,29 +12350,29 @@ function loadManifestFromDir(dir) {
12066
12350
  }
12067
12351
  function copyDirSync(src, dest) {
12068
12352
  ensureDir(dest);
12069
- const entries = fs5.readdirSync(src, { withFileTypes: true });
12353
+ const entries = fs6.readdirSync(src, { withFileTypes: true });
12070
12354
  for (const entry of entries) {
12071
12355
  const srcPath = path11.join(src, entry.name);
12072
12356
  const destPath = path11.join(dest, entry.name);
12073
12357
  if (entry.isDirectory()) {
12074
12358
  copyDirSync(srcPath, destPath);
12075
12359
  } else {
12076
- fs5.copyFileSync(srcPath, destPath);
12360
+ fs6.copyFileSync(srcPath, destPath);
12077
12361
  }
12078
12362
  }
12079
12363
  }
12080
12364
  function rmDirSync(dir) {
12081
- if (!fs5.existsSync(dir)) return;
12082
- const entries = fs5.readdirSync(dir, { withFileTypes: true });
12365
+ if (!fs6.existsSync(dir)) return;
12366
+ const entries = fs6.readdirSync(dir, { withFileTypes: true });
12083
12367
  for (const entry of entries) {
12084
12368
  const p = path11.join(dir, entry.name);
12085
12369
  if (entry.isDirectory()) {
12086
12370
  rmDirSync(p);
12087
12371
  } else {
12088
- fs5.unlinkSync(p);
12372
+ fs6.unlinkSync(p);
12089
12373
  }
12090
12374
  }
12091
- fs5.rmdirSync(dir);
12375
+ fs6.rmdirSync(dir);
12092
12376
  }
12093
12377
  var FsPluginStore = class {
12094
12378
  getUserPluginsDir() {
@@ -12096,10 +12380,10 @@ var FsPluginStore = class {
12096
12380
  }
12097
12381
  listUserPluginDirs() {
12098
12382
  const dir = userPluginsDir();
12099
- if (!fs5.existsSync(dir)) return [];
12383
+ if (!fs6.existsSync(dir)) return [];
12100
12384
  let entries;
12101
12385
  try {
12102
- entries = fs5.readdirSync(dir, { withFileTypes: true });
12386
+ entries = fs6.readdirSync(dir, { withFileTypes: true });
12103
12387
  } catch {
12104
12388
  return [];
12105
12389
  }
@@ -12118,20 +12402,20 @@ var FsPluginStore = class {
12118
12402
  ensureDir(dir);
12119
12403
  }
12120
12404
  writeFile(filePath, content) {
12121
- fs5.writeFileSync(filePath, content);
12405
+ fs6.writeFileSync(filePath, content);
12122
12406
  }
12123
12407
  readFile(filePath) {
12124
12408
  try {
12125
- return fs5.readFileSync(filePath, "utf8");
12409
+ return fs6.readFileSync(filePath, "utf8");
12126
12410
  } catch {
12127
12411
  return null;
12128
12412
  }
12129
12413
  }
12130
12414
  dirExists(path15) {
12131
- return fs5.existsSync(path15) && fs5.statSync(path15).isDirectory();
12415
+ return fs6.existsSync(path15) && fs6.statSync(path15).isDirectory();
12132
12416
  }
12133
12417
  exists(path15) {
12134
- return fs5.existsSync(path15);
12418
+ return fs6.existsSync(path15);
12135
12419
  }
12136
12420
  };
12137
12421
 
@@ -12600,20 +12884,20 @@ echo "Running ${name} with prompt: $prompt"
12600
12884
  };
12601
12885
  function fsReaddirSync(dir) {
12602
12886
  try {
12603
- return fs6.readdirSync(dir);
12887
+ return fs7.readdirSync(dir);
12604
12888
  } catch {
12605
12889
  return [];
12606
12890
  }
12607
12891
  }
12608
12892
  function fsChmodSync(fpath, mode) {
12609
12893
  try {
12610
- fs6.chmodSync(fpath, mode);
12894
+ fs7.chmodSync(fpath, mode);
12611
12895
  } catch {
12612
12896
  }
12613
12897
  }
12614
12898
 
12615
12899
  // src/utils/stateStore.ts
12616
- var fs7 = __toESM(require("fs"));
12900
+ var fs8 = __toESM(require("fs"));
12617
12901
  var path13 = __toESM(require("path"));
12618
12902
  var STATE_FILE = "state.json";
12619
12903
  function getStatePath(baseDir) {
@@ -12623,14 +12907,14 @@ function saveState(plan, baseDir) {
12623
12907
  const statePath = getStatePath(baseDir ?? process.cwd());
12624
12908
  ensureDir(path13.dirname(statePath));
12625
12909
  plan.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
12626
- fs7.writeFileSync(statePath, JSON.stringify(plan, null, 2));
12910
+ fs8.writeFileSync(statePath, JSON.stringify(plan, null, 2));
12627
12911
  }
12628
12912
  function loadState(baseDir, logger = defaultLogger) {
12629
12913
  const dir = baseDir ?? process.cwd();
12630
12914
  const statePath = getStatePath(dir);
12631
- if (!fs7.existsSync(statePath)) return null;
12915
+ if (!fs8.existsSync(statePath)) return null;
12632
12916
  try {
12633
- const raw = fs7.readFileSync(statePath, "utf-8");
12917
+ const raw = fs8.readFileSync(statePath, "utf-8");
12634
12918
  const parsed = JSON.parse(raw);
12635
12919
  if (!Array.isArray(parsed.runners)) {
12636
12920
  logger.warn("stateStore", `rejecting state from ${statePath}: old format (scalar "runner" instead of "runners" array). Please re-plan.`);
@@ -12645,15 +12929,15 @@ function loadState(baseDir, logger = defaultLogger) {
12645
12929
  function clearState(baseDir) {
12646
12930
  const dir = baseDir ?? process.cwd();
12647
12931
  const statePath = getStatePath(dir);
12648
- if (fs7.existsSync(statePath)) fs7.unlinkSync(statePath);
12932
+ if (fs8.existsSync(statePath)) fs8.unlinkSync(statePath);
12649
12933
  }
12650
12934
  function stateExists(baseDir) {
12651
12935
  const dir = baseDir ?? process.cwd();
12652
- return fs7.existsSync(getStatePath(dir));
12936
+ return fs8.existsSync(getStatePath(dir));
12653
12937
  }
12654
12938
 
12655
12939
  // src/services/SettingsService.ts
12656
- var fs8 = __toESM(require("fs"));
12940
+ var fs9 = __toESM(require("fs"));
12657
12941
  var path14 = __toESM(require("path"));
12658
12942
  var os6 = __toESM(require("os"));
12659
12943
  var DEFAULTS = {
@@ -12669,6 +12953,17 @@ function getSettingsPath() {
12669
12953
  if (override && override.trim()) return override.trim();
12670
12954
  return path14.join(os6.homedir(), ".config", "ordewell", "settings.json");
12671
12955
  }
12956
+ function readPlannerModels(raw) {
12957
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
12958
+ const out = {};
12959
+ for (const [provider, entry] of Object.entries(raw)) {
12960
+ if (!entry || typeof entry !== "object") continue;
12961
+ const { model, effort } = entry;
12962
+ if (typeof model !== "string" || !model.trim()) continue;
12963
+ out[provider] = typeof effort === "string" && effort.trim() ? { model, effort } : { model };
12964
+ }
12965
+ return Object.keys(out).length > 0 ? out : void 0;
12966
+ }
12672
12967
  var SettingsService = class {
12673
12968
  filePath;
12674
12969
  cache = null;
@@ -12689,7 +12984,7 @@ var SettingsService = class {
12689
12984
  }
12690
12985
  fileMtimeMs() {
12691
12986
  try {
12692
- return fs8.statSync(this.filePath).mtimeMs;
12987
+ return fs9.statSync(this.filePath).mtimeMs;
12693
12988
  } catch {
12694
12989
  return null;
12695
12990
  }
@@ -12762,6 +13057,26 @@ var SettingsService = class {
12762
13057
  }
12763
13058
  this.persist();
12764
13059
  }
13060
+ getPlannerModel(provider) {
13061
+ return this.getAll().plannerModels?.[provider];
13062
+ }
13063
+ setPlannerModel(provider, entry) {
13064
+ this.getAll();
13065
+ if (entry === void 0) {
13066
+ if (this.cache.plannerModels) {
13067
+ delete this.cache.plannerModels[provider];
13068
+ if (Object.keys(this.cache.plannerModels).length === 0) {
13069
+ this.cache.plannerModels = void 0;
13070
+ }
13071
+ }
13072
+ } else {
13073
+ if (!this.cache.plannerModels) {
13074
+ this.cache.plannerModels = {};
13075
+ }
13076
+ this.cache.plannerModels[provider] = entry.effort ? { model: entry.model, effort: entry.effort } : { model: entry.model };
13077
+ }
13078
+ this.persist();
13079
+ }
12765
13080
  getEnabledRunners() {
12766
13081
  return this.getAll().enabledRunners;
12767
13082
  }
@@ -12772,8 +13087,8 @@ var SettingsService = class {
12772
13087
  }
12773
13088
  load() {
12774
13089
  try {
12775
- if (fs8.existsSync(this.filePath)) {
12776
- const raw = JSON.parse(fs8.readFileSync(this.filePath, "utf-8"));
13090
+ if (fs9.existsSync(this.filePath)) {
13091
+ const raw = JSON.parse(fs9.readFileSync(this.filePath, "utf-8"));
12777
13092
  const settings = {
12778
13093
  grillMe: { enabled: raw.grillMe?.enabled ?? DEFAULTS.grillMe.enabled },
12779
13094
  tdd: { enabled: raw.tdd?.enabled ?? DEFAULTS.tdd.enabled },
@@ -12787,6 +13102,10 @@ var SettingsService = class {
12787
13102
  if (raw.modelAllowlist !== void 0) {
12788
13103
  settings.modelAllowlist = raw.modelAllowlist;
12789
13104
  }
13105
+ const plannerModels = readPlannerModels(raw.plannerModels);
13106
+ if (plannerModels) {
13107
+ settings.plannerModels = plannerModels;
13108
+ }
12790
13109
  if (Array.isArray(raw.enabledRunners)) {
12791
13110
  settings.enabledRunners = raw.enabledRunners.map(String);
12792
13111
  }
@@ -12798,13 +13117,48 @@ var SettingsService = class {
12798
13117
  }
12799
13118
  persist() {
12800
13119
  const dir = path14.dirname(this.filePath);
12801
- if (!fs8.existsSync(dir)) {
12802
- fs8.mkdirSync(dir, { recursive: true });
13120
+ if (!fs9.existsSync(dir)) {
13121
+ fs9.mkdirSync(dir, { recursive: true });
12803
13122
  }
12804
- fs8.writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2));
13123
+ fs9.writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2));
12805
13124
  this.cachedMtimeMs = this.fileMtimeMs();
12806
13125
  }
12807
13126
  };
13127
+
13128
+ // src/services/PlannerModelMemory.ts
13129
+ var PlannerModelMemory = class {
13130
+ constructor(store) {
13131
+ this.store = store;
13132
+ }
13133
+ store;
13134
+ /** Record what the user picked for `provider`; a blank model forgets it instead. */
13135
+ remember(provider, model, effort) {
13136
+ const id = model.trim();
13137
+ if (!id) {
13138
+ this.store.setPlannerModel(provider, void 0);
13139
+ return;
13140
+ }
13141
+ const level = effort?.trim();
13142
+ this.store.setPlannerModel(provider, level ? { model: id, effort: level } : { model: id });
13143
+ }
13144
+ /**
13145
+ * What the model and effort should become when `provider` takes over the
13146
+ * planner, given the models it actually serves. The `source` is what the
13147
+ * three surfaces word their notice from — restored, defaulted, or nothing.
13148
+ */
13149
+ recall(provider, catalog) {
13150
+ const remembered = this.store.getPlannerModel(provider);
13151
+ const match = remembered && catalog.find((c) => c.id === remembered.model);
13152
+ if (remembered && match) {
13153
+ const level = remembered.effort ?? "";
13154
+ const survives = (match.variants ?? []).some((v) => v.id === level);
13155
+ return { model: match.id, effort: survives ? level : "", source: "remembered" };
13156
+ }
13157
+ const first = catalog[0];
13158
+ if (first) return { model: first.id, effort: "", source: "catalog-default" };
13159
+ return { model: "", effort: "", source: "none" };
13160
+ }
13161
+ };
12808
13162
  // Annotate the CommonJS export names for ESM import in node:
12809
13163
  0 && (module.exports = {
12810
13164
  ALL_PROVIDERS,
@@ -12828,6 +13182,7 @@ var SettingsService = class {
12828
13182
  DENY_ALL,
12829
13183
  EmbeddedNewlineError,
12830
13184
  EnvConfig,
13185
+ ExecutableNotFoundError,
12831
13186
  FsPluginStore,
12832
13187
  GIT_READONLY_SUBCOMMANDS,
12833
13188
  GREP_DEFAULT_HEAD_LIMIT,
@@ -12848,14 +13203,17 @@ var SettingsService = class {
12848
13203
  PROVIDER_PRIORITY,
12849
13204
  PROVIDER_SHORT_LABEL,
12850
13205
  PendingApprovals,
13206
+ PlanEditError,
12851
13207
  PlanParseError,
12852
13208
  PlanStore,
12853
13209
  Planner,
13210
+ PlannerModelMemory,
12854
13211
  REFUSED_COMMANDS,
12855
13212
  RunnerInstallation,
12856
13213
  RunnerRegistry,
12857
13214
  SEARCH_EXCLUSIONS,
12858
13215
  STATE_DIR,
13216
+ STOPPED_TOOL_RESULT,
12859
13217
  SYMBOL_LANGUAGES,
12860
13218
  Session,
12861
13219
  SettingsService,
@@ -12866,9 +13224,11 @@ var SettingsService = class {
12866
13224
  TmuxRunner,
12867
13225
  VerdictEngine,
12868
13226
  WINDOWS_MAX_COMMAND_LINE,
13227
+ WorkspaceNotFoundError,
12869
13228
  addTaskToPlan,
12870
13229
  applyHeadLimit,
12871
13230
  applyTaskOps,
13231
+ assertWorkspaceExists,
12872
13232
  augmentPromptWithPriorOutputs,
12873
13233
  augmentedPath,
12874
13234
  buildConversationSystemPrompt,
@@ -12901,6 +13261,7 @@ var SettingsService = class {
12901
13261
  clearAugmentedPathCache,
12902
13262
  clearResearchShellCache,
12903
13263
  clearState,
13264
+ clipboardCopyCommand,
12904
13265
  coerceAssignments,
12905
13266
  collectDirectDependencyOutputs,
12906
13267
  collectProviderCredentials,
@@ -12941,6 +13302,7 @@ var SettingsService = class {
12941
13302
  hasTmux,
12942
13303
  includeGlobFor,
12943
13304
  isCliProvider,
13305
+ isExecutableResolved,
12944
13306
  isOpenAiProvider,
12945
13307
  killTree,
12946
13308
  knownModelId,