@ordewell/cli 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/main.js +411 -157
  2. package/package.json +4 -4
package/dist/main.js CHANGED
@@ -24,7 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/main.ts
27
- var import_core20 = require("@ordewell/core");
27
+ var import_core23 = require("@ordewell/core");
28
28
 
29
29
  // src/utils/env.ts
30
30
  var import_fs = require("fs");
@@ -94,7 +94,8 @@ Planning:
94
94
  ordewell sessions list|load|delete Manage named sessions
95
95
  ordewell fork Continue in a copy of the conversation and its tasks
96
96
  ordewell compact Condense the conversation into a summary (tasks are kept)
97
- ordewell rewind [<n>] Rewind the conversation to before message <n> (no <n>: list them)
97
+ ordewell rewind [<n>] Fork the conversation from before message <n> (no <n>: list them)
98
+ ordewell handoff [action] Review, merge, discard or clean up a finished isolated run
98
99
 
99
100
  Tasks (<id> is an order number or a task ID):
100
101
  ordewell add-task --title "..." Add a task to the current plan
@@ -123,6 +124,7 @@ Planner, models and runners:
123
124
  ordewell runners [<id> on|off] Enable or disable runners (claude-code, opencode, codex)
124
125
  ordewell allowlist set|clear|show Limit which models a runner may use
125
126
  ordewell auto [on|off] Autonomous permission mode for new sessions
127
+ ordewell parallel [<n>] How many AI tasks run at once (default 3; applies to a live run)
126
128
  ordewell refresh Re-discover runners and model catalogs
127
129
  ordewell models List every provider's catalog (works without a server)
128
130
 
@@ -196,8 +198,9 @@ Environment:
196
198
  OPENROUTER_BASE_URL Default: https://openrouter.ai/api/v1
197
199
  ORDEWELL_AUTONOMOUS_MODE Approval posture for new sessions (see "ordewell auto")
198
200
  ORDEWELL_PORT Daemon port CLI commands target (default: 3742)
201
+ ORDEWELL_DIRENV false: agents skip the project's direnv .envrc
199
202
  ORDEWELL_RESEARCH_ENABLED true (default) or false
200
- ORDEWELL_MAX_PARALLEL Default: 3
203
+ ORDEWELL_MAX_PARALLEL Default: 3 \u2014 set with "ordewell parallel"
201
204
  `);
202
205
  }
203
206
 
@@ -616,13 +619,17 @@ var ApiClient = class {
616
619
  }
617
620
  return res.data.targets;
618
621
  }
619
- /** Cut the conversation back to just before the user message at `index` (a transcript position). */
622
+ /**
623
+ * Fork the conversation from just before the user message at `index` (a
624
+ * transcript position) into a session the daemon has already adopted. The
625
+ * original is left as it was; `rewoundMessage` is that message in full.
626
+ */
620
627
  async rewindConversation(sessionId, index) {
621
628
  const res = await this.httpRequest("POST", `/api/plans/${sessionId}/conversation/rewind`, { index });
622
629
  if (res.status !== 200) {
623
630
  throw new Error(res.data?.error || "Rewind failed");
624
631
  }
625
- return res.data.plan;
632
+ return { sessionId: res.data.sessionId, goal: res.data.goal, plan: res.data.plan, rewoundMessage: res.data.rewoundMessage };
626
633
  }
627
634
  /**
628
635
  * Condense the conversation into a summary. One planner call, so it can take
@@ -937,6 +944,25 @@ function readLastSession(workspace) {
937
944
  }
938
945
  return null;
939
946
  }
947
+ var SESSION_PREFIX = "session-";
948
+ function expandSessionId(id, workspace) {
949
+ if (id.startsWith(SESSION_PREFIX)) return id;
950
+ let files;
951
+ try {
952
+ files = (0, import_fs3.readdirSync)((0, import_path3.join)((0, import_core5.getStateDir)(workspace), "sessions")).filter((f) => f.endsWith(".json"));
953
+ } catch {
954
+ return id;
955
+ }
956
+ const matches = /* @__PURE__ */ new Set();
957
+ for (const file of files) {
958
+ try {
959
+ const full = JSON.parse((0, import_fs3.readFileSync)((0, import_path3.join)((0, import_core5.getStateDir)(workspace), "sessions", file), "utf8"))?.meta?.id;
960
+ if (typeof full === "string" && (full.endsWith(id) || full.slice(SESSION_PREFIX.length).startsWith(id))) matches.add(full);
961
+ } catch {
962
+ }
963
+ }
964
+ return matches.size === 1 ? [...matches][0] : id;
965
+ }
940
966
 
941
967
  // src/approvals.ts
942
968
  function describeApproval(event) {
@@ -1262,6 +1288,7 @@ var STATE_OF = {
1262
1288
  active: "active",
1263
1289
  merged: "integrated",
1264
1290
  conflict: "conflict",
1291
+ repairing: "repairing",
1265
1292
  // A failed integration keeps its refs exactly as a failed verdict does; the
1266
1293
  // user sees one thing: work that did not land and can be looked at.
1267
1294
  kept: "kept",
@@ -1293,19 +1320,25 @@ function isolationOfPlan(plan) {
1293
1320
  const state = STATE_OF[String(record.status)];
1294
1321
  if (!state) continue;
1295
1322
  const changed = changedRepos(record, legacy, state);
1323
+ const repairedFiles = Array.isArray(record.repairedFiles) && record.repairedFiles.length > 0 ? record.repairedFiles.map(String) : void 0;
1296
1324
  tasks[taskId] = {
1297
1325
  state,
1298
1326
  branch: String(record.branch ?? ""),
1299
1327
  worktree: String(record.workspace ?? record.worktree ?? ""),
1300
1328
  repos: changed,
1301
- ...typeof record.conflictRepo === "string" ? { conflictRepo: record.conflictRepo } : legacy && state === "conflict" ? { conflictRepo: "." } : {}
1329
+ ...typeof record.conflictRepo === "string" ? { conflictRepo: record.conflictRepo } : legacy && state === "conflict" ? { conflictRepo: "." } : {},
1330
+ ...Array.isArray(record.conflictFiles) && record.conflictFiles.length > 0 ? { conflictFiles: record.conflictFiles.map(String) } : {},
1331
+ // No `repair` (attempt/limit): `conflictRepairAttempts` is server config, never
1332
+ // sent in a persisted plan, so it cannot be reconstructed here. A live
1333
+ // `tasksStatus` update fills it in once the stream catches up.
1334
+ ...repairedFiles ? { repairedFiles } : {}
1302
1335
  };
1303
1336
  if (state === "integrated") {
1304
- landed.push({ taskId, order: Number(record.order ?? 0), title: String(record.title ?? taskId), changed });
1337
+ landed.push({ taskId, order: Number(record.order ?? 0), title: String(record.title ?? taskId), changed, ...repairedFiles ? { repairedFiles } : {} });
1305
1338
  }
1306
1339
  }
1307
1340
  landed.sort((a, b) => a.order - b.order);
1308
- const entry = ({ taskId, order, title }) => ({ taskId, order, title });
1341
+ const entry = ({ taskId, order, title, repairedFiles }) => ({ taskId, order, title, ...repairedFiles ? { repairedFiles } : {} });
1309
1342
  return {
1310
1343
  handoff: {
1311
1344
  repos: repos.map((repo) => ({ ...repo, landed: landed.filter((t) => t.changed.includes(repo.path)).map(entry) })),
@@ -1328,6 +1361,16 @@ function reposWithWork(handoff) {
1328
1361
  const withWork = handoff.repos.filter((r) => r.landed.length > 0);
1329
1362
  return (withWork.length > 0 ? withWork : handoff.repos).map((r) => r.path);
1330
1363
  }
1364
+ function repairedLanded(handoff) {
1365
+ return handoff.landed.filter((t) => t.repairedFiles?.length);
1366
+ }
1367
+ function repairedNotice(handoff) {
1368
+ const repaired = repairedLanded(handoff);
1369
+ if (repaired.length === 0) return "";
1370
+ const named = repaired.map((t) => `${t.title} (${(0, import_core7.capConflictFiles)(t.repairedFiles)})`);
1371
+ const list = named.length === 1 ? named[0] : `${named.slice(0, -1).join(", ")} and ${named[named.length - 1]}`;
1372
+ return ` ${list} landed through ${repaired.length === 1 ? "a conflict repair" : "conflict repairs"}.`;
1373
+ }
1331
1374
  function repoResultLines(handoff) {
1332
1375
  return handoff.repos.map(({ path, landed }) => {
1333
1376
  const n = landed.length;
@@ -1337,12 +1380,12 @@ function repoResultLines(handoff) {
1337
1380
  function taskRepoNames(isolation) {
1338
1381
  return (isolation?.repos ?? []).filter((r) => r !== ".");
1339
1382
  }
1340
- function mergeOutcome(result, branch, group) {
1383
+ function mergeOutcome(result, branch, group, repaired = []) {
1341
1384
  const ok = result.outcome === "merged";
1342
1385
  if (!group && result.outcome === "conflict") {
1343
1386
  return { ok, message: `Merging ${branch} conflicted, so it was aborted \u2014 your tree is as it was. Merge it with git and resolve the conflict there.` };
1344
1387
  }
1345
- const { message } = (0, import_core7.describeMergeResult)(result, branch, group);
1388
+ const { message } = (0, import_core7.describeMergeResult)(result, branch, group, repaired);
1346
1389
  return { ok, message: ok || !group ? message : `${message} Each repository's ${branch} is a plain branch you can merge by hand.` };
1347
1390
  }
1348
1391
 
@@ -2384,7 +2427,7 @@ async function handleCompact(subArgs, injectedApi) {
2384
2427
  async function handleRewind(subArgs, injectedApi) {
2385
2428
  const arg = positionals(subArgs)[0];
2386
2429
  if (arg !== void 0 && !/^\d+$/.test(arg)) fail(REWIND_USAGE);
2387
- const { api, sessionId } = await adopted(subArgs, injectedApi);
2430
+ const { api, sessionId, workspace } = await adopted(subArgs, injectedApi);
2388
2431
  if (arg === void 0) {
2389
2432
  let targets;
2390
2433
  try {
@@ -2398,15 +2441,20 @@ async function handleRewind(subArgs, injectedApi) {
2398
2441
  }
2399
2442
  console.log("Your messages, most recent first:\n");
2400
2443
  for (const t of [...targets].reverse()) console.log(` ${String(t.index).padStart(4)} ${t.preview}`);
2401
- console.log("\n `ordewell rewind <n>` discards message <n> and everything after it. The tasks stay as they are.");
2444
+ console.log("\n `ordewell rewind <n>` forks the conversation from just before message <n>. The original is kept, and the tasks ride along.");
2402
2445
  return;
2403
2446
  }
2447
+ let fork;
2404
2448
  try {
2405
- await api.rewindConversation(sessionId, Number(arg));
2449
+ fork = await api.rewindConversation(sessionId, Number(arg));
2406
2450
  } catch (err) {
2407
2451
  fail(`Failed to rewind: ${err.message}`);
2408
2452
  }
2409
- console.log(`Rewound ${sessionId} to just before message ${arg}. The tasks are unchanged; the next message continues from there.`);
2453
+ saveLastSession(fork.sessionId, fork.goal, fork.plan.runners ?? [], workspace);
2454
+ console.log(`Forked ${sessionId} into ${fork.sessionId} from just before message ${arg} \u2014 the fork is now the current session.`);
2455
+ console.log(` \`ordewell sessions load ${sessionId}\` to go back to the original.`);
2456
+ console.log("\nThe message you rewound to, to resend or edit:\n");
2457
+ console.log(fork.rewoundMessage);
2410
2458
  }
2411
2459
 
2412
2460
  // src/commands/handoff.ts
@@ -2619,12 +2667,12 @@ function runHandoffAction(state, id) {
2619
2667
  overlay: isRepoGroup(handoff) ? {
2620
2668
  kind: "confirm",
2621
2669
  title: "Merge all into your branches?",
2622
- message: `Merge ${handoffBranch(handoff)} into whatever each of ${reposWithWork(handoff).join(", ")} has checked out. Ordewell never does this on its own. It checks every repository first and merges none unless every repository can take it; on git older than 2.38 it goes repository by repository and stops at the first that fails. A merge that conflicts is aborted, and your trees stay as they were.`,
2670
+ message: `Merge ${handoffBranch(handoff)} into whatever each of ${reposWithWork(handoff).join(", ")} has checked out. Ordewell never does this on its own. It checks every repository first and merges none unless every repository can take it; on git older than 2.38 it goes repository by repository and stops at the first that fails. A merge that conflicts is aborted, and your trees stay as they were. Once every repository has merged, the run's worktrees and branches are removed.${repairedNotice(handoff)}`,
2623
2671
  action: { kind: "merge-run" }
2624
2672
  } : {
2625
2673
  kind: "confirm",
2626
2674
  title: "Merge into your branch?",
2627
- message: `Merge ${handoffBranch(handoff)} into whatever you have checked out. Ordewell never does this on its own. If it conflicts the merge is aborted and your tree stays as it was.`,
2675
+ message: `Merge ${handoffBranch(handoff)} into whatever you have checked out. Ordewell never does this on its own. If it conflicts the merge is aborted and your tree stays as it was. Once it has merged, the run's worktrees and branches are removed.${repairedNotice(handoff)}`,
2628
2676
  action: { kind: "merge-run" }
2629
2677
  }
2630
2678
  },
@@ -2649,7 +2697,14 @@ function confirmedHandoff(state, kind) {
2649
2697
  const closed = { ...state, overlay: null };
2650
2698
  if (!state.sessionId || !state.handoff) return { state: closed, effects: [] };
2651
2699
  const { sessionId, handoff } = state;
2652
- const effect = kind === "merge-run" ? { type: "isolationMerge", sessionId, branch: handoffBranch(handoff), ...isRepoGroup(handoff) ? { group: true } : {} } : { type: "isolationDiscard", sessionId, branch: handoffBranch(handoff) };
2700
+ const repaired = repairedLanded(handoff);
2701
+ const effect = kind === "merge-run" ? {
2702
+ type: "isolationMerge",
2703
+ sessionId,
2704
+ branch: handoffBranch(handoff),
2705
+ ...isRepoGroup(handoff) ? { group: true } : {},
2706
+ ...repaired.length > 0 ? { repaired } : {}
2707
+ } : { type: "isolationDiscard", sessionId, branch: handoffBranch(handoff) };
2653
2708
  return { state: closed, effects: [effect] };
2654
2709
  }
2655
2710
  function showDiff(state, diff) {
@@ -2714,7 +2769,7 @@ function chooseBlocked(state, choice) {
2714
2769
  return { state: closed, effects: [{ type: "isolationContinue", sessionId, mode: choice }] };
2715
2770
  }
2716
2771
  function sameIsolation(a, b) {
2717
- return a?.state === b?.state && a?.branch === b?.branch && a?.worktree === b?.worktree && a?.conflictRepo === b?.conflictRepo && (a?.repos ?? []).join("\0") === (b?.repos ?? []).join("\0");
2772
+ return a?.state === b?.state && a?.branch === b?.branch && a?.worktree === b?.worktree && a?.conflictRepo === b?.conflictRepo && (a?.repos ?? []).join("\0") === (b?.repos ?? []).join("\0") && (a?.conflictFiles ?? []).join("\0") === (b?.conflictFiles ?? []).join("\0") && a?.repair?.attempt === b?.repair?.attempt && a?.repair?.limit === b?.repair?.limit && (a?.repairedFiles ?? []).join("\0") === (b?.repairedFiles ?? []).join("\0");
2718
2773
  }
2719
2774
  function mapTasks(tasks, map) {
2720
2775
  return tasks.map((task) => {
@@ -2787,8 +2842,8 @@ async function handleHandoff(subArgs, injectedApi, confirm = askYesNo) {
2787
2842
  return;
2788
2843
  }
2789
2844
  case "merge": {
2790
- await confirmed(group ? `Merge ${branch} into the branch checked out in each of ${reposWithWork(handoff).join(", ")}? Nothing is merged unless every repository can take it.` : `Merge ${branch} into the branch you have checked out?`);
2791
- const { ok, message } = mergeOutcome(await attempt(() => api.mergeRun(sessionId)), branch, group);
2845
+ await confirmed((group ? `Merge ${branch} into the branch checked out in each of ${reposWithWork(handoff).join(", ")}? Nothing is merged unless every repository can take it; once all have, the run's worktrees and branches are removed.` : `Merge ${branch} into the branch you have checked out? Once merged, the run's worktrees and branches are removed.`) + repairedNotice(handoff));
2846
+ const { ok, message } = mergeOutcome(await attempt(() => api.mergeRun(sessionId)), branch, group, repairedLanded(handoff));
2792
2847
  if (ok) console.log(message);
2793
2848
  else fail(message);
2794
2849
  return;
@@ -2928,30 +2983,47 @@ async function handlePlannerEffort(subArgs, injectedApi) {
2928
2983
  console.log(`Planner effort set to ${level}.`);
2929
2984
  }
2930
2985
 
2931
- // src/commands/key.ts
2986
+ // src/commands/parallel.ts
2932
2987
  var import_core14 = require("@ordewell/core");
2933
- var USAGE5 = "Usage: ordewell key [list | set <provider> <key>]";
2988
+ var USAGE5 = "Usage: ordewell parallel [<number of tasks, 1 or more>]";
2989
+ async function handleParallel(subArgs, injectedApi) {
2990
+ const [wanted] = positionals(subArgs);
2991
+ const api = await connect(subArgs, injectedApi);
2992
+ if (!wanted || wanted === "show") {
2993
+ const settings = await api.getSettings();
2994
+ console.log(`Up to ${String(settings.maxParallel ?? "?")} AI tasks run at once. Change with: ordewell parallel <n>`);
2995
+ return;
2996
+ }
2997
+ const limit = (0, import_core14.parseMaxParallel)(wanted);
2998
+ if (limit === null) fail(`"${wanted}" is not a number of tasks (1 or more).`, USAGE5);
2999
+ await persistEnv(api, { ORDEWELL_MAX_PARALLEL: String(limit) });
3000
+ console.log(`Up to ${limit} AI task${limit === 1 ? "" : "s"} now run at once.`);
3001
+ }
3002
+
3003
+ // src/commands/key.ts
3004
+ var import_core15 = require("@ordewell/core");
3005
+ var USAGE6 = "Usage: ordewell key [list | set <provider> <key>]";
2934
3006
  async function handleKey(subArgs, injectedApi) {
2935
3007
  const [action, provider, ...keyParts] = positionals(subArgs);
2936
3008
  if (action && action !== "list" && action !== "set") {
2937
- fail(`Unknown key action: "${action}"`, USAGE5);
3009
+ fail(`Unknown key action: "${action}"`, USAGE6);
2938
3010
  }
2939
3011
  const api = await connect(subArgs, injectedApi);
2940
3012
  if (action !== "set") {
2941
3013
  const catalog = await fetchCatalog(api);
2942
3014
  console.log("\nAPI provider keys\n");
2943
- for (const id2 of import_core14.PROVIDER_PRIORITY) {
2944
- const meta2 = import_core14.ALL_PROVIDERS[id2];
3015
+ for (const id2 of import_core15.PROVIDER_PRIORITY) {
3016
+ const meta2 = import_core15.ALL_PROVIDERS[id2];
2945
3017
  const configured = catalog.providers.includes(id2);
2946
3018
  console.log(` ${configured ? "\u2713" : " "} ${id2.padEnd(14)} ${meta2.apiKeyEnvVar.padEnd(28)} ${meta2.label}`);
2947
3019
  }
2948
3020
  console.log(`
2949
- \u2713 = a working key was found. ${USAGE5}`);
3021
+ \u2713 = a working key was found. ${USAGE6}`);
2950
3022
  return;
2951
3023
  }
2952
- if (!provider) fail(USAGE5);
3024
+ if (!provider) fail(USAGE6);
2953
3025
  const id = provider.toLowerCase();
2954
- const meta = import_core14.ALL_PROVIDERS[id];
3026
+ const meta = import_core15.ALL_PROVIDERS[id];
2955
3027
  if (!meta) {
2956
3028
  fail(`Unknown provider: ${provider}`, "Run `ordewell key` to see the list.");
2957
3029
  }
@@ -2962,7 +3034,7 @@ async function handleKey(subArgs, injectedApi) {
2962
3034
  }
2963
3035
 
2964
3036
  // src/commands/runners.ts
2965
- var USAGE6 = "Usage: ordewell runners [<runner-id> on|off]";
3037
+ var USAGE7 = "Usage: ordewell runners [<runner-id> on|off]";
2966
3038
  async function handleRunners(subArgs, injectedApi) {
2967
3039
  const [runner, toggle] = positionals(subArgs);
2968
3040
  const api = await connect(subArgs, injectedApi);
@@ -2973,7 +3045,7 @@ async function handleRunners(subArgs, injectedApi) {
2973
3045
  console.log(` ${r.enabled ? "\u2713" : " "} ${r.id.padEnd(14)} ${r.name}${r.enabled ? "" : " (disabled)"}`);
2974
3046
  }
2975
3047
  console.log(`
2976
- \u2713 = offered to the planner. ${USAGE6}`);
3048
+ \u2713 = offered to the planner. ${USAGE7}`);
2977
3049
  return;
2978
3050
  }
2979
3051
  const state = await api.getRunners();
@@ -2982,7 +3054,7 @@ async function handleRunners(subArgs, injectedApi) {
2982
3054
  fail(`Unknown runner: ${runner}`, `Enabled or not, this daemon knows: ${state.runners.map((r) => r.id).join(", ")}`);
2983
3055
  }
2984
3056
  const enabled = toggle === void 0 ? !known.enabled : toggle === "on" ? true : toggle === "off" ? false : null;
2985
- if (enabled === null) fail(USAGE6);
3057
+ if (enabled === null) fail(USAGE7);
2986
3058
  await api.setRunnerEnabled(runner, enabled);
2987
3059
  console.log(`${runner} ${enabled ? "enabled" : "disabled"}.`);
2988
3060
  }
@@ -3032,7 +3104,7 @@ async function handleApprove(subArgs, injectedApi) {
3032
3104
  // src/tui/terminalLauncher.ts
3033
3105
  var import_child_process3 = require("child_process");
3034
3106
  var import_util = require("util");
3035
- var import_core15 = require("@ordewell/core");
3107
+ var import_core16 = require("@ordewell/core");
3036
3108
  var execFileAsync = (0, import_util.promisify)(import_child_process3.execFile);
3037
3109
  var defaultExecFile = async (command, args) => {
3038
3110
  const { stdout, stderr } = await execFileAsync(command, args);
@@ -3052,16 +3124,16 @@ var defaultWhich = (bin) => {
3052
3124
  async function openTaskTerminal(port, planSessionId, taskId, deps = {}) {
3053
3125
  const platform = deps.platform ?? process.platform;
3054
3126
  const env = deps.env ?? process.env;
3055
- const hasTmuxImpl = deps.hasTmuxImpl ?? import_core15.hasTmux;
3127
+ const hasTmuxImpl = deps.hasTmuxImpl ?? import_core16.hasTmux;
3056
3128
  const execFileImpl = deps.execFileImpl ?? defaultExecFile;
3057
3129
  const spawnImpl = deps.spawnImpl ?? defaultSpawn;
3058
3130
  const which = deps.which ?? defaultWhich;
3059
3131
  if (!hasTmuxImpl()) {
3060
3132
  return { ok: false, message: "tmux is not installed \u2014 install it to get a real terminal per task." };
3061
3133
  }
3062
- const session = (0, import_core15.tmuxSessionName)(port);
3063
- const socket = (0, import_core15.tmuxSocketName)(port);
3064
- const base = (0, import_core15.tmuxWindowName)(taskId, planSessionId);
3134
+ const session = (0, import_core16.tmuxSessionName)(port);
3135
+ const socket = (0, import_core16.tmuxSocketName)(port);
3136
+ const base = (0, import_core16.tmuxWindowName)(taskId, planSessionId);
3065
3137
  let windows;
3066
3138
  try {
3067
3139
  const { stdout } = await execFileImpl("tmux", ["-L", socket, "list-windows", "-t", session, "-F", "#{window_name}"]);
@@ -3355,7 +3427,7 @@ Dependencies \xB7 #${task.order} ${task.title}
3355
3427
  }
3356
3428
 
3357
3429
  // src/tui/index.ts
3358
- var import_core19 = require("@ordewell/core");
3430
+ var import_core22 = require("@ordewell/core");
3359
3431
 
3360
3432
  // src/tui/editor.ts
3361
3433
  function emptyEditor() {
@@ -3517,6 +3589,7 @@ var SLASH_COMMANDS = [
3517
3589
  { name: "allowlist", usage: "/allowlist [set <runner> <ids> | clear <runner>]", description: "Limit which models a runner may use", category: "models" },
3518
3590
  { name: "runners", usage: "/runners [<id> on|off]", description: "Enable or disable runners (claude-code, opencode, codex)", category: "models" },
3519
3591
  { name: "auto", usage: "/auto [on|off]", description: "Autonomous permission mode for new plans", category: "models" },
3592
+ { name: "parallel", usage: "/parallel [<n>]", description: "How many AI tasks run at once (applies to a live run)", category: "models" },
3520
3593
  { name: "refresh", usage: "/refresh", description: "Re-discover runners and model catalogs", category: "models" },
3521
3594
  // Skills
3522
3595
  { name: "tdd", usage: "/tdd [on|off]", description: "Test-Driven Development mode", category: "skills" },
@@ -3525,7 +3598,7 @@ var SLASH_COMMANDS = [
3525
3598
  { name: "sessions", usage: "/sessions", description: "List saved sessions", category: "session" },
3526
3599
  { name: "fork", usage: "/fork", description: "Continue in a copy of this conversation and its tasks; the original stays as it is", category: "session" },
3527
3600
  { name: "compact", usage: "/compact", description: "Condense this conversation into a summary; the last two exchanges and all tasks are kept", category: "session" },
3528
- { name: "rewind", usage: "/rewind [<message>]", description: "Rewind the conversation to just before one of your messages (tasks are kept)", category: "session" },
3601
+ { name: "rewind", usage: "/rewind [<message>]", description: "Fork the conversation from just before one of your messages, after a confirmation (the original is kept)", category: "session" },
3529
3602
  { name: "new", usage: "/new", description: "Start a new session", category: "session" },
3530
3603
  { name: "save", usage: "/save", description: "Save the current session", category: "session" },
3531
3604
  { name: "load", usage: "/load <id>", description: "Load a saved session", category: "session" },
@@ -3741,6 +3814,7 @@ function chatEditorRoom(cols, active) {
3741
3814
  }
3742
3815
 
3743
3816
  // src/tui/state.ts
3817
+ var import_core17 = require("@ordewell/core");
3744
3818
  var SKILL_IDS = ["tdd", "verify"];
3745
3819
  function noSkills() {
3746
3820
  return { tdd: false, verify: false };
@@ -3805,6 +3879,7 @@ function initialState(overrides = {}) {
3805
3879
  orchestratorModel: "",
3806
3880
  plannerProvider: "",
3807
3881
  plannerEffort: "",
3882
+ maxParallel: import_core17.DEFAULT_MAX_PARALLEL,
3808
3883
  configuredProviders: [],
3809
3884
  allowlist: {},
3810
3885
  autonomous: true,
@@ -3824,7 +3899,7 @@ function initialState(overrides = {}) {
3824
3899
  }
3825
3900
 
3826
3901
  // src/tui/layout.ts
3827
- var import_core16 = require("@ordewell/core");
3902
+ var import_core18 = require("@ordewell/core");
3828
3903
  var CHROME_ROWS = 4;
3829
3904
  function chatInputWrap(state) {
3830
3905
  const active = state.focus === "chat" && !state.overlay;
@@ -3951,21 +4026,18 @@ function chatBodyLines(messages, cols) {
3951
4026
  }
3952
4027
  function chatLayout(state, rows, cols) {
3953
4028
  const welcome = state.tasks.length === 0;
3954
- if (welcome && state.messages.length === 0) {
3955
- return { lines: welcomeLines(state, cols), anchor: "top", maxScroll: 0 };
3956
- }
3957
4029
  const body = chatBodyLines(state.messages, cols);
3958
- const lines = welcome ? [...welcomeLines(state, cols), "", ...body] : body;
3959
- return { lines, anchor: "bottom", maxScroll: Math.max(0, lines.length - rows) };
4030
+ const lines = !welcome ? body : state.messages.length === 0 ? welcomeLines(state, cols) : [...welcomeLines(state, cols), "", ...body];
4031
+ return { lines, anchor: lines.length > rows ? "bottom" : "top", maxScroll: Math.max(0, lines.length - rows) };
3960
4032
  }
3961
4033
  function chatScrollMax(state) {
3962
4034
  return chatLayout(state, bodyRows(state), chatPaneWidth(state)).maxScroll;
3963
4035
  }
3964
4036
  function plannerSetup(state) {
3965
4037
  const provider = state.plannerProvider;
3966
- const registration = import_core16.ALL_PROVIDERS[provider];
4038
+ const registration = import_core18.ALL_PROVIDERS[provider];
3967
4039
  if (!registration) return { label: "not set", ready: false };
3968
- const runner = (0, import_core16.runnerForProvider)(provider);
4040
+ const runner = (0, import_core18.runnerForProvider)(provider);
3969
4041
  const ready = runner ? state.runners.some((r) => r.id === runner) : state.configuredProviders.includes(provider);
3970
4042
  return { label: registration.label, ready };
3971
4043
  }
@@ -4073,33 +4145,69 @@ var STATUS_ICON = {
4073
4145
  pending: "\xB7"
4074
4146
  };
4075
4147
  var IDLE_ICON = "~";
4148
+ var FIRST_ROW_LINE = 2;
4076
4149
  function planLayout(state, rows, cols) {
4077
4150
  const done = state.tasks.filter((t) => t.status === "completed").length;
4078
4151
  const lines = [style.bold(`Plan ${done}/${state.tasks.length}`), ""];
4079
- let selectedLine = 2;
4152
+ const rowSpans = [];
4080
4153
  let editorLine;
4081
4154
  planRows(state).forEach((row, i) => {
4082
4155
  const taskStart = lines.length;
4083
- if (i === state.selectedTask) selectedLine = taskStart;
4084
4156
  const renderedTask = taskLines(state, row, i, cols);
4085
4157
  if (row.task.id === state.expandedTaskId && renderedTask.editorLine !== void 0) {
4086
4158
  editorLine = taskStart + renderedTask.editorLine;
4087
4159
  }
4088
4160
  lines.push(...renderedTask.lines);
4161
+ rowSpans.push({ start: taskStart, end: lines.length - 1 });
4089
4162
  });
4090
- const maxScroll = Math.max(0, lines.length - rows);
4091
- const anchorLine = editorLine ?? selectedLine;
4092
- return { lines, followOffset: Math.min(maxScroll, Math.max(0, anchorLine - rows + 3)), maxScroll };
4163
+ const selected = rowSpans[state.selectedTask] ?? { start: FIRST_ROW_LINE, end: FIRST_ROW_LINE };
4164
+ const anchor = editorLine === void 0 ? selected : { start: editorLine, end: editorLine };
4165
+ return { lines, rowSpans, anchor, rows, maxScroll: Math.max(0, lines.length - rows) };
4166
+ }
4167
+ var clampOffset = (layout, offset) => Math.max(0, Math.min(layout.maxScroll, offset));
4168
+ function revealOffset(layout, offset, span) {
4169
+ const at = clampOffset(layout, offset);
4170
+ const bodyHeight = layout.rows - 1;
4171
+ const first = span.start <= FIRST_ROW_LINE ? 0 : span.start - 1;
4172
+ if (span.start < 1 + at || span.end - span.start + 1 > bodyHeight) return clampOffset(layout, first);
4173
+ if (span.end > at + layout.rows - 1) return clampOffset(layout, span.end - layout.rows + 1);
4174
+ return at;
4093
4175
  }
4094
4176
  function planOffset(layout, planScroll) {
4095
- if (planScroll === null) return layout.followOffset;
4096
- return Math.max(0, Math.min(layout.maxScroll, planScroll));
4177
+ if (planScroll === null) return revealOffset(layout, 0, layout.anchor);
4178
+ return clampOffset(layout, planScroll);
4179
+ }
4180
+ function topRowInView(layout, offset) {
4181
+ const top = 1 + offset;
4182
+ const whole = layout.rowSpans.findIndex((span) => span.start >= top && span.end <= offset + layout.rows - 1);
4183
+ if (whole >= 0) return whole;
4184
+ const covering = layout.rowSpans.findIndex((span) => span.end >= top);
4185
+ return covering >= 0 ? covering : layout.rowSpans.length - 1;
4186
+ }
4187
+ function bottomRowInView(layout, offset) {
4188
+ const bottom = offset + layout.rows - 1;
4189
+ const spans = layout.rowSpans;
4190
+ for (let i = spans.length - 1; i >= 0; i--) {
4191
+ if (spans[i].start >= 1 + offset && spans[i].end <= bottom) return i;
4192
+ }
4193
+ for (let i = spans.length - 1; i >= 0; i--) {
4194
+ if (spans[i].start <= bottom) return i;
4195
+ }
4196
+ return 0;
4197
+ }
4198
+ function rowNearestView(layout, offset, row) {
4199
+ const span = layout.rowSpans[row];
4200
+ if (!span) return row;
4201
+ if (span.end < 1 + offset) return topRowInView(layout, offset);
4202
+ if (span.start > offset + layout.rows - 1) return bottomRowInView(layout, offset);
4203
+ return row;
4097
4204
  }
4098
4205
  function planScrollExtent(state) {
4099
4206
  const cols = planPaneWidth(state);
4100
- if (cols === 0) return { followOffset: 0, maxScroll: 0 };
4101
- const { followOffset, maxScroll } = planLayout(state, bodyRows(state), cols);
4102
- return { followOffset, maxScroll };
4207
+ if (cols === 0) {
4208
+ return { lines: [], rowSpans: [], anchor: { start: FIRST_ROW_LINE, end: FIRST_ROW_LINE }, rows: 0, maxScroll: 0 };
4209
+ }
4210
+ return planLayout(state, bodyRows(state), cols);
4103
4211
  }
4104
4212
  function taskLines(state, row, index, cols) {
4105
4213
  const { task, parent } = row;
@@ -4112,7 +4220,7 @@ function taskLines(state, row, index, cols) {
4112
4220
  const icon = paint(rawIcon);
4113
4221
  const kind = running ? style.yellow("RUN") : task.type === "user" ? style.yellow("MAN") : style.grey(" AI");
4114
4222
  const caret = selected ? style.cyan("\u276F") : " ";
4115
- const orderLabel = parent ? (0, import_core16.taskOrderLabel)(task, parent) : String(task.order).padStart(2);
4223
+ const orderLabel = parent ? (0, import_core18.taskOrderLabel)(task, parent) : String(task.order).padStart(2);
4116
4224
  const indent = parent ? " " : "";
4117
4225
  const head = `${indent}${caret} ${icon} ${orderLabel} ${kind} `;
4118
4226
  const titleRoom = Math.max(1, cols - width(head));
@@ -4133,12 +4241,22 @@ function taskLines(state, row, index, cols) {
4133
4241
  const mode = task.type === "ai" ? `mode: ${task.taskMode ?? "default"}${modeInfo?.autonomous ? style.yellow(" \u26A1") : ""}` : "";
4134
4242
  const runner = task.assignedRunner ?? "";
4135
4243
  const bodyPad = parent ? " " : " ";
4136
- const meta = [running ? "working" : "", runner, model].filter(Boolean).join(" \xB7 ");
4244
+ const activity = idle ? "quiet \u2014 t opens its terminal" : running ? "working" : "";
4245
+ const meta = [activity, runner, model].filter(Boolean).join(" \xB7 ");
4137
4246
  if (meta) lines.push(style.grey(truncate(`${bodyPad}${meta}`, cols)));
4138
4247
  if (effort || mode) lines.push(style.grey(truncate(`${bodyPad}${[effort, mode].filter(Boolean).join(" \xB7 ")}`, cols)));
4139
4248
  if (task.isolation?.state === "conflict") {
4140
4249
  const where = task.isolation.conflictRepo && task.isolation.conflictRepo !== "." ? ` in ${task.isolation.conflictRepo}` : "";
4141
- lines.push(style.red(truncate(`${bodyPad}\u26A0 merge conflict${where} \u2014 its work is kept on its own branch`, cols)));
4250
+ const files = task.isolation.conflictFiles?.length ? ` (${(0, import_core18.capConflictFiles)(task.isolation.conflictFiles)})` : "";
4251
+ lines.push(style.red(truncate(`${bodyPad}\u26A0 merge conflict${where}${files} \u2014 its work is kept on its own branch`, cols)));
4252
+ }
4253
+ if (task.isolation?.state === "repairing") {
4254
+ const files = task.isolation.conflictFiles?.length ? ` in ${(0, import_core18.capConflictFiles)(task.isolation.conflictFiles)}` : "";
4255
+ const attempt = task.isolation.repair ? ` (attempt ${task.isolation.repair.attempt}/${task.isolation.repair.limit})` : "";
4256
+ lines.push(style.yellow(truncate(`${bodyPad}\u21BB repairing conflict${files}${attempt}`, cols)));
4257
+ }
4258
+ if (task.isolation?.state === "integrated" && task.isolation.repairedFiles?.length) {
4259
+ lines.push(style.grey(truncate(`${bodyPad}\u21BB landed after repairing conflict in ${(0, import_core18.capConflictFiles)(task.isolation.repairedFiles)}`, cols)));
4142
4260
  }
4143
4261
  let editorLine;
4144
4262
  if (expanded) {
@@ -4223,6 +4341,7 @@ function helpScrollMax(state) {
4223
4341
  }
4224
4342
 
4225
4343
  // src/tui/render.ts
4344
+ var import_core19 = require("@ordewell/core");
4226
4345
  function render(state) {
4227
4346
  const { rows, cols } = state;
4228
4347
  const rowsForBody = bodyRows(state);
@@ -4321,8 +4440,7 @@ function renderSkills(state, cols) {
4321
4440
  return truncate([...badges, auto].join(" "), cols);
4322
4441
  }
4323
4442
  function scrolledBackMark(state) {
4324
- if (state.focus === "plan") return state.planScroll === null ? "" : "\u2191 scrolled back \xB7 \u2191\u2193 to follow";
4325
- return state.scroll > 0 ? "\u2191 scrolled back \xB7 pgdn for live" : "";
4443
+ return state.focus !== "plan" && state.scroll > 0 ? "\u2191 scrolled back \xB7 pgdn for live" : "";
4326
4444
  }
4327
4445
  function renderStatus(state, cols) {
4328
4446
  const mark = scrolledBackMark(state);
@@ -4462,14 +4580,7 @@ function renderOverlay(state, rows, cols) {
4462
4580
  cols
4463
4581
  );
4464
4582
  }
4465
- if (overlay.kind === "confirm") {
4466
- return frame(
4467
- overlay.title,
4468
- [overlay.message, "", style.grey("enter confirms \xB7 esc cancels")],
4469
- rows,
4470
- cols
4471
- );
4472
- }
4583
+ if (overlay.kind === "confirm") return renderConfirm(overlay, rows, cols);
4473
4584
  if (overlay.kind === "prompt") {
4474
4585
  const secret = overlay.action.kind === "api-key";
4475
4586
  const shown = secret ? "\u2022".repeat(overlay.value.length) : overlay.value;
@@ -4493,7 +4604,10 @@ function renderHandoff(state, overlay, rows, cols) {
4493
4604
  const more = scroll + room < lines.length ? "\u2191\u2193 pgup/pgdn scroll \xB7 " : "";
4494
4605
  return frame(`Diff \u2014 ${handoffBranch(handoff)}`, [...shown, "", style.grey(`${more}enter or esc goes back`)], rows, cols);
4495
4606
  }
4496
- const landed = handoff.landed.length === 0 ? [style.grey("Nothing landed on it.")] : handoff.landed.map((task) => ` ${style.green("\u2713")} #${task.order} ${task.title}`);
4607
+ const landed = handoff.landed.length === 0 ? [style.grey("Nothing landed on it.")] : handoff.landed.map((task) => {
4608
+ const repaired = task.repairedFiles?.length ? style.yellow(` \u2014 repaired (${(0, import_core19.capConflictFiles)(task.repairedFiles)})`) : "";
4609
+ return ` ${style.green("\u2713")} #${task.order} ${task.title}${repaired}`;
4610
+ });
4497
4611
  const actions = handoffActions(handoff).map((action, index) => {
4498
4612
  const active = index === overlay.index;
4499
4613
  const caret = active ? style.cyan("\u276F") : " ";
@@ -4553,6 +4667,24 @@ function renderHelp(scroll, rows, cols) {
4553
4667
  const footer = style.grey(offset < maxScroll ? "\u2191\u2193 scroll \xB7 any other key closes" : "any key closes");
4554
4668
  return frame("Commands", [...lines.slice(offset, offset + room), footer], rows, cols);
4555
4669
  }
4670
+ var QUOTE_MAX_LINES = 8;
4671
+ function renderConfirm(overlay, rows, cols) {
4672
+ const { choice, quote, note } = overlay;
4673
+ const inner = Math.max(1, cols - 3);
4674
+ const options = (choice?.options ?? []).map((o, i) => `${i === choice?.index ? style.cyan("\u276F") : " "} ${i + 1}. ${o.label}`);
4675
+ const hint = choice ? style.grey(`\u2191\u2193 move \xB7 enter or 1\u2013${choice.options.length} chooses \xB7 esc cancels`) : style.grey("enter confirms \xB7 esc cancels");
4676
+ const noteLines = note ? ["", ...note.split("\n")] : [];
4677
+ const rest = [...noteLines, ...options.length > 0 ? ["", ...options] : [], "", hint];
4678
+ const quoted = [];
4679
+ if (quote !== void 0) {
4680
+ const fixed = 1 + wrap(overlay.message, inner).length + 1 + 1 + rest.flatMap((l) => wrap(l, inner)).length;
4681
+ const room = Math.max(2, Math.min(QUOTE_MAX_LINES, rows - fixed));
4682
+ const wrapped = wrap(quote, inner - 2);
4683
+ const shown = wrapped.length > room ? [...wrapped.slice(0, room - 1), "\u2026"] : wrapped;
4684
+ quoted.push("", ...shown.map((l) => `${style.grey("\u2502")} ${l}`));
4685
+ }
4686
+ return frame(overlay.title, [overlay.message, ...quoted, ...rest], rows, cols);
4687
+ }
4556
4688
  function frame(title, body, rows, cols) {
4557
4689
  const inner = Math.max(1, cols - 2);
4558
4690
  const lines = [
@@ -4563,9 +4695,9 @@ function frame(title, body, rows, cols) {
4563
4695
  }
4564
4696
 
4565
4697
  // src/tui/reducer.ts
4566
- var import_core17 = require("@ordewell/core");
4698
+ var import_core20 = require("@ordewell/core");
4567
4699
  var import_plan_utils3 = require("@ordewell/core/plan-utils");
4568
- var KNOWN_PROVIDERS2 = Object.keys(import_core17.ALL_PROVIDERS);
4700
+ var KNOWN_PROVIDERS2 = Object.keys(import_core20.ALL_PROVIDERS);
4569
4701
  var DEFAULT_EFFORT = "__runner_default__";
4570
4702
  function step(state, effects = []) {
4571
4703
  return { state, effects };
@@ -4584,6 +4716,17 @@ function alreadySpoken(messages, content) {
4584
4716
  const redrawn = messages.at(-1)?.role === "system" && messages.at(-1)?.content === content;
4585
4717
  return spoken || redrawn;
4586
4718
  }
4719
+ function runLabel(tasks) {
4720
+ const running = tasks.filter(isTaskRunning);
4721
+ if (running.length > 0) {
4722
+ const [first] = running;
4723
+ const name = first.assignedRunner ? `${first.title} \xB7 ${first.assignedRunner}` : first.title;
4724
+ return running.length > 1 ? `${name} (+${running.length - 1} more)` : name;
4725
+ }
4726
+ const waiting = tasks.filter((t) => t.status === "awaiting_user").length;
4727
+ if (waiting === 0) return "";
4728
+ return waiting === 1 ? "1 task waits for you" : `${waiting} tasks wait for you`;
4729
+ }
4587
4730
  function researchLabel(messages, summary) {
4588
4731
  const others = messages.filter(isPendingResearch).length - 1;
4589
4732
  return others > 0 ? `${summary} (+${others} more)` : summary;
@@ -4653,7 +4796,8 @@ function reduce(state, action) {
4653
4796
  expandedTaskId,
4654
4797
  taskEditor: expandedTaskId !== null ? state.taskEditor : null
4655
4798
  };
4656
- return step({ ...next, selectedTask: Math.min(state.selectedTask, Math.max(0, planRows(next).length - 1)) });
4799
+ const selectedTask = clampSelection(state.selectedTask, planRows(next).length);
4800
+ return step(settlePlan({ ...next, selectedTask, focus: tasks.length === 0 ? "chat" : state.focus }));
4657
4801
  }
4658
4802
  case "plannerMessage": {
4659
4803
  if (stale(state, action.sessionId)) return step(state);
@@ -4686,6 +4830,16 @@ function reduce(state, action) {
4686
4830
  const status = state.status === "planning" ? "researching" : state.status;
4687
4831
  return step({ ...spoken, status, busyLabel: researchLabel(spoken.messages, summary) });
4688
4832
  }
4833
+ // A settled transcript line, not a research step: nothing ever settles a
4834
+ // task's start, so as a step it stayed "⋯" forever and was counted into
4835
+ // the planner's next "(+N more)".
4836
+ case "taskStarted": {
4837
+ if (stale(state, action.sessionId)) return step(state);
4838
+ const tasks = state.tasks.map((t) => t.id === action.taskId && !isTaskRunning(t) ? { ...t, status: "in_progress" } : t);
4839
+ const who = action.runner ? ` \xB7 ${action.runner}` : "";
4840
+ const spoken = say(state, "system", sanitize(`Started "${action.title}"${who}`));
4841
+ return step({ ...spoken, tasks, status: "executing", busyLabel: runLabel(tasks) });
4842
+ }
4689
4843
  case "researchStepDone": {
4690
4844
  if (stale(state, action.sessionId)) return step(state);
4691
4845
  const messages = settleResearchStep(state.messages, action);
@@ -4726,7 +4880,7 @@ function reduce(state, action) {
4726
4880
  return t;
4727
4881
  });
4728
4882
  if (!changed) return step(state);
4729
- return step({ ...state, status: "executing", tasks });
4883
+ return step({ ...state, status: "executing", tasks, busyLabel: runLabel(tasks) });
4730
4884
  }
4731
4885
  case "queueReady": {
4732
4886
  if (stale(state, action.sessionId)) return step(state);
@@ -4746,7 +4900,7 @@ function reduce(state, action) {
4746
4900
  if (stale(state, action.sessionId)) return step(state);
4747
4901
  return step(showDiff(state, action.diff));
4748
4902
  }
4749
- case "handoffDiscarded": {
4903
+ case "runCleared": {
4750
4904
  if (stale(state, action.sessionId)) return step(state);
4751
4905
  const overlay = state.overlay?.kind === "handoff" ? null : state.overlay;
4752
4906
  return step({ ...clearIsolation(state), overlay });
@@ -4787,6 +4941,7 @@ function reduce(state, action) {
4787
4941
  return step(refillPicker({ ...state, sessions: action.sessions }, ["load-session", "delete-session"]));
4788
4942
  case "rewindTargetsLoaded":
4789
4943
  if (stale(state, action.sessionId)) return step(state);
4944
+ if (action.pick !== void 0) return pickRewindTarget({ ...state, rewindTargets: action.targets }, action.pick);
4790
4945
  return step(refillPicker({ ...state, rewindTargets: action.targets }, ["rewind"]));
4791
4946
  // A fork holds no run, whatever the session it came from was doing — the
4792
4947
  // original keeps its run, and its events are stale from here on.
@@ -4802,6 +4957,15 @@ function reduce(state, action) {
4802
4957
  pendingApprovals: [],
4803
4958
  overlay: state.overlay?.kind === "approval" ? null : state.overlay
4804
4959
  });
4960
+ // The rewound message comes back as a draft to edit and resend, so it lands
4961
+ // as if just typed: history is not being browsed, and nothing is parked.
4962
+ case "inputPrefilled":
4963
+ if (stale(state, action.sessionId)) return step(state);
4964
+ return step({
4965
+ ...state,
4966
+ focus: "chat",
4967
+ editor: { ...state.editor, text: action.text, cursor: action.text.length, historyIndex: state.editor.history.length, draft: "" }
4968
+ });
4805
4969
  case "runnersLoaded":
4806
4970
  return step(refillPicker({
4807
4971
  ...state,
@@ -4827,11 +4991,7 @@ function reduce(state, action) {
4827
4991
  return step(say(state, "system", action.message));
4828
4992
  case "resize": {
4829
4993
  const resized = { ...state, rows: action.rows, cols: action.cols, selection: null };
4830
- return step({
4831
- ...resized,
4832
- scroll: clamp(resized.scroll, chatScrollMax(resized)),
4833
- planScroll: resized.planScroll === null ? null : clamp(resized.planScroll, planScrollExtent(resized).maxScroll)
4834
- });
4994
+ return step(settlePlan({ ...resized, scroll: clamp(resized.scroll, chatScrollMax(resized)) }));
4835
4995
  }
4836
4996
  // Whether there is anything to animate is the app loop's call (it owns the
4837
4997
  // timer); a tick that arrives simply advances the frame.
@@ -4890,6 +5050,7 @@ function applySettings(state, settings) {
4890
5050
  orchestratorModel: typeof settings.orchestratorModel === "string" ? settings.orchestratorModel : state.orchestratorModel,
4891
5051
  plannerProvider: typeof settings.aiProvider === "string" ? settings.aiProvider : state.plannerProvider,
4892
5052
  plannerEffort: typeof settings.plannerThinkingEffort === "string" ? settings.plannerThinkingEffort : state.plannerEffort,
5053
+ maxParallel: typeof settings.maxParallel === "number" ? settings.maxParallel : state.maxParallel,
4893
5054
  allowlist: settings.modelAllowlist && typeof settings.modelAllowlist === "object" ? settings.modelAllowlist : state.allowlist
4894
5055
  };
4895
5056
  }
@@ -4906,11 +5067,11 @@ function modelDetail(m) {
4906
5067
  function providerErrorHint(state) {
4907
5068
  const failed = Object.keys(state.providerErrors ?? {});
4908
5069
  if (failed.length === 0) return void 0;
4909
- const names = failed.map((p) => import_core17.ALL_PROVIDERS[p]?.label ?? p);
5070
+ const names = failed.map((p) => import_core20.ALL_PROVIDERS[p]?.label ?? p);
4910
5071
  return `\u26A0 Unavailable (key or fetch failed): ${names.join(", ")} \u2014 showing working providers only.`;
4911
5072
  }
4912
5073
  function plannerModelItems(state) {
4913
- const runner = (0, import_core17.runnerForProvider)(state.plannerProvider);
5074
+ const runner = (0, import_core20.runnerForProvider)(state.plannerProvider);
4914
5075
  if (!runner) return state.orchestratorModels.map((m) => ({ id: m.id, label: m.label, detail: modelDetail(m) }));
4915
5076
  const items = state.models.filter((m) => m.runners?.includes(runner)).map((m) => ({
4916
5077
  id: m.id,
@@ -4924,7 +5085,7 @@ function plannerModelItems(state) {
4924
5085
  return items.length > 0 ? items : [{ id: "", label: `No ${runner} models discovered yet`, detail: "run /refresh", disabled: true }];
4925
5086
  }
4926
5087
  function plannerEffortItems(state) {
4927
- const runner = (0, import_core17.runnerForProvider)(state.plannerProvider);
5088
+ const runner = (0, import_core20.runnerForProvider)(state.plannerProvider);
4928
5089
  if (!runner) {
4929
5090
  return [{ id: "", label: "Thinking effort applies to a coding-agent planner", detail: "switch with /planner", disabled: true }];
4930
5091
  }
@@ -5049,7 +5210,7 @@ function pointerPane(state, key) {
5049
5210
  }
5050
5211
  function scrollPointed(state, key) {
5051
5212
  const delta = key.name === "scrollup" ? WHEEL_NOTCH : -WHEEL_NOTCH;
5052
- return pointerPane(state, key) === "plan" ? scrollPlan(state, delta) : scrollChat(state, delta);
5213
+ return pointerPane(state, key) === "plan" ? scrollPlan(state, delta, false) : scrollChat(state, delta);
5053
5214
  }
5054
5215
  var isMouseSelect = (key) => key.name === "mousedown" || key.name === "mousedrag" || key.name === "mouseup";
5055
5216
  function selectionCell(state, pane, key) {
@@ -5082,10 +5243,23 @@ function clamp(value, max) {
5082
5243
  function scrollChat(state, delta) {
5083
5244
  return step({ ...state, scroll: clamp(state.scroll + delta, chatScrollMax(state)) });
5084
5245
  }
5085
- function scrollPlan(state, delta) {
5086
- const { followOffset, maxScroll } = planScrollExtent(state);
5087
- const from = state.planScroll ?? followOffset;
5088
- return step({ ...state, planScroll: clamp(from - delta, maxScroll) });
5246
+ function settlePlan(state) {
5247
+ if (planPaneWidth(state) === 0) return state;
5248
+ const layout = planScrollExtent(state);
5249
+ return { ...state, planScroll: revealOffset(layout, planOffset(layout, state.planScroll), layout.anchor) };
5250
+ }
5251
+ function scrollPlan(state, delta, paged) {
5252
+ const layout = planScrollExtent(state);
5253
+ const from = planOffset(layout, state.planScroll);
5254
+ const to = clamp(from - delta, layout.maxScroll);
5255
+ const scrolled = { ...state, planScroll: to };
5256
+ if (state.taskEditor || layout.rowSpans.length === 0) return step(scrolled);
5257
+ const back = delta > 0;
5258
+ if (!paged) {
5259
+ return step({ ...scrolled, selectedTask: rowNearestView(layout, to, state.selectedTask) });
5260
+ }
5261
+ if (to === from) return step({ ...scrolled, selectedTask: back ? 0 : layout.rowSpans.length - 1 });
5262
+ return step({ ...scrolled, selectedTask: back ? bottomRowInView(layout, to) : topRowInView(layout, to) });
5089
5263
  }
5090
5264
  var plannerInFlight = (state) => state.status === "planning" || state.status === "researching";
5091
5265
  function stopPlanning(state, sessionId) {
@@ -5115,7 +5289,10 @@ function handleKey2(state, key) {
5115
5289
  return step({ ...state, editor: { ...state.editor, text, cursor: token.start + completed.length } });
5116
5290
  }
5117
5291
  }
5118
- if (key.name === "tab") return step({ ...state, focus: state.focus === "chat" ? "plan" : "chat" });
5292
+ if (key.name === "tab") {
5293
+ if (state.focus === "chat" && state.tasks.length === 0) return step(state);
5294
+ return step({ ...state, focus: state.focus === "chat" ? "plan" : "chat" });
5295
+ }
5119
5296
  if (state.focus === "plan") return handlePlanKey(state, key);
5120
5297
  if (key.name === "enter") return submit(state);
5121
5298
  if (key.name === "escape") return step({ ...state, editor: { ...state.editor, text: "", cursor: 0 } });
@@ -5147,35 +5324,32 @@ function handlePlanKey(state, key) {
5147
5324
  const editingTask = state.expandedTaskId ? findTask(state.tasks, state.expandedTaskId) : void 0;
5148
5325
  if (editingTask && state.taskEditor) return handleTaskEditKey(state, editingTask, state.taskEditor, key);
5149
5326
  if (key.name === "escape") {
5150
- if (state.expandedTaskId) return step({ ...state, expandedTaskId: null, planScroll: null });
5327
+ if (state.expandedTaskId) return step(settlePlan({ ...state, expandedTaskId: null }));
5151
5328
  return step({ ...state, focus: "chat" });
5152
5329
  }
5153
5330
  if (key.name === "up") {
5154
- return step({ ...state, selectedTask: Math.max(0, state.selectedTask - 1), planScroll: null });
5331
+ return step(settlePlan({ ...state, selectedTask: Math.max(0, state.selectedTask - 1) }));
5155
5332
  }
5156
5333
  if (key.name === "down") {
5157
- return step({
5158
- ...state,
5159
- selectedTask: Math.min(planRows(state).length - 1, state.selectedTask + 1),
5160
- planScroll: null
5161
- });
5334
+ return step(settlePlan({ ...state, selectedTask: clampSelection(state.selectedTask + 1, planRows(state).length) }));
5162
5335
  }
5163
5336
  const scroll = scrollDelta(key, state);
5164
- if (scroll !== null) return scrollPlan(state, scroll);
5337
+ if (scroll !== null) return scrollPlan(state, scroll, true);
5338
+ if (key.name === "char" && key.char === "a") return addTask(state, "");
5165
5339
  const row = selectedPlanRow(state);
5166
5340
  if (!row) return step(state);
5167
5341
  const task = row.task;
5168
5342
  if (key.name === "enter" || key.name === "right") {
5169
5343
  const hasSubtasks = (task.subtasks?.length ?? 0) > 0;
5170
5344
  if (hasSubtasks && state.expandedTaskId !== task.id) {
5171
- return step({ ...state, expandedTaskId: task.id, taskEditor: null, planScroll: null });
5345
+ return step(settlePlan({ ...state, expandedTaskId: task.id, taskEditor: null }));
5172
5346
  }
5173
5347
  const text = task.prompt ?? task.description ?? task.title;
5174
- return step({
5348
+ return step(settlePlan({
5175
5349
  ...state,
5176
5350
  expandedTaskId: task.id,
5177
5351
  taskEditor: { ...emptyEditor(), text, cursor: text.length }
5178
- });
5352
+ }));
5179
5353
  }
5180
5354
  if (!state.sessionId || key.name !== "char") return step(state);
5181
5355
  if (key.char === "E") return step(state, [{ type: "execute", sessionId: state.sessionId }]);
@@ -5184,7 +5358,6 @@ function handlePlanKey(state, key) {
5184
5358
  return step(state, [taskActionEffect(state, state.sessionId, task.id, action)]);
5185
5359
  }
5186
5360
  if (key.char === "d") return confirmRemoveTask(state, task);
5187
- if (key.char === "a") return addTask(state, "");
5188
5361
  if (key.char === "t") {
5189
5362
  return step(state, [{ type: "openTaskTerminal", sessionId: state.sessionId, taskId: task.id }]);
5190
5363
  }
@@ -5200,13 +5373,13 @@ function handlePlanKey(state, key) {
5200
5373
  }
5201
5374
  function handleTaskEditKey(state, task, editor, key) {
5202
5375
  if (key.name === "enter") return commitTaskEdit(state, task, editor);
5203
- if (key.name === "escape") return step({ ...state, expandedTaskId: null, taskEditor: null });
5376
+ if (key.name === "escape") return step(settlePlan({ ...state, expandedTaskId: null, taskEditor: null }));
5204
5377
  const scroll = scrollDelta(key, state);
5205
- if (scroll !== null) return scrollPlan(state, scroll);
5206
- return step({ ...state, taskEditor: applyKey(editor, key, taskEditorRoom(state)) });
5378
+ if (scroll !== null) return scrollPlan(state, scroll, true);
5379
+ return step(settlePlan({ ...state, taskEditor: applyKey(editor, key, taskEditorRoom(state)) }));
5207
5380
  }
5208
5381
  function commitTaskEdit(state, task, editor) {
5209
- const collapsed = { ...state, expandedTaskId: null, taskEditor: null };
5382
+ const collapsed = settlePlan({ ...state, expandedTaskId: null, taskEditor: null });
5210
5383
  const prompt = editor.text.trim();
5211
5384
  const original = task.prompt ?? task.description ?? task.title;
5212
5385
  if (!prompt || prompt === original || !state.sessionId) return step(collapsed);
@@ -5261,7 +5434,18 @@ function handleOverlayKey(state, overlay, key) {
5261
5434
  }
5262
5435
  function handleConfirmKey(state, overlay, key) {
5263
5436
  if (key.name === "escape") return step({ ...state, overlay: null });
5264
- if (key.name !== "enter") return step(state);
5437
+ const { choice } = overlay;
5438
+ if (!choice) return key.name === "enter" ? runConfirm(state, overlay) : step(state);
5439
+ if (key.name === "up" || key.name === "down") {
5440
+ const index = clamp(choice.index + (key.name === "down" ? 1 : -1), choice.options.length - 1);
5441
+ return step({ ...state, overlay: { ...overlay, choice: { ...choice, index } } });
5442
+ }
5443
+ const chosen = key.name === "enter" ? choice.index : key.name === "char" ? Number(key.char) - 1 : -1;
5444
+ const option = choice.options[chosen];
5445
+ if (!option) return step(state);
5446
+ return option.confirms ? runConfirm(state, overlay) : step({ ...state, overlay: null });
5447
+ }
5448
+ function runConfirm(state, overlay) {
5265
5449
  const closed = { ...state, overlay: null };
5266
5450
  if (overlay.action.kind === "new-session") return newSession(closed);
5267
5451
  if (overlay.action.kind === "remove-task") {
@@ -5274,7 +5458,8 @@ function handleConfirmKey(state, overlay, key) {
5274
5458
  if (overlay.action.kind === "init-workspace") {
5275
5459
  return step({ ...closed, status: "planning" }, [{ type: "startConversation", goal: overlay.action.goal, allowInit: true }]);
5276
5460
  }
5277
- return step(closed);
5461
+ const { index } = overlay.action;
5462
+ return withIdlePlanner(closed, (sessionId) => step(closed, [{ type: "rewindConversation", sessionId, index }]));
5278
5463
  }
5279
5464
  function handleHelpKey(state, scroll, key) {
5280
5465
  const page = pageNotch(bodyRows(state));
@@ -5298,7 +5483,7 @@ function handlePromptKey(state, overlay, key) {
5298
5483
  if (overlay.action.kind === "api-key") {
5299
5484
  return step(closed, [{ type: "setApiKey", provider: overlay.action.provider, key: value2 }]);
5300
5485
  }
5301
- if (!state.sessionId) return fail3(closed, "No active plan to add a task to.");
5486
+ if (!state.sessionId) return fail3(closed, "No active plan to add a task to \u2014 describe a goal first, then /add-task <title>.");
5302
5487
  return step(closed, [{ type: "addTask", sessionId: state.sessionId, title: value2 }]);
5303
5488
  }
5304
5489
  const value = editText(overlay.value, key);
@@ -5371,10 +5556,7 @@ function choose(state, picker2, item) {
5371
5556
  case "load-session":
5372
5557
  return step(closed, [{ type: "loadSession", sessionId: item.id }]);
5373
5558
  case "rewind":
5374
- return withSession(
5375
- closed,
5376
- (sessionId) => step(closed, [{ type: "rewindConversation", sessionId, index: Number(item.id) }])
5377
- );
5559
+ return pickRewindTarget(state, Number(item.id));
5378
5560
  case "delete-session":
5379
5561
  return step(closed, [{ type: "deleteSession", sessionId: item.id }]);
5380
5562
  case "set-key":
@@ -5455,7 +5637,7 @@ function runCommand(state, { name, args }) {
5455
5637
  case "quit":
5456
5638
  return step({ ...state, exiting: true }, [{ type: "exit" }]);
5457
5639
  case "refresh":
5458
- return step(state, [{ type: "refresh" }]);
5640
+ return step(state, [{ type: "refresh", announce: true }]);
5459
5641
  case "run":
5460
5642
  return withSession(state, (sessionId) => step(state, [{ type: "execute", sessionId }]));
5461
5643
  case "approve":
@@ -5484,6 +5666,8 @@ function runCommand(state, { name, args }) {
5484
5666
  return runners(state, args);
5485
5667
  case "auto":
5486
5668
  return setAutonomous(state, args[0]);
5669
+ case "parallel":
5670
+ return setMaxParallel(state, args[0]);
5487
5671
  case "mouse":
5488
5672
  return setMouseCapture(state, args[0]);
5489
5673
  case "sessions":
@@ -5571,12 +5755,34 @@ function rewind(state, arg) {
5571
5755
  return withIdlePlanner(state, (sessionId) => {
5572
5756
  if (arg === void 0) {
5573
5757
  return step(
5574
- { ...state, rewindTargets: null, overlay: { kind: "picker", picker: picker("Rewind to before\u2026", [], { kind: "rewind" }, { hint: "The chosen message and everything after it are discarded; the tasks stay as they are." }) } },
5758
+ { ...state, rewindTargets: null, overlay: { kind: "picker", picker: picker("Rewind to before\u2026", [], { kind: "rewind" }, { hint: "Rewinding forks the conversation from just before the chosen message; the original is kept and the tasks ride along." }) } },
5575
5759
  [{ type: "loadRewindTargets", sessionId }]
5576
5760
  );
5577
5761
  }
5578
5762
  if (!/^\d+$/.test(arg)) return fail3(state, "Usage: /rewind [<message>] \u2014 or /rewind alone to pick one.");
5579
- return step(state, [{ type: "rewindConversation", sessionId, index: Number(arg) }]);
5763
+ return step(state, [{ type: "loadRewindTargets", sessionId, pick: Number(arg) }]);
5764
+ });
5765
+ }
5766
+ var REWIND_OPTIONS = [
5767
+ { label: "Restore Conversation", confirms: true },
5768
+ { label: "Never mind", confirms: false }
5769
+ ];
5770
+ function pickRewindTarget(state, index) {
5771
+ return withIdlePlanner({ ...state, overlay: null }, () => {
5772
+ const target = state.rewindTargets?.find((t) => t.index === index);
5773
+ if (!target) return fail3({ ...state, overlay: null }, `No message ${index} to rewind to \u2014 /rewind alone lists them.`);
5774
+ return step({
5775
+ ...state,
5776
+ overlay: {
5777
+ kind: "confirm",
5778
+ title: "Rewind",
5779
+ message: "Confirm you want to restore to the point before you sent this message:",
5780
+ quote: target.content,
5781
+ note: "The conversation will be forked.\nThe code will be unchanged.",
5782
+ action: { kind: "rewind", index },
5783
+ choice: { options: REWIND_OPTIONS, index: 0 }
5784
+ }
5785
+ });
5580
5786
  });
5581
5787
  }
5582
5788
  function resolveToggle(arg, current) {
@@ -5618,6 +5824,7 @@ function newSession(state) {
5618
5824
  expandedTaskId: null,
5619
5825
  taskEditor: null,
5620
5826
  scroll: 0,
5827
+ planScroll: null,
5621
5828
  status: "idle",
5622
5829
  busyLabel: "",
5623
5830
  thinkingLine: "",
@@ -5629,7 +5836,7 @@ function newSession(state) {
5629
5836
  }
5630
5837
  function setModel(state, args) {
5631
5838
  if (args[0] === "set" && args[1]) {
5632
- const runner = (0, import_core17.runnerForProvider)(state.plannerProvider);
5839
+ const runner = (0, import_core20.runnerForProvider)(state.plannerProvider);
5633
5840
  const known = runner ? modelsForRunner(state.models, runner) : [];
5634
5841
  if (runner && known.length > 0 && !known.some((m) => m.id === args[1])) {
5635
5842
  return fail3(state, `${args[1]} was not discovered for ${runner}.`);
@@ -5651,21 +5858,21 @@ function setModel(state, args) {
5651
5858
  }
5652
5859
  function plannerItems(state) {
5653
5860
  const installed = new Set(state.runners.map((r) => r.id));
5654
- const agents = import_core17.CLI_PROVIDERS.map((id) => {
5655
- const runner = (0, import_core17.runnerForProvider)(id);
5861
+ const agents = import_core20.CLI_PROVIDERS.map((id) => {
5862
+ const runner = (0, import_core20.runnerForProvider)(id);
5656
5863
  const ready = installed.has(runner);
5657
5864
  return {
5658
5865
  id,
5659
- label: import_core17.ALL_PROVIDERS[id].label,
5866
+ label: import_core20.ALL_PROVIDERS[id].label,
5660
5867
  detail: ready ? "coding agent \xB7 no API key needed" : "CLI not installed or not on PATH",
5661
5868
  selected: state.plannerProvider === id,
5662
5869
  disabled: !ready
5663
5870
  };
5664
5871
  });
5665
- const vendors = import_core17.PROVIDER_PRIORITY.filter((id) => state.configuredProviders.includes(id)).map((id) => ({
5872
+ const vendors = import_core20.PROVIDER_PRIORITY.filter((id) => state.configuredProviders.includes(id)).map((id) => ({
5666
5873
  id,
5667
- label: import_core17.ALL_PROVIDERS[id].label,
5668
- detail: import_core17.ALL_PROVIDERS[id].apiKeyEnvVar,
5874
+ label: import_core20.ALL_PROVIDERS[id].label,
5875
+ detail: import_core20.ALL_PROVIDERS[id].apiKeyEnvVar,
5669
5876
  selected: state.plannerProvider === id
5670
5877
  }));
5671
5878
  return vendors.length > 0 ? [...agents, ...vendors] : [...agents, { id: "", label: "No API providers configured", detail: "add a key with /key", disabled: true }];
@@ -5723,16 +5930,16 @@ function setKey(state, args) {
5723
5930
  }
5724
5931
  return step(state, [{ type: "setApiKey", provider, key: args.slice(2).join(" ") }]);
5725
5932
  }
5726
- const items = import_core17.PROVIDER_PRIORITY.map((id) => ({
5933
+ const items = import_core20.PROVIDER_PRIORITY.map((id) => ({
5727
5934
  id,
5728
- label: import_core17.ALL_PROVIDERS[id].label,
5729
- detail: import_core17.ALL_PROVIDERS[id].apiKeyEnvVar,
5935
+ label: import_core20.ALL_PROVIDERS[id].label,
5936
+ detail: import_core20.ALL_PROVIDERS[id].apiKeyEnvVar,
5730
5937
  selected: state.configuredProviders.includes(id)
5731
5938
  }));
5732
5939
  return step({ ...state, overlay: { kind: "picker", picker: picker("API provider key", items, { kind: "set-key" }) } });
5733
5940
  }
5734
5941
  function keyPrompt(provider) {
5735
- const meta = import_core17.ALL_PROVIDERS[provider];
5942
+ const meta = import_core20.ALL_PROVIDERS[provider];
5736
5943
  return {
5737
5944
  kind: "prompt",
5738
5945
  title: `${meta.label} API key`,
@@ -5807,6 +6014,12 @@ function runners(state, args) {
5807
6014
  })
5808
6015
  } });
5809
6016
  }
6017
+ function setMaxParallel(state, arg) {
6018
+ if (!arg) return step(say(state, "system", `Up to ${state.maxParallel} AI task${state.maxParallel === 1 ? "" : "s"} run at once \u2014 /parallel <n> changes it.`));
6019
+ const limit = (0, import_core20.parseMaxParallel)(arg);
6020
+ if (limit === null) return fail3(state, "Usage: /parallel [<number of tasks, 1 or more>]");
6021
+ return step({ ...state, maxParallel: limit }, [{ type: "setMaxParallel", limit }]);
6022
+ }
5810
6023
  function setAutonomous(state, arg) {
5811
6024
  const enabled = resolveToggle(arg, state.autonomous);
5812
6025
  if (enabled === null) return fail3(state, "Usage: /auto [on|off]");
@@ -6009,16 +6222,20 @@ function taskEffortCommand(state, args) {
6009
6222
  return assignTaskEffort(state, sessionId, task, value);
6010
6223
  });
6011
6224
  }
6225
+ function clampSelection(index, rows) {
6226
+ return Math.max(0, Math.min(index, rows - 1));
6227
+ }
6012
6228
  function addTask(state, title) {
6013
- return withSession(state, (sessionId) => {
6014
- if (!title) {
6015
- return step({
6016
- ...state,
6017
- overlay: { kind: "prompt", title: "New task title", value: "", action: { kind: "add-task" } }
6018
- });
6019
- }
6020
- return step(state, [{ type: "addTask", sessionId, title }]);
6021
- });
6229
+ if (!state.sessionId) {
6230
+ return fail3(state, "No active plan to add a task to \u2014 describe a goal first, then /add-task <title>.");
6231
+ }
6232
+ if (!title) {
6233
+ return step({
6234
+ ...state,
6235
+ overlay: { kind: "prompt", title: "New task title", value: "", action: { kind: "add-task" } }
6236
+ });
6237
+ }
6238
+ return step(state, [{ type: "addTask", sessionId: state.sessionId, title }]);
6022
6239
  }
6023
6240
  function taskCommand(state, token, run) {
6024
6241
  return withSession(state, (sessionId) => {
@@ -6058,7 +6275,9 @@ function createApp(deps) {
6058
6275
  function dispatch(action) {
6059
6276
  if (exited) return;
6060
6277
  const result = reduce(state, action);
6278
+ const previousSession = state.sessionId;
6061
6279
  state = result.state;
6280
+ if (state.sessionId && state.sessionId !== previousSession) deps.onSessionChange?.(state.sessionId, state);
6062
6281
  if (state.exiting) {
6063
6282
  exited = true;
6064
6283
  clearInterval(spinnerInterval);
@@ -6090,7 +6309,7 @@ function createApp(deps) {
6090
6309
 
6091
6310
  // src/tui/effects.ts
6092
6311
  var import_child_process4 = require("child_process");
6093
- var import_core18 = require("@ordewell/core");
6312
+ var import_core21 = require("@ordewell/core");
6094
6313
  var import_plan_utils4 = require("@ordewell/core/plan-utils");
6095
6314
  var ConversationQueue = class {
6096
6315
  tail = Promise.resolve();
@@ -6261,7 +6480,7 @@ async function perform(effect, deps) {
6261
6480
  dispatch({ type: "notice", message: `Orchestrator model set to ${effect.modelId}.` });
6262
6481
  return;
6263
6482
  case "setPlanner": {
6264
- const meta = import_core18.ALL_PROVIDERS[effect.provider];
6483
+ const meta = import_core21.ALL_PROVIDERS[effect.provider];
6265
6484
  if (!meta) throw new Error(`Unknown planner: ${effect.provider}`);
6266
6485
  const env = { AI_PROVIDER: effect.provider };
6267
6486
  const settings = await api.updateSettings({ env });
@@ -6273,7 +6492,7 @@ async function perform(effect, deps) {
6273
6492
  });
6274
6493
  dispatch({
6275
6494
  type: "notice",
6276
- message: `${(0, import_core18.isCliProvider)(effect.provider) ? `Planning with ${meta.label} \u2014 no API key needed.` : `Planner set to ${meta.label}.`} ${plannerModelNotice(recall2)}`
6495
+ message: `${(0, import_core21.isCliProvider)(effect.provider) ? `Planning with ${meta.label} \u2014 no API key needed.` : `Planner set to ${meta.label}.`} ${plannerModelNotice(recall2)}`
6277
6496
  });
6278
6497
  await loadModels(deps);
6279
6498
  return;
@@ -6285,7 +6504,7 @@ async function perform(effect, deps) {
6285
6504
  dispatch({ type: "notice", message: `Planner effort set to ${effect.effort || "the runner default"}.` });
6286
6505
  return;
6287
6506
  case "setApiKey": {
6288
- const meta = import_core18.ALL_PROVIDERS[effect.provider];
6507
+ const meta = import_core21.ALL_PROVIDERS[effect.provider];
6289
6508
  if (!meta) throw new Error(`Unknown provider: ${effect.provider}`);
6290
6509
  await api.updateSettings({ env: { [meta.apiKeyEnvVar]: effect.key } });
6291
6510
  deps.setEnvVar(meta.apiKeyEnvVar, effect.key);
@@ -6316,6 +6535,11 @@ async function perform(effect, deps) {
6316
6535
  dispatch({ type: "notice", message: effect.message });
6317
6536
  return;
6318
6537
  }
6538
+ case "setMaxParallel":
6539
+ await api.updateSettings({ env: { ORDEWELL_MAX_PARALLEL: String(effect.limit) } });
6540
+ deps.setEnvVar("ORDEWELL_MAX_PARALLEL", String(effect.limit));
6541
+ dispatch({ type: "notice", message: `Up to ${effect.limit} AI task${effect.limit === 1 ? "" : "s"} now run at once.` });
6542
+ return;
6319
6543
  case "setAutonomous":
6320
6544
  deps.setEnvVar("ORDEWELL_AUTONOMOUS_MODE", String(effect.enabled));
6321
6545
  dispatch({ type: "notice", message: `Autonomous mode ${effect.enabled ? "on" : "off"}.` });
@@ -6352,13 +6576,23 @@ async function perform(effect, deps) {
6352
6576
  return;
6353
6577
  }
6354
6578
  case "loadRewindTargets":
6355
- dispatch({ type: "rewindTargetsLoaded", targets: await api.rewindTargets(effect.sessionId), sessionId: effect.sessionId });
6579
+ dispatch({
6580
+ type: "rewindTargetsLoaded",
6581
+ targets: await api.rewindTargets(effect.sessionId),
6582
+ sessionId: effect.sessionId,
6583
+ ...effect.pick !== void 0 && { pick: effect.pick }
6584
+ });
6356
6585
  return;
6586
+ // A rewind is a fork from an earlier point, so the TUI follows it the way
6587
+ // it follows `/fork`; the original keeps its whole conversation. The
6588
+ // rewound message comes back into the input, to edit and send again.
6357
6589
  case "rewindConversation": {
6358
- const plan = await api.rewindConversation(effect.sessionId, effect.index);
6359
- dispatch({ type: "chatRestored", history: plan.conversationHistory ?? [], sessionId: effect.sessionId });
6360
- dispatch({ type: "planUpdated", plan, sessionId: effect.sessionId });
6361
- dispatch({ type: "notice", message: "Rewound the conversation. The tasks are unchanged; your next message continues from here." });
6590
+ const fork = await api.rewindConversation(effect.sessionId, effect.index);
6591
+ dispatch({ type: "sessionForked", sessionId: fork.sessionId, goal: fork.goal });
6592
+ dispatch({ type: "chatRestored", history: fork.plan.conversationHistory ?? [], sessionId: fork.sessionId });
6593
+ dispatch({ type: "planUpdated", plan: fork.plan, sessionId: fork.sessionId });
6594
+ dispatch({ type: "inputPrefilled", text: fork.rewoundMessage, sessionId: fork.sessionId });
6595
+ dispatch({ type: "notice", message: `Forked ${effect.sessionId} into ${fork.sessionId} from before that message \u2014 the original is kept (/sessions to go back). The message is ready to edit and resend.` });
6362
6596
  return;
6363
6597
  }
6364
6598
  // The redrawn transcript opens with the summary entry, which is what the
@@ -6373,15 +6607,17 @@ async function perform(effect, deps) {
6373
6607
  dispatch({ type: "handoffDiff", diff: await api.reviewRunDiff(effect.sessionId), sessionId: effect.sessionId });
6374
6608
  return;
6375
6609
  // A conflict or a refusal is an answer, not a fault: either way the user's
6376
- // tree is exactly as it was, and the words say what to do next.
6610
+ // tree is exactly as it was, and the words say what to do next. A full
6611
+ // merge leaves nothing to hand over — the daemon has cleared the run up.
6377
6612
  case "isolationMerge": {
6378
- const { ok, message } = mergeOutcome(await api.mergeRun(effect.sessionId), effect.branch, effect.group === true);
6613
+ const { ok, message } = mergeOutcome(await api.mergeRun(effect.sessionId), effect.branch, effect.group === true, effect.repaired ?? []);
6614
+ if (ok) dispatch({ type: "runCleared", sessionId: effect.sessionId });
6379
6615
  dispatch({ type: ok ? "notice" : "failed", message });
6380
6616
  return;
6381
6617
  }
6382
6618
  case "isolationDiscard":
6383
6619
  await api.discardRun(effect.sessionId);
6384
- dispatch({ type: "handoffDiscarded", sessionId: effect.sessionId });
6620
+ dispatch({ type: "runCleared", sessionId: effect.sessionId });
6385
6621
  dispatch({ type: "notice", message: `Discarded the run and ${effect.branch}.` });
6386
6622
  return;
6387
6623
  case "isolationCleanup":
@@ -6418,7 +6654,7 @@ async function perform(effect, deps) {
6418
6654
  return;
6419
6655
  case "refresh":
6420
6656
  await Promise.all([loadRunners(deps), loadSettings(deps), loadModels(deps)]);
6421
- dispatch({ type: "notice", message: "Refreshed runners, settings and models." });
6657
+ if (effect.announce) dispatch({ type: "notice", message: "Refreshed runners, settings and models." });
6422
6658
  return;
6423
6659
  case "exit":
6424
6660
  deps.exit();
@@ -6434,7 +6670,7 @@ function osc52(text) {
6434
6670
  function copySelection(deps, text) {
6435
6671
  if (!text) return;
6436
6672
  const lines = text.split("\n").length;
6437
- const command = (0, import_core18.clipboardCopyCommand)(deps.hasBin);
6673
+ const command = (0, import_core21.clipboardCopyCommand)(deps.hasBin);
6438
6674
  if (command) {
6439
6675
  try {
6440
6676
  (deps.pipeToClipboard ?? pipeToClipboardDefault)(command, text);
@@ -6571,12 +6807,12 @@ function onExecutionEvent(dispatch, event, sessionId) {
6571
6807
  return;
6572
6808
  }
6573
6809
  case "task_started":
6574
- dispatch({ type: "researchStep", summary: `${event.title ?? event.taskId} \xB7 ${event.runner ?? ""}`.trim(), sessionId });
6810
+ dispatch({ type: "taskStarted", taskId: String(event.taskId), title: String(event.title ?? event.taskId), runner: event.runner, sessionId });
6575
6811
  return;
6576
6812
  // The extension shows these in its checkpoint panel; here they are
6577
6813
  // transcript lines, which is the TUI's equivalent surface.
6578
6814
  case "checkpoint":
6579
- dispatch({ type: "notice", message: `\xB7 Checkpoint \u2014 ${event.taskTitle}: ${(0, import_core18.truncateCheckpointSummary)(event.summary)}` });
6815
+ dispatch({ type: "notice", message: `\xB7 Checkpoint \u2014 ${event.taskTitle}: ${(0, import_core21.truncateCheckpointSummary)(event.summary)}` });
6580
6816
  return;
6581
6817
  case "approval_decided":
6582
6818
  dispatch({ type: "notice", message: describeApprovalDecision2(event) });
@@ -6632,8 +6868,10 @@ function onExecutionEvent(dispatch, event, sessionId) {
6632
6868
  case "approval_request":
6633
6869
  case "approval_settled":
6634
6870
  return;
6635
- // Its asker already has the result, from the merge request itself.
6871
+ // Its asker already has the words, from the merge request itself; a
6872
+ // viewer that did not ask still has to drop a run that is gone.
6636
6873
  case "isolation_merge":
6874
+ if (event.result.outcome === "merged") dispatch({ type: "runCleared", sessionId });
6637
6875
  return;
6638
6876
  default: {
6639
6877
  const unhandled = event;
@@ -6958,7 +7196,7 @@ async function handleTui(subArgs) {
6958
7196
  }
6959
7197
  const workspace = flag(subArgs, "--workspace") || process.cwd();
6960
7198
  try {
6961
- (0, import_core19.assertWorkspaceExists)(workspace);
7199
+ (0, import_core22.assertWorkspaceExists)(workspace);
6962
7200
  } catch {
6963
7201
  console.error(
6964
7202
  `Workspace "${workspace}" does not exist.
@@ -6967,7 +7205,7 @@ cd into a directory that exists, or pass --workspace <path> naming one that does
6967
7205
  );
6968
7206
  process.exit(1);
6969
7207
  }
6970
- registerSkillCommands((0, import_core19.createSkillsService)(workspace).listSkills());
7208
+ registerSkillCommands((0, import_core22.createSkillsService)(workspace).listSkills());
6971
7209
  const explicitPort = flag(subArgs, "--port") || process.env.ORDEWELL_PORT;
6972
7210
  const targetPort = explicitPort ? resolvePort(subArgs) : await findFreePort();
6973
7211
  const { port, owned: ownedAtLaunch } = await ensureDaemonOwned(targetPort, { detached: false });
@@ -6986,7 +7224,7 @@ cd into a directory that exists, or pass --workspace <path> naming one that does
6986
7224
  conversationQueue,
6987
7225
  port,
6988
7226
  dispatch: (action) => app.dispatch(action),
6989
- newSessionId: () => (0, import_core19.mintSessionId)(),
7227
+ newSessionId: () => (0, import_core22.mintSessionId)(),
6990
7228
  setEnvVar: (key, value) => {
6991
7229
  writeEnvVar(findEnvFile(), key, value);
6992
7230
  process.env[key] = value;
@@ -7003,7 +7241,16 @@ cd into a directory that exists, or pass --workspace <path> naming one that does
7003
7241
  },
7004
7242
  exit: () => shutdown(0)
7005
7243
  }),
7006
- onExit: () => shutdown(0)
7244
+ onExit: () => shutdown(0),
7245
+ // The same pointer `ordewell plan` leaves, so `ordewell handoff`,
7246
+ // `terminal <n>` and the other one-shot commands act on the session this
7247
+ // TUI is showing instead of saying there is none.
7248
+ onSessionChange: (sessionId, state) => {
7249
+ try {
7250
+ saveLastSession(sessionId, state.goal, state.runners.filter((r) => r.enabled).map((r) => r.id), workspace);
7251
+ } catch {
7252
+ }
7253
+ }
7007
7254
  });
7008
7255
  terminal = openTerminal({
7009
7256
  mouse: mouseCapture,
@@ -7049,6 +7296,7 @@ var COMMANDS = {
7049
7296
  model: handleModel,
7050
7297
  planner: handlePlanner,
7051
7298
  "planner-effort": handlePlannerEffort,
7299
+ parallel: handleParallel,
7052
7300
  key: handleKey,
7053
7301
  runners: handleRunners,
7054
7302
  auto: handleAuto,
@@ -7095,10 +7343,12 @@ function cliVersion() {
7095
7343
  }
7096
7344
 
7097
7345
  // src/main.ts
7346
+ var TUI_FLAGS = /* @__PURE__ */ new Set(["--workspace", "--port"]);
7098
7347
  async function main() {
7099
- (0, import_core20.migrateOldConfigDir)();
7348
+ (0, import_core23.migrateOldConfigDir)();
7100
7349
  loadEnvFile();
7101
7350
  const argv = process.argv.slice(2);
7351
+ if (TUI_FLAGS.has(argv[0] ?? "")) argv.unshift("tui");
7102
7352
  const command = argv[0] ?? (process.stdin.isTTY ? "tui" : "--help");
7103
7353
  if (command === "--help" || command === "-h") {
7104
7354
  printHelp();
@@ -7108,6 +7358,10 @@ async function main() {
7108
7358
  console.log(cliVersion());
7109
7359
  return;
7110
7360
  }
7361
+ const sessionAt = argv.indexOf("--session-id");
7362
+ if (sessionAt > 0 && argv[sessionAt + 1]) {
7363
+ argv[sessionAt + 1] = expandSessionId(argv[sessionAt + 1], flag(argv, "--workspace") || process.cwd());
7364
+ }
7111
7365
  const handler = COMMANDS[command];
7112
7366
  if (!handler) {
7113
7367
  console.error(`Unknown command: ${command}`);