claude-overnight 1.25.29 → 1.25.31

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.
@@ -1 +1 @@
1
- export declare const VERSION = "1.25.29";
1
+ export declare const VERSION = "1.25.31";
package/dist/_version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by build — do not edit manually.
2
- export const VERSION = "1.25.29";
2
+ export const VERSION = "1.25.31";
package/dist/index.js CHANGED
@@ -7,16 +7,18 @@ import { VERSION } from "./_version.js";
7
7
  import { query } from "@anthropic-ai/claude-agent-sdk";
8
8
  import { Swarm } from "./swarm.js";
9
9
  import { planTasks, refinePlan, identifyThemes, buildThinkingTasks, orchestrate, salvageFromFile } from "./planner.js";
10
- import { modelDisplayName, formatContextWindow, DEFAULT_MODEL } from "./models.js";
10
+ import { formatContextWindow, DEFAULT_MODEL } from "./models.js";
11
11
  import { setPlannerEnvResolver } from "./planner-query.js";
12
12
  import { setTranscriptRunDir } from "./transcripts.js";
13
+ import { getProxyPort, buildProxyUrl } from "./proxy-port.js";
13
14
  import { pickModel, loadProviders, preflightProvider, buildEnvResolver, healthCheckCursorProxy, PROXY_DEFAULT_URL, isCursorProxyProvider, readCursorProxyLogTail, ensureCursorProxyRunning, bundledComposerProxyShellCommand, warnMacCursorAgentShellPatchIfNeeded, hasCursorAgentToken, } from "./providers.js";
14
15
  import { RunDisplay } from "./ui.js";
15
16
  import { renderSummary, wrap } from "./render.js";
16
17
  import { executeRun } from "./run.js";
17
- import { parseCliFlags, isAuthError, fetchModels, ask, select, selectKey, loadTaskFile, validateConcurrency, isGitRepo, validateGitRepo, showPlan, BRAILLE, makeProgressLog, } from "./cli.js";
18
+ import { parseCliFlags, isAuthError, fetchModels, ask, select, selectKey, loadTaskFile, validateConcurrency, isGitRepo, validateGitRepo, showPlan, makeProgressLog, } from "./cli.js";
18
19
  import { loadRunState, findIncompleteRuns, findOrphanedDesigns, backfillOrphanedPlans, formatTimeAgo, showRunHistory, readPreviousRunKnowledge, createRunDir, updateLatestSymlink, readMdDir, saveRunState, autoMergeBranches, } from "./state.js";
19
20
  import { runSetupCoach, loadUserSettings, saveUserSettings, COACH_MODEL } from "./coach.js";
21
+ import { editRunSettings, formatSettingsSummary } from "./settings.js";
20
22
  function countTasksInFile(path) {
21
23
  try {
22
24
  const parsed = JSON.parse(readFileSync(path, "utf-8"));
@@ -65,8 +67,6 @@ async function promptResumeOverrides(state, cliFlags, argv, noTTY, runDir) {
65
67
  catch { }
66
68
  return;
67
69
  }
68
- // Kick off model fetch in the background so it's ready if the user picks Edit.
69
- const modelsPromise = fetchModels(20_000).catch(() => []);
70
70
  // ── Interactive review ──
71
71
  const fmtSummary = () => {
72
72
  const remaining = Math.max(1, state.remaining);
@@ -89,6 +89,7 @@ async function promptResumeOverrides(state, cliFlags, argv, noTTY, runDir) {
89
89
  console.log(` ${chalk.dim("concur ")}${chalk.white(String(state.concurrency))}`);
90
90
  console.log(` ${chalk.dim("usage cap ")}${chalk.white(capStr)}`);
91
91
  console.log(` ${chalk.dim("extra ")}${chalk.white(extraStr)}`);
92
+ console.log(` ${chalk.dim("perms ")}${chalk.white(state.permissionMode === "bypassPermissions" ? "yolo" : state.permissionMode)}`);
92
93
  };
93
94
  fmtSummary();
94
95
  const action = await selectKey("", [
@@ -100,64 +101,24 @@ async function promptResumeOverrides(state, cliFlags, argv, noTTY, runDir) {
100
101
  process.exit(0);
101
102
  if (action === "r")
102
103
  return;
103
- // ── Edit walk ──
104
- let modelFrame = 0;
105
- const modelSpinner = setInterval(() => {
106
- process.stdout.write(`\x1B[2K\r ${chalk.cyan(BRAILLE[modelFrame++ % BRAILLE.length])} ${chalk.dim("loading models...")}`);
107
- }, 120);
108
- let models;
109
- try {
110
- models = await modelsPromise;
111
- }
112
- finally {
113
- clearInterval(modelSpinner);
114
- process.stdout.write(`\x1B[2K\r`);
115
- }
116
- const pick = await pickModel(`${chalk.cyan("①")} Worker model:`, models, state.workerProviderId ?? state.workerModel);
117
- state.workerModel = pick.model;
118
- state.workerProviderId = pick.providerId;
119
- const remAns = await ask(`\n ${chalk.cyan("②")} Remaining sessions ${chalk.dim(`[${state.remaining}]:`)} `);
120
- const parsedRem = parseInt(remAns);
121
- if (!isNaN(parsedRem) && parsedRem > 0) {
122
- state.remaining = parsedRem;
123
- state.budget = state.accCompleted + state.accFailed + parsedRem;
124
- }
125
- const concAns = await ask(`\n ${chalk.cyan("③")} Concurrency ${chalk.dim(`[${state.concurrency}]:`)} `);
126
- const parsedConc = parseInt(concAns);
127
- if (!isNaN(parsedConc) && parsedConc >= 1)
128
- state.concurrency = parsedConc;
129
- const currentCap = state.usageCap != null ? String(Math.round(state.usageCap * 100)) : "off";
130
- const capAns = await ask(`\n ${chalk.cyan("④")} Usage cap % ${chalk.dim(`[${currentCap}]`)} ${chalk.dim("(0 = off):")} `);
131
- if (capAns.trim()) {
132
- const v = parseFloat(capAns);
133
- if (!isNaN(v) && v >= 0 && v <= 100)
134
- state.usageCap = v > 0 ? v / 100 : undefined;
135
- }
136
- const currentExtra = state.allowExtraUsage
137
- ? (state.extraUsageBudget ? `$${state.extraUsageBudget}` : "unlimited")
138
- : "off";
139
- const extraChoice = await select(`${chalk.cyan("⑤")} Extra usage ${chalk.dim(`[current: ${currentExtra}]`)}:`, [
140
- { name: "Keep current", value: "keep" },
141
- { name: "Off", value: "off", hint: "stop at plan limit" },
142
- { name: "With $ cap", value: "budget", hint: "set a spending cap" },
143
- { name: "Unlimited", value: "unlimited", hint: "no cap, billed as overage" },
144
- ]);
145
- if (extraChoice === "off") {
146
- state.allowExtraUsage = false;
147
- state.extraUsageBudget = undefined;
148
- }
149
- else if (extraChoice === "budget") {
150
- const bAns = await ask(` ${chalk.dim("Max extra $:")} `);
151
- const bVal = parseFloat(bAns);
152
- if (!isNaN(bVal) && bVal > 0) {
153
- state.extraUsageBudget = bVal;
154
- state.allowExtraUsage = true;
155
- }
156
- }
157
- else if (extraChoice === "unlimited") {
158
- state.allowExtraUsage = true;
159
- state.extraUsageBudget = undefined;
160
- }
104
+ const settings = {
105
+ workerModel: state.workerModel,
106
+ plannerModel: state.plannerModel,
107
+ fastModel: state.fastModel,
108
+ workerProviderId: state.workerProviderId,
109
+ plannerProviderId: state.plannerProviderId,
110
+ fastProviderId: state.fastProviderId,
111
+ concurrency: state.concurrency,
112
+ usageCap: state.usageCap,
113
+ allowExtraUsage: state.allowExtraUsage ?? false,
114
+ extraUsageBudget: state.extraUsageBudget,
115
+ permissionMode: state.permissionMode,
116
+ };
117
+ await editRunSettings({
118
+ current: settings,
119
+ cliConcurrencySet: !!cliFlags.concurrency,
120
+ });
121
+ Object.assign(state, settings);
161
122
  try {
162
123
  saveRunState(runDir, state);
163
124
  }
@@ -636,7 +597,6 @@ async function main() {
636
597
  objective = coachResult.improvedObjective;
637
598
  }
638
599
  }
639
- const modelsPromise = fetchModels();
640
600
  const defaultBudget = coachResult?.recommended.budget ?? 10;
641
601
  const budgetAns = await ask(`\n ${chalk.cyan("②")} ${chalk.dim("Budget")} ${chalk.dim("[")}${chalk.white(String(defaultBudget))}${chalk.dim("]:")} `);
642
602
  budget = parseInt(budgetAns) || defaultBudget;
@@ -644,91 +604,42 @@ async function main() {
644
604
  console.error(chalk.red(` Budget must be a positive number`));
645
605
  process.exit(1);
646
606
  }
647
- // ③ Max concurrency (skip if --concurrency set)
648
- if (cliFlags.concurrency) {
649
- concurrency = parseInt(cliFlags.concurrency);
650
- }
651
- else {
652
- const defaultC = Math.min(coachResult?.recommended.concurrency ?? 5, budget);
653
- const concAns = await ask(`\n ${chalk.cyan("③")} ${chalk.dim("Max concurrency")} ${chalk.dim("[")}${chalk.white(String(defaultC))}${chalk.dim("]:")} `);
654
- concurrency = parseInt(concAns) || defaultC;
655
- if (concurrency < 1)
656
- concurrency = 1;
657
- }
658
- let modelFrame = 0;
659
- const modelSpinner = setInterval(() => {
660
- process.stdout.write(`\x1B[2K\r ${chalk.cyan(BRAILLE[modelFrame++ % BRAILLE.length])} ${chalk.dim("loading models...")}`);
661
- }, 120);
662
- let models;
663
- try {
664
- models = await modelsPromise;
665
- }
666
- finally {
667
- clearInterval(modelSpinner);
668
- process.stdout.write(`\x1B[2K\r`);
669
- }
670
- const plannerPick = await pickModel(`${chalk.cyan("④")} Planner model ${chalk.dim("(thinking, steering -- use your strongest)")}:`, models, coachResult?.recommended.plannerModel);
671
- plannerModel = plannerPick.model;
672
- plannerProvider = plannerPick.provider;
673
- const workerPick = await pickModel(`${chalk.cyan("⑤")} Worker model ${chalk.dim("(what runs the tasks -- Qwen 3.6 Plus / OpenRouter / etc via Other…)")}:`, models, coachResult?.recommended.workerModel);
674
- workerModel = workerPick.model;
675
- workerProvider = workerPick.provider;
676
- // ⑤b Optional fast model for quick tasks that will be verified
677
- const suggestFast = !!coachResult?.recommended.fastModel;
678
- const fastChoice = await select(`${chalk.cyan("⑤b")} Fast model ${chalk.dim("(optional -- Haiku/Qwen for quick tasks, checked by worker)")}:`, [
679
- { name: "Skip", value: "skip", hint: "two-tier mode only (current setup)" },
680
- { name: "Pick a fast model", value: "pick", hint: "Haiku, Qwen, or any provider -- for well-scoped tasks" },
681
- ], suggestFast ? 1 : 0);
682
- if (fastChoice === "pick") {
683
- const fastPick = await pickModel(`${chalk.cyan("⑤c")} Fast model:`, models, coachResult?.recommended.fastModel ?? undefined);
684
- fastModel = fastPick.model;
685
- fastProvider = fastPick.provider;
686
- }
687
- const usageCapItems = [
688
- { name: "Unlimited", value: undefined, hint: "full capacity, wait through rate limits" },
689
- { name: "90%", value: 0.9, hint: "leave 10% for other work" },
690
- { name: "75%", value: 0.75, hint: "conservative, plenty of headroom" },
691
- { name: "50%", value: 0.5, hint: "use half, keep the rest" },
692
- ];
693
- const coachCap = coachResult?.recommended.usageCap;
694
- const usageCapDefault = coachCap == null ? 0
695
- : coachCap >= 0.85 ? 1
696
- : coachCap >= 0.6 ? 2
697
- : 3;
698
- usageCap = await select(`${chalk.cyan("⑥")} Usage cap:`, usageCapItems, usageCapDefault);
699
- const extraChoice = await select(`${chalk.cyan("⑦")} Allow extra usage ${chalk.dim("(billed separately)")}:`, [
700
- { name: "No", value: "no", hint: "stop when plan limits are reached" },
701
- { name: "Yes, with $ limit", value: "budget", hint: "set a spending cap" },
702
- { name: "Yes, unlimited", value: "unlimited", hint: "keep going no matter what" },
703
- ]);
704
- if (extraChoice === "budget") {
705
- const budgetAns2 = await ask(` ${chalk.dim("Max extra usage $:")} `);
706
- extraUsageBudget = parseFloat(budgetAns2);
707
- if (!extraUsageBudget || extraUsageBudget <= 0)
708
- extraUsageBudget = 5;
709
- allowExtraUsage = true;
710
- }
711
- else if (extraChoice === "unlimited")
712
- allowExtraUsage = true;
713
- // ⑧ Permission mode (skip if --yolo or --perm set)
714
607
  const cliYolo = argv.includes("--yolo");
715
- if (cliFlags.perm) {
716
- permissionMode = cliFlags.perm;
717
- }
718
- else if (cliYolo) {
719
- permissionMode = "bypassPermissions";
720
- }
721
- else {
722
- const permItems = [
723
- { name: "Auto", value: "auto", hint: "accept low-risk, reject high-risk" },
724
- { name: "Bypass all", value: "bypassPermissions", hint: "agents can run anything (yolo)" },
725
- { name: "Prompt each", value: "default", hint: "ask for every dangerous op" },
726
- ];
727
- const permDefault = coachResult?.recommended.permissionMode === "bypassPermissions" ? 1
728
- : coachResult?.recommended.permissionMode === "default" ? 2 : 0;
729
- permissionMode = await select(`${chalk.cyan("⑧")} Permissions:`, permItems, permDefault);
730
- }
731
- // Worktrees + merge (skip if --yolo, --worktrees, --no-worktrees, or --merge set)
608
+ const coach = coachResult?.recommended;
609
+ const settingsDefaults = {
610
+ workerModel: coach?.workerModel ?? DEFAULT_MODEL,
611
+ plannerModel: coach?.plannerModel ?? DEFAULT_MODEL,
612
+ fastModel: coach?.fastModel ?? undefined,
613
+ concurrency: Math.min(coach?.concurrency ?? 5, budget),
614
+ usageCap: coach?.usageCap ?? undefined,
615
+ allowExtraUsage: false,
616
+ permissionMode: cliYolo ? "bypassPermissions" : coach?.permissionMode ?? "auto",
617
+ };
618
+ const settings = await editRunSettings({
619
+ current: settingsDefaults,
620
+ cliConcurrencySet: !!cliFlags.concurrency,
621
+ defaults: coach ? {
622
+ plannerModel: coach.plannerModel,
623
+ workerModel: coach.workerModel,
624
+ fastModel: coach.fastModel ?? undefined,
625
+ concurrency: Math.min(coach.concurrency, budget),
626
+ usageCap: coach.usageCap,
627
+ permissionMode: cliYolo ? "bypassPermissions" : coach.permissionMode,
628
+ } : undefined,
629
+ });
630
+ plannerModel = settings.plannerModel;
631
+ workerModel = settings.workerModel;
632
+ fastModel = settings.fastModel;
633
+ concurrency = settings.concurrency;
634
+ usageCap = settings.usageCap;
635
+ allowExtraUsage = settings.allowExtraUsage;
636
+ extraUsageBudget = settings.extraUsageBudget;
637
+ permissionMode = cliFlags.perm ?? (cliYolo ? "bypassPermissions" : settings.permissionMode);
638
+ const savedProviders = loadProviders();
639
+ plannerProvider = settings.plannerProviderId ? savedProviders.find(p => p.id === settings.plannerProviderId) : undefined;
640
+ workerProvider = settings.workerProviderId ? savedProviders.find(p => p.id === settings.workerProviderId) : undefined;
641
+ fastProvider = settings.fastProviderId ? savedProviders.find(p => p.id === settings.fastProviderId) : undefined;
642
+ // ④ Worktrees + merge (skip if --yolo, --worktrees, --no-worktrees, or --merge set)
732
643
  const gitRepo = isGitRepo(cwd);
733
644
  if (cliYolo || argv.includes("--no-worktrees")) {
734
645
  useWorktrees = false;
@@ -739,7 +650,7 @@ async function main() {
739
650
  mergeStrategy = cliFlags.merge || "yolo";
740
651
  }
741
652
  else if (gitRepo) {
742
- const wtChoice = await select(`${chalk.cyan("")} Git isolation:`, [
653
+ const wtChoice = await select(`${chalk.cyan("④b")} Git isolation:`, [
743
654
  { name: "Worktrees + yolo merge", value: "wt-yolo", hint: "isolate agents, merge into current branch" },
744
655
  { name: "Worktrees + new branch", value: "wt-branch", hint: "isolate agents, merge into a new branch" },
745
656
  { name: "No worktrees", value: "no-wt", hint: "all agents share the working directory" },
@@ -751,31 +662,20 @@ async function main() {
751
662
  useWorktrees = false;
752
663
  mergeStrategy = "yolo";
753
664
  }
754
- const parts = [];
755
- if (fastModel)
756
- parts.push(`${modelDisplayName(plannerModel)} → ${modelDisplayName(workerModel)} + ${modelDisplayName(fastModel)}`);
757
- else if (workerModel !== plannerModel)
758
- parts.push(`${modelDisplayName(workerModel)} → ${modelDisplayName(plannerModel)}`);
759
- else
760
- parts.push(modelDisplayName(workerModel));
761
- parts.push(`budget ${budget}`, `${concurrency}×`);
665
+ const inner = formatSettingsSummary({ ...settings, permissionMode });
666
+ const parts2 = [`budget ${budget}`, `${concurrency}×`];
762
667
  if (budget > 2)
763
- parts.push("flex");
764
- if (usageCap != null)
765
- parts.push(`cap ${Math.round(usageCap * 100)}%`);
766
- parts.push(allowExtraUsage ? (extraUsageBudget ? `extra $${extraUsageBudget}` : "extra ∞") : "no extra");
767
- if (permissionMode !== "auto")
768
- parts.push(permissionMode === "bypassPermissions" ? "yolo" : "prompt");
668
+ parts2.push("flex");
769
669
  if (useWorktrees)
770
- parts.push(mergeStrategy === "branch" ? "wt→branch" : "wt→yolo");
670
+ parts2.push(mergeStrategy === "branch" ? "wt→branch" : "wt→yolo");
771
671
  else
772
- parts.push("no wt");
672
+ parts2.push("no wt");
773
673
  if (completedRuns.length > 0)
774
- parts.push(`${completedRuns.length} prior`);
775
- const inner = parts.join(chalk.dim(" · "));
776
- const innerLen = parts.join(" · ").length;
674
+ parts2.push(`${completedRuns.length} prior`);
675
+ const fullLine = inner + chalk.dim(" · ") + parts2.join(chalk.dim(" · "));
676
+ const innerLen = fullLine.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "").length;
777
677
  console.log(chalk.dim(`\n ╭${"─".repeat(innerLen + 4)}╮`));
778
- console.log(chalk.dim(" │") + ` ${inner} ` + chalk.dim("│"));
678
+ console.log(chalk.dim(" │") + ` ${fullLine} ` + chalk.dim("│"));
779
679
  console.log(chalk.dim(` ╰${"─".repeat(innerLen + 4)}╯`));
780
680
  }
781
681
  else {
@@ -889,7 +789,18 @@ async function main() {
889
789
  }
890
790
  // Auto-start cursor proxy before pinging (restarts when a token exists so stale listeners get CURSOR_API_KEY).
891
791
  if (cursorProxies.length > 0) {
892
- await ensureCursorProxyRunning();
792
+ const resolvedPort = getProxyPort(cwd);
793
+ const resolvedUrl = buildProxyUrl(resolvedPort);
794
+ await ensureCursorProxyRunning(resolvedUrl);
795
+ // Sync providers to the resolved port (may differ from default if per-project port was picked)
796
+ for (const p of cursorProxies) {
797
+ if (!p.baseURL || p.baseURL === PROXY_DEFAULT_URL) {
798
+ p.baseURL = resolvedUrl;
799
+ }
800
+ }
801
+ if (resolvedUrl !== PROXY_DEFAULT_URL) {
802
+ console.log(chalk.dim(` Proxy port: ${resolvedPort}`));
803
+ }
893
804
  if (!hasCursorAgentToken()) {
894
805
  console.error(chalk.red(` ✗ Cursor models require a User API key — add it via ${chalk.bold("Cursor…")} setup, or set ` +
895
806
  `${chalk.bold("CURSOR_API_KEY")} / ${chalk.bold("CURSOR_BRIDGE_API_KEY")}, or ${chalk.bold("cursorApiKey")} in providers.json.`));
@@ -108,6 +108,11 @@ export declare function healthCheckCursorProxy(baseUrl?: string): Promise<boolea
108
108
  * Returns model IDs like ["auto", "composer", "composer-2", "opus-4.6", ...].
109
109
  */
110
110
  export declare function fetchCursorModels(baseUrl?: string): Promise<string[]>;
111
+ /** Options for {@link ensureCursorProxyRunning}. */
112
+ export interface EnsureProxyOptions {
113
+ forceRestart?: boolean;
114
+ projectRoot?: string;
115
+ }
111
116
  /**
112
117
  * Auto-start the cursor-composer-in-claude as a detached background process.
113
118
  *
@@ -123,9 +128,13 @@ export declare function fetchCursorModels(baseUrl?: string): Promise<string[]>;
123
128
  * When `forceRestart` is true, any listener on the port is killed and the
124
129
  * bundled proxy is spawned (same as a version mismatch).
125
130
  *
126
- * Returns true when the proxy is reachable at PROXY_DEFAULT_URL.
131
+ * When `projectRoot` is provided and `baseUrl` is the default, a per-project
132
+ * port is resolved from `.claude-overnight/config.json` so concurrent runs
133
+ * in different repos don't collide on port 8765.
134
+ *
135
+ * Returns true when the proxy is reachable.
127
136
  */
128
- export declare function ensureCursorProxyRunning(baseUrl?: string, forceRestart?: boolean): Promise<boolean>;
137
+ export declare function ensureCursorProxyRunning(baseUrl?: string, opts?: EnsureProxyOptions): Promise<boolean>;
129
138
  /**
130
139
  * Full install + configure flow for cursor-composer-in-claude.
131
140
  * Walks through CLI install, API key config, and proxy start.
package/dist/providers.js CHANGED
@@ -11,6 +11,7 @@ import { getBearerToken, clearTokenCache } from "./auth.js";
11
11
  import { DEFAULT_MODEL } from "./models.js";
12
12
  import { CURSOR_PRIORITY_MODELS, CURSOR_KNOWN_MODELS, KNOWN_CURSOR_MODEL_IDS, cursorModelHint, } from "./cursor-models.js";
13
13
  import { VERSION } from "./_version.js";
14
+ import { getProxyPort, buildProxyUrl } from "./proxy-port.js";
14
15
  /** Cached system Node.js and agent script paths — resolved once, reused across envFor calls. */
15
16
  let _cachedAgentNode = null;
16
17
  let _cachedAgentScript = null;
@@ -812,13 +813,23 @@ async function isPortInUse(port, host = "127.0.0.1") {
812
813
  * When `forceRestart` is true, any listener on the port is killed and the
813
814
  * bundled proxy is spawned (same as a version mismatch).
814
815
  *
815
- * Returns true when the proxy is reachable at PROXY_DEFAULT_URL.
816
+ * When `projectRoot` is provided and `baseUrl` is the default, a per-project
817
+ * port is resolved from `.claude-overnight/config.json` so concurrent runs
818
+ * in different repos don't collide on port 8765.
819
+ *
820
+ * Returns true when the proxy is reachable.
816
821
  */
817
- export async function ensureCursorProxyRunning(baseUrl = PROXY_DEFAULT_URL, forceRestart = false) {
822
+ export async function ensureCursorProxyRunning(baseUrl = PROXY_DEFAULT_URL, opts) {
818
823
  warnMacCursorAgentShellPatchIfNeeded();
819
- const url = new URL(baseUrl);
820
- const port = parseInt(url.port, 10) || 80;
821
- // Stale listener on :8765 may have been started without CURSOR_API_KEY for the agent child.
824
+ // Resolve per-project port if no explicit base URL was given and projectRoot is available
825
+ const resolvedPort = opts?.projectRoot && baseUrl === PROXY_DEFAULT_URL
826
+ ? getProxyPort(opts.projectRoot)
827
+ : null;
828
+ const effectiveBaseUrl = resolvedPort != null ? buildProxyUrl(resolvedPort) : baseUrl;
829
+ const url = new URL(effectiveBaseUrl);
830
+ const port = resolvedPort ?? (parseInt(url.port, 10) || 80);
831
+ const forceRestart = opts?.forceRestart ?? false;
832
+ // Stale listener may have been started without CURSOR_API_KEY for the agent child.
822
833
  // When we have a token, replace the listener by default so the bundled proxy always inherits it.
823
834
  // Opt out: CURSOR_OVERNIGHT_NO_PROXY_RESTART=1 (e.g. shared port / external proxy).
824
835
  const token = resolveCursorAgentToken();
@@ -961,7 +972,7 @@ async function startProxyProcess(baseUrl, url, port) {
961
972
  catch { }
962
973
  const logFd = openSync(logPath, "a");
963
974
  console.log(chalk.dim(` Spawning proxy… ${chalk.dim(`(logs: ${logPath})`)}`));
964
- const child = spawn(process.execPath, [composerCli], {
975
+ const child = spawn(process.execPath, [composerCli, "--port", String(port)], {
965
976
  detached: true,
966
977
  stdio: ["ignore", logFd, logFd],
967
978
  env: proxyEnv,
@@ -1196,7 +1207,7 @@ export async function setupCursorProxy() {
1196
1207
  { key: "c", desc: "ancel" },
1197
1208
  ]);
1198
1209
  if (choice === "r") {
1199
- if (await ensureCursorProxyRunning(PROXY_DEFAULT_URL, true)) {
1210
+ if (await ensureCursorProxyRunning(PROXY_DEFAULT_URL, { forceRestart: true })) {
1200
1211
  console.log(chalk.green("\n ✓ Proxy is running and healthy"));
1201
1212
  return true;
1202
1213
  }
@@ -1262,7 +1273,7 @@ async function pickCursorModel() {
1262
1273
  { key: "c", desc: "ancel" },
1263
1274
  ]);
1264
1275
  if (choice === "r") {
1265
- if (await ensureCursorProxyRunning(PROXY_DEFAULT_URL, true)) {
1276
+ if (await ensureCursorProxyRunning(PROXY_DEFAULT_URL, { forceRestart: true })) {
1266
1277
  console.log(chalk.green(" ✓ Proxy started"));
1267
1278
  break;
1268
1279
  }
@@ -0,0 +1,4 @@
1
+ /** Resolve proxy port (reads from config, or allocates and persists a new one). */
2
+ export declare function getProxyPort(projectRoot: string): number;
3
+ /** Build the full proxy URL for a per-project port. */
4
+ export declare function buildProxyUrl(port: number): string;
@@ -0,0 +1,28 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
2
+ import { join } from "path";
3
+ const CONFIG_FILE = "config.json";
4
+ /** Resolve proxy port (reads from config, or allocates and persists a new one). */
5
+ export function getProxyPort(projectRoot) {
6
+ const dir = join(projectRoot, ".claude-overnight");
7
+ const file = join(dir, CONFIG_FILE);
8
+ try {
9
+ const cfg = JSON.parse(readFileSync(file, "utf-8"));
10
+ if (typeof cfg.proxyPort === "number" && cfg.proxyPort >= 1024 && cfg.proxyPort <= 65535) {
11
+ return cfg.proxyPort;
12
+ }
13
+ }
14
+ catch { /* not found or malformed */ }
15
+ const port = 61000 + Math.floor(Math.random() * 4536);
16
+ try {
17
+ if (!existsSync(dir))
18
+ mkdirSync(dir, { recursive: true });
19
+ const existing = existsSync(file) ? JSON.parse(readFileSync(file, "utf-8")) : {};
20
+ writeFileSync(file, JSON.stringify({ ...existing, proxyPort: port }, null, 2));
21
+ }
22
+ catch { /* best effort */ }
23
+ return port;
24
+ }
25
+ /** Build the full proxy URL for a per-project port. */
26
+ export function buildProxyUrl(port) {
27
+ return `http://127.0.0.1:${port}`;
28
+ }
package/dist/render.js CHANGED
@@ -451,7 +451,7 @@ export function renderFrame(swarm, showHotkeys, runInfo, selectedAgentId, maxRow
451
451
  const detailChip = swarm.active > 0 ? chalk.dim(" [d] detail") : "";
452
452
  const selectChip = swarm.active > 0 && running.length <= 10 ? chalk.dim(" [0-9] select") : "";
453
453
  const panelChip = panel?.visible ? chalk.green(` [Ctrl-O] ${panel.state.expanded ? "collapse" : "expand"}`) : "";
454
- hotkeyRow = chalk.dim(` [b] budget [t] cap [c] conc [e] extra ${pauseLabel} [s] steer [?] ask [q] stop`) + fixChip + retryChip + chip + detailChip + selectChip + panelChip;
454
+ hotkeyRow = chalk.dim(` [s] settings ${pauseLabel} [i] inject [?] ask [q] stop`) + fixChip + retryChip + chip + detailChip + selectChip + panelChip;
455
455
  if (swarm.blocked > 0 && swarm.blocked === swarm.active) {
456
456
  extraFooterRows.push(chalk.yellow(` all workers rate-limited -- [r] retry-now, [c] reduce concurrency, [p] pause, [q] quit`));
457
457
  }
@@ -641,7 +641,7 @@ export function renderSteeringFrame(runInfo, data, showHotkeys, rlGetter, maxRow
641
641
  const pending = runInfo?.pendingSteer ?? 0;
642
642
  const chip = pending > 0 ? chalk.cyan(` \u270E ${pending} steer queued`) : "";
643
643
  const panelChip = panel?.visible ? chalk.green(` [Ctrl-O] ${panel.state.expanded ? "collapse" : "expand"}`) : "";
644
- hotkeyRow = chalk.dim(" [b] budget [s] steer [q] stop") + chip + panelChip;
644
+ hotkeyRow = chalk.dim(" [s] settings [i] inject [q] stop") + chip + panelChip;
645
645
  }
646
646
  return renderUnifiedFrame({
647
647
  model: runInfo.model,
package/dist/run.js CHANGED
@@ -19,7 +19,8 @@ export async function executeRun(cfg) {
19
19
  process.stdout.write("\x1B[?25h\n");
20
20
  }
21
21
  catch { } };
22
- const { objective, cwd, workerModel, plannerModel, fastModel, concurrency, permissionMode, allowedTools, beforeWave: beforeWaveCmds, afterWave: afterWaveCmds, afterRun: afterRunCmds, runDir, previousKnowledge, } = cfg;
22
+ const { objective, cwd, allowedTools, beforeWave: beforeWaveCmds, afterWave: afterWaveCmds, afterRun: afterRunCmds, runDir, previousKnowledge } = cfg;
23
+ let { workerModel, plannerModel, fastModel, concurrency, permissionMode } = cfg;
23
24
  const envForModel = buildEnvResolver({
24
25
  plannerModel, plannerProvider: cfg.plannerProvider,
25
26
  workerModel, workerProvider: cfg.workerProvider,
@@ -44,6 +45,7 @@ export async function executeRun(cfg) {
44
45
  const liveConfig = {
45
46
  remaining: 0, usageCap, concurrency, paused: false, dirty: false,
46
47
  extraUsageBudget: cfg.extraUsageBudget,
48
+ workerModel, plannerModel, fastModel, permissionMode,
47
49
  };
48
50
  let waveNum;
49
51
  const waveHistory = [];
@@ -530,6 +532,15 @@ export async function executeRun(cfg) {
530
532
  remaining = liveConfig.remaining;
531
533
  usageCap = liveConfig.usageCap;
532
534
  cfg.extraUsageBudget = liveConfig.extraUsageBudget;
535
+ if (liveConfig.workerModel)
536
+ workerModel = liveConfig.workerModel;
537
+ if (liveConfig.plannerModel)
538
+ plannerModel = liveConfig.plannerModel;
539
+ if (liveConfig.fastModel !== undefined)
540
+ fastModel = liveConfig.fastModel;
541
+ if (liveConfig.permissionMode)
542
+ permissionMode = liveConfig.permissionMode;
543
+ concurrency = liveConfig.concurrency;
533
544
  liveConfig.dirty = false;
534
545
  }
535
546
  liveConfig.remaining = remaining;
@@ -0,0 +1,21 @@
1
+ import type { MutableRunSettings, PermMode } from "./types.js";
2
+ interface EditSettingsOptions {
3
+ /** Existing settings to show as current values (resume) or blank defaults. */
4
+ current: MutableRunSettings;
5
+ /** CLI flags that already override concurrency (skip prompt if set). */
6
+ cliConcurrencySet?: boolean;
7
+ /** Coach-recommended defaults (initial setup only). */
8
+ defaults?: {
9
+ plannerModel?: string;
10
+ workerModel?: string;
11
+ fastModel?: string;
12
+ concurrency?: number;
13
+ usageCap?: number | null;
14
+ permissionMode?: PermMode;
15
+ };
16
+ }
17
+ /** Interactively edit all mutable run settings. Mutates `options.current` in place. */
18
+ export declare function editRunSettings(options: EditSettingsOptions): Promise<MutableRunSettings>;
19
+ /** Format a MutableRunSettings as a compact summary line for the terminal. */
20
+ export declare function formatSettingsSummary(s: MutableRunSettings): string;
21
+ export {};
@@ -0,0 +1,120 @@
1
+ import chalk from "chalk";
2
+ import { modelDisplayName, formatContextWindow } from "./models.js";
3
+ import { fetchModels, ask, select, BRAILLE } from "./cli.js";
4
+ import { pickModel } from "./providers.js";
5
+ /** Interactively edit all mutable run settings. Mutates `options.current` in place. */
6
+ export async function editRunSettings(options) {
7
+ const s = options.current;
8
+ const modelsPromise = fetchModels(20_000).catch(() => []);
9
+ let modelFrame = 0;
10
+ const modelSpinner = setInterval(() => {
11
+ process.stdout.write(`\x1B[2K\r ${chalk.cyan(BRAILLE[modelFrame++ % BRAILLE.length])} ${chalk.dim("loading models...")}`);
12
+ }, 120);
13
+ let models;
14
+ try {
15
+ models = await modelsPromise;
16
+ }
17
+ finally {
18
+ clearInterval(modelSpinner);
19
+ process.stdout.write(`\x1B[2K\r`);
20
+ }
21
+ const plannerPick = await pickModel(`${chalk.cyan("①")} Planner model ${chalk.dim("(thinking, steering -- use your strongest)")}:`, models, options.defaults?.plannerModel ?? s.plannerModel);
22
+ s.plannerModel = plannerPick.model;
23
+ s.plannerProviderId = plannerPick.providerId;
24
+ const workerPick = await pickModel(`${chalk.cyan("②")} Worker model ${chalk.dim("(what runs the tasks -- Qwen 3.6 Plus / OpenRouter / etc via Other…)")}:`, models, options.defaults?.workerModel ?? s.workerModel);
25
+ s.workerModel = workerPick.model;
26
+ s.workerProviderId = workerPick.providerId;
27
+ const suggestFast = !!(options.defaults?.fastModel);
28
+ const fastChoice = await select(`${chalk.cyan("③")} Fast model ${chalk.dim("(optional -- Haiku/Qwen for quick tasks, checked by worker)")}:`, [
29
+ { name: "Skip", value: "skip", hint: "two-tier mode only (current setup)" },
30
+ { name: "Pick a fast model", value: "pick", hint: "Haiku, Qwen, or any provider -- for well-scoped tasks" },
31
+ ], suggestFast ? 1 : 0);
32
+ if (fastChoice === "pick") {
33
+ const fastPick = await pickModel(`${chalk.cyan("③b")} Fast model:`, models, options.defaults?.fastModel ?? s.fastModel);
34
+ s.fastModel = fastPick.model;
35
+ s.fastProviderId = fastPick.providerId;
36
+ }
37
+ else {
38
+ s.fastModel = undefined;
39
+ s.fastProviderId = undefined;
40
+ }
41
+ if (!options.cliConcurrencySet) {
42
+ const defaultC = options.defaults?.concurrency ?? s.concurrency;
43
+ const concAns = await ask(`\n ${chalk.cyan("④")} ${chalk.dim("Max concurrency")} ${chalk.dim("[")}${chalk.white(String(defaultC))}${chalk.dim("]:")} `);
44
+ const parsed = parseInt(concAns);
45
+ if (!isNaN(parsed) && parsed >= 1)
46
+ s.concurrency = parsed;
47
+ }
48
+ const coachCap = options.defaults?.usageCap;
49
+ const usageCapItems = [
50
+ { name: "Unlimited", value: undefined, hint: "full capacity, wait through rate limits" },
51
+ { name: "90%", value: 0.9, hint: "leave 10% for other work" },
52
+ { name: "75%", value: 0.75, hint: "conservative, plenty of headroom" },
53
+ { name: "50%", value: 0.5, hint: "use half, keep the rest" },
54
+ ];
55
+ const usageCapDefault = coachCap == null ? 0
56
+ : coachCap >= 0.85 ? 1
57
+ : coachCap >= 0.6 ? 2
58
+ : 3;
59
+ s.usageCap = await select(`${chalk.cyan("⑤")} Usage cap:`, usageCapItems, usageCapDefault);
60
+ const extraChoice = await select(`${chalk.cyan("⑥")} Allow extra usage ${chalk.dim("(billed separately)")}:`, [
61
+ { name: "No", value: "no", hint: "stop when plan limits are reached" },
62
+ { name: "Yes, with $ limit", value: "budget", hint: "set a spending cap" },
63
+ { name: "Yes, unlimited", value: "unlimited", hint: "keep going no matter what" },
64
+ ]);
65
+ if (extraChoice === "budget") {
66
+ const bAns = await ask(` ${chalk.dim("Max extra usage $:")} `);
67
+ const bVal = parseFloat(bAns);
68
+ s.allowExtraUsage = true;
69
+ s.extraUsageBudget = (!isNaN(bVal) && bVal > 0) ? bVal : 5;
70
+ }
71
+ else if (extraChoice === "unlimited") {
72
+ s.allowExtraUsage = true;
73
+ s.extraUsageBudget = undefined;
74
+ }
75
+ else {
76
+ s.allowExtraUsage = false;
77
+ s.extraUsageBudget = undefined;
78
+ }
79
+ const permItems = [
80
+ { name: "Auto", value: "auto", hint: "accept low-risk, reject high-risk" },
81
+ { name: "Bypass all", value: "bypassPermissions", hint: "agents can run anything (yolo)" },
82
+ { name: "Prompt each", value: "default", hint: "ask for every dangerous op" },
83
+ ];
84
+ const permDefault = options.defaults?.permissionMode === "bypassPermissions" ? 1
85
+ : options.defaults?.permissionMode === "default" ? 2 : 0;
86
+ s.permissionMode = await select(`${chalk.cyan("⑦")} Permissions:`, permItems, permDefault);
87
+ const modelLine = (label, m) => m ? `${chalk.dim(label.padEnd(11))}${chalk.white(m)} ${chalk.dim(`(${formatContextWindow(m)} context)`)}` : null;
88
+ const lines = [
89
+ modelLine("planner", s.plannerModel),
90
+ modelLine("worker", s.workerModel),
91
+ modelLine("fast", s.fastModel),
92
+ ].filter(Boolean);
93
+ console.log();
94
+ for (const l of lines)
95
+ console.log(l);
96
+ const capStr = s.usageCap != null ? `${Math.round(s.usageCap * 100)}%` : "unlimited";
97
+ const extraStr = s.allowExtraUsage ? (s.extraUsageBudget ? `$${s.extraUsageBudget}` : "unlimited") : "off";
98
+ console.log(` ${chalk.dim("concur ")}${chalk.white(String(s.concurrency))}`);
99
+ console.log(` ${chalk.dim("usage cap ")}${chalk.white(capStr)}`);
100
+ console.log(` ${chalk.dim("extra ")}${chalk.white(extraStr)}`);
101
+ console.log(` ${chalk.dim("perms ")}${chalk.white(s.permissionMode === "bypassPermissions" ? "yolo" : s.permissionMode)}`);
102
+ console.log();
103
+ return s;
104
+ }
105
+ /** Format a MutableRunSettings as a compact summary line for the terminal. */
106
+ export function formatSettingsSummary(s) {
107
+ const parts = [];
108
+ if (s.fastModel)
109
+ parts.push(`${modelDisplayName(s.plannerModel)} → ${modelDisplayName(s.workerModel)} + ${modelDisplayName(s.fastModel)}`);
110
+ else if (s.workerModel !== s.plannerModel)
111
+ parts.push(`${modelDisplayName(s.workerModel)} → ${modelDisplayName(s.plannerModel)}`);
112
+ else
113
+ parts.push(modelDisplayName(s.workerModel));
114
+ if (s.usageCap != null)
115
+ parts.push(`cap ${Math.round(s.usageCap * 100)}%`);
116
+ parts.push(s.allowExtraUsage ? (s.extraUsageBudget ? `extra $${s.extraUsageBudget}` : "extra ∞") : "no extra");
117
+ if (s.permissionMode !== "auto")
118
+ parts.push(s.permissionMode === "bypassPermissions" ? "yolo" : "prompt");
119
+ return parts.join(chalk.dim(" · "));
120
+ }
package/dist/swarm.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Task, AgentState, SwarmPhase, PermMode, MergeStrategy, RateLimitWindow } from "./types.js";
1
+ import { type PermMode } from "./types.js";
2
+ import type { Task, AgentState, SwarmPhase, MergeStrategy, RateLimitWindow } from "./types.js";
2
3
  import type { MergeResult } from "./merge.js";
3
4
  export interface SwarmConfig {
4
5
  tasks: Task[];
@@ -77,12 +78,14 @@ export declare class Swarm {
77
78
  private pendingTools;
78
79
  private ctxWarned;
79
80
  logFile?: string;
80
- readonly model: string | undefined;
81
+ model: string | undefined;
81
82
  usageCap: number | undefined;
82
83
  readonly allowExtraUsage: boolean;
83
84
  extraUsageBudget: number | undefined;
84
85
  readonly baseCostUsd: number;
85
86
  mergeBranch?: string;
87
+ /** Permission mode read from config on each agent dispatch. Writable for mid-run changes. */
88
+ private _permMode;
86
89
  constructor(config: SwarmConfig);
87
90
  get active(): number;
88
91
  get blocked(): number;
@@ -100,6 +103,10 @@ export declare class Swarm {
100
103
  retryRateLimitNow(): void;
101
104
  /** Live-adjust the overage spend cap. `undefined` = unlimited. If already over the new cap, stop dispatch. */
102
105
  setExtraUsageBudget(n: number | undefined): void;
106
+ /** Live-adjust the worker model. Picked up by next agent dispatch. */
107
+ setModel(m: string): void;
108
+ /** Live-adjust the SDK permission mode. Picked up by next agent dispatch. */
109
+ setPermissionMode(m: PermMode): void;
103
110
  run(): Promise<void>;
104
111
  abort(): void;
105
112
  /** Re-queue all errored agents' tasks for retry within this wave. */
package/dist/swarm.js CHANGED
@@ -5,7 +5,7 @@ import chalk from "chalk";
5
5
  import { query } from "@anthropic-ai/claude-agent-sdk";
6
6
  import { NudgeError, RATE_LIMIT_WINDOW_SHORT, extractToolTarget, sumUsageTokens } from "./types.js";
7
7
  import { gitExec, autoCommit, mergeAllBranches, warnDirtyTree, cleanStaleWorktrees, writeSwarmLog } from "./merge.js";
8
- import { ensureCursorProxyRunning } from "./providers.js";
8
+ import { ensureCursorProxyRunning, PROXY_DEFAULT_URL } from "./providers.js";
9
9
  import { getModelCapability } from "./models.js";
10
10
  import { createTurn, beginTurn, endTurn, updateTurn } from "./turns.js";
11
11
  const SIMPLIFY_PROMPT = `You just finished your task. Now review and simplify your changes.
@@ -93,6 +93,8 @@ export class Swarm {
93
93
  extraUsageBudget;
94
94
  baseCostUsd;
95
95
  mergeBranch;
96
+ /** Permission mode read from config on each agent dispatch. Writable for mid-run changes. */
97
+ _permMode;
96
98
  constructor(config) {
97
99
  if (!config.tasks.length)
98
100
  throw new Error("SwarmConfig: tasks array must not be empty");
@@ -115,6 +117,7 @@ export class Swarm {
115
117
  this.queue = [...config.tasks];
116
118
  this.total = config.tasks.length;
117
119
  this.targetConcurrency = config.concurrency;
120
+ this._permMode = config.permissionMode;
118
121
  }
119
122
  get active() { return this.agents.filter(a => a.status === "running").length; }
120
123
  get blocked() { return this.agents.filter(a => a.status === "running" && a.blockedAt != null).length; }
@@ -210,6 +213,23 @@ export class Swarm {
210
213
  this.capForOverage(`Extra usage budget $${n} exceeded ($${this.overageCostUsd.toFixed(2)} spent) -- stopping dispatch`);
211
214
  }
212
215
  }
216
+ /** Live-adjust the worker model. Picked up by next agent dispatch. */
217
+ setModel(m) {
218
+ if (this.model === m)
219
+ return;
220
+ const prev = this.model;
221
+ this.model = m;
222
+ this.log(-1, `Worker model: ${prev} → ${m}`);
223
+ }
224
+ /** Live-adjust the SDK permission mode. Picked up by next agent dispatch. */
225
+ setPermissionMode(m) {
226
+ if (this._permMode === m)
227
+ return;
228
+ const prev = this._permMode ?? "auto";
229
+ this._permMode = m;
230
+ const label = m === "bypassPermissions" ? "yolo" : m;
231
+ this.log(-1, `Permission mode: ${prev === "bypassPermissions" ? "yolo" : prev} → ${label}`);
232
+ }
213
233
  async run() {
214
234
  try {
215
235
  if (this.config.useWorktrees) {
@@ -330,7 +350,7 @@ export class Swarm {
330
350
  // attempt to restart it before the next task.
331
351
  if (this.config.cursorProxy) {
332
352
  this.log(-1, " Checking cursor proxy health…");
333
- const restarted = await ensureCursorProxyRunning();
353
+ const restarted = await ensureCursorProxyRunning(PROXY_DEFAULT_URL, { projectRoot: this.config.cwd });
334
354
  if (!restarted) {
335
355
  this.log(-1, chalk.yellow(" ⚠ Proxy still down — remaining tasks may fail"));
336
356
  }
@@ -528,7 +548,7 @@ export class Swarm {
528
548
  agent.finishedAt = undefined;
529
549
  }
530
550
  try {
531
- const perm = this.config.permissionMode ?? "auto";
551
+ const perm = this._permMode ?? "auto";
532
552
  let resumeSessionId = task.resumeSessionId;
533
553
  let resumePrompt = "Continue. Complete the task.";
534
554
  const runOnce = async (isResume) => {
package/dist/types.d.ts CHANGED
@@ -203,6 +203,23 @@ export interface RunMemory {
203
203
  /** Pending user directives from the steer inbox, consumed by the next successful steering call. */
204
204
  userGuidance?: string;
205
205
  }
206
+ /** Mutable subset of RunConfigBase — settings that can be changed at any point, including mid-run. */
207
+ export interface MutableRunSettings {
208
+ workerModel: string;
209
+ plannerModel: string;
210
+ fastModel?: string;
211
+ workerProviderId?: string;
212
+ plannerProviderId?: string;
213
+ fastProviderId?: string;
214
+ concurrency: number;
215
+ usageCap?: number;
216
+ allowExtraUsage: boolean;
217
+ extraUsageBudget?: number;
218
+ permissionMode: PermMode;
219
+ beforeWave?: string | string[];
220
+ afterWave?: string | string[];
221
+ afterRun?: string | string[];
222
+ }
206
223
  /** Shared configuration for a run -- both live (RunConfig) and persisted (RunState). */
207
224
  export interface RunConfigBase {
208
225
  /** Total session budget. */
package/dist/ui.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Swarm } from "./swarm.js";
2
- import type { RLGetter, WaveSummary } from "./types.js";
2
+ import type { RLGetter, WaveSummary, PermMode } from "./types.js";
3
3
  import { InteractivePanel } from "./interactive-panel.js";
4
4
  /** Short-lived context the steering view renders around its live log. */
5
5
  export interface SteeringContext {
@@ -34,8 +34,16 @@ export interface LiveConfig {
34
34
  concurrency: number;
35
35
  paused: boolean;
36
36
  dirty: boolean;
37
- /** Overage spend cap ($) -- undefined = unlimited. Synced from the [e] hotkey. */
37
+ /** Overage spend cap ($) -- undefined = unlimited. Synced from the [s] hotkey. */
38
38
  extraUsageBudget?: number;
39
+ /** Worker model for agent tasks. Changed mid-run, picked up by next wave/agent dispatch. */
40
+ workerModel?: string;
41
+ /** Planner/steering model. Changed mid-run, picked up by next steer/planner call. */
42
+ plannerModel?: string;
43
+ /** Fast model for quick verification tasks. */
44
+ fastModel?: string;
45
+ /** SDK permission mode. Changed mid-run, picked up by next agent dispatch. */
46
+ permissionMode?: PermMode;
39
47
  }
40
48
  /** State of an in-flight or recently-completed ask side query. */
41
49
  export interface AskState {
@@ -57,6 +65,8 @@ export declare class RunDisplay {
57
65
  private interval?;
58
66
  private keyHandler?;
59
67
  private inputMode;
68
+ /** Which field the settings editor is currently editing. Order: budget, cap, conc, extra, worker, planner, fast, perms, pause. */
69
+ private settingsField;
60
70
  private inputSegs;
61
71
  private started;
62
72
  private readonly isTTY;
@@ -120,6 +130,8 @@ export declare class RunDisplay {
120
130
  * The content area shrinks so input prompts are never clipped. */
121
131
  private flush;
122
132
  private render;
133
+ /** Read the current value for a settings field from liveConfig/swarm. */
134
+ private currentFieldValue;
123
135
  private renderInputPrompt;
124
136
  private hasHotkeys;
125
137
  private setupHotkeys;
@@ -127,7 +139,7 @@ export declare class RunDisplay {
127
139
  private handlePaste;
128
140
  /** Keyboard handler used only while the panel is expanded fullscreen.
129
141
  * Handles scroll + close. Swallows everything else so the normal hotkeys
130
- * (b/t/c/p/s/?/d/0-9) do not fire while the user is reading. */
142
+ * (s/p/i/?/d/0-9) do not fire while the user is reading. */
131
143
  private handlePanelKey;
132
144
  /** Handle a typed (non-pasted) chunk. Returns true if the frame needs a redraw.
133
145
  *
@@ -141,7 +153,7 @@ export declare class RunDisplay {
141
153
  * 3. ESC alone → cancel input / close detail / dismiss panel
142
154
  * 4. numeric input → digits, Enter, Backspace
143
155
  * 5. text input → printable chars, Enter, Backspace, ESC (with lookahead)
144
- * 6. hotkey mode → b, t, c, e, p, s, q, ?, d, 0-9
156
+ * 6. hotkey mode → s (settings), i (inject), q, ?, d, 0-9, f, r, p
145
157
  */
146
158
  private handleTyped;
147
159
  private plainTick;
package/dist/ui.js CHANGED
@@ -10,6 +10,8 @@ import { allTurns, cycleFocused } from "./turns.js";
10
10
  const MAX_STEERING_EVENTS = 60;
11
11
  const MAX_INPUT_LEN = 600;
12
12
  const MAX_ASK_LINES = 40;
13
+ const SETTINGS_FIELDS = ["budget", "cap", "conc", "extra", "worker", "planner", "fast", "perms", "pause"];
14
+ const NUMERIC_SETTINGS_FIELDS = new Set(["budget", "cap", "conc", "extra"]);
13
15
  /** Visible lines for the ask panel, clamped to leave room for header/content/footer/input. */
14
16
  function askDisplayCap() {
15
17
  return Math.max(3, Math.min(MAX_ASK_LINES, (process.stdout.rows || 40) - 20));
@@ -28,6 +30,8 @@ export class RunDisplay {
28
30
  interval;
29
31
  keyHandler;
30
32
  inputMode = "none";
33
+ /** Which field the settings editor is currently editing. Order: budget, cap, conc, extra, worker, planner, fast, perms, pause. */
34
+ settingsField = 0;
31
35
  inputSegs = [];
32
36
  started = false;
33
37
  isTTY;
@@ -442,24 +446,54 @@ export class RunDisplay {
442
446
  }
443
447
  return frame + bottom;
444
448
  }
449
+ /** Read the current value for a settings field from liveConfig/swarm. */
450
+ currentFieldValue(field) {
451
+ const lc = this.liveConfig;
452
+ const s = this.swarm;
453
+ switch (field) {
454
+ case "budget": return String(lc?.remaining ?? "—");
455
+ case "cap": return lc?.usageCap != null ? `${Math.round(lc.usageCap * 100)}%` : "unlimited";
456
+ case "conc": return String(lc?.concurrency ?? "—");
457
+ case "extra": return lc?.extraUsageBudget != null ? `$${lc.extraUsageBudget}` : "unlimited";
458
+ case "worker": return lc?.workerModel ?? s?.model ?? "—";
459
+ case "planner": return lc?.plannerModel ?? "—";
460
+ case "fast": return lc?.fastModel ?? "(none)";
461
+ case "perms": {
462
+ const p = lc?.permissionMode ?? "auto";
463
+ return p === "bypassPermissions" ? "yolo" : p;
464
+ }
465
+ case "pause": return s?.paused ? "paused" : "running";
466
+ default: return "";
467
+ }
468
+ }
445
469
  renderInputPrompt() {
446
470
  if (this.inputMode === "none")
447
471
  return "";
448
472
  const rendered = renderSegments(this.inputSegs);
449
- if (this.inputMode === "budget") {
450
- return `\n ${chalk.cyan(">")} New budget (remaining sessions): ${rendered}\u2588`;
451
- }
452
- if (this.inputMode === "threshold") {
453
- return `\n ${chalk.cyan(">")} New usage cap (0-100%): ${rendered}\u2588`;
454
- }
455
- if (this.inputMode === "concurrency") {
456
- return `\n ${chalk.cyan(">")} New concurrency (min 1): ${rendered}\u2588`;
457
- }
458
- if (this.inputMode === "extra") {
459
- return `\n ${chalk.cyan(">")} Extra usage $ cap (0 = stop on overage): ${rendered}\u2588`;
473
+ if (this.inputMode === "settings") {
474
+ const labels = [
475
+ "New budget (remaining sessions)",
476
+ "New usage cap (0-100%, 0=unlimited)",
477
+ "New concurrency (min 1)",
478
+ "Extra usage $ cap (0=stop on overage)",
479
+ "Worker model (for agent tasks)",
480
+ "Planner model (steering/thinking)",
481
+ "Fast model (optional, empty=skip)",
482
+ "Permission mode (auto/yolo/prompt)",
483
+ "Pause/resume workers",
484
+ ];
485
+ const total = SETTINGS_FIELDS.length;
486
+ const field = SETTINGS_FIELDS[this.settingsField % total];
487
+ const label = labels[this.settingsField % total];
488
+ const idx = this.settingsField + 1;
489
+ const currentValue = this.currentFieldValue(field);
490
+ const hint = field === "pause"
491
+ ? chalk.dim(` (Enter to toggle, Tab to skip, Esc to exit)`)
492
+ : chalk.dim(` [${idx}/${total}] Tab=next Esc=exit current: ${chalk.white(currentValue)}`);
493
+ return `\n ${chalk.cyan("◆")} ${chalk.bold(label)}${hint}\n ${rendered}\u2588`;
460
494
  }
461
495
  if (this.inputMode === "steer") {
462
- return `\n ${chalk.cyan(">")} ${chalk.bold("Steer next wave")} ${chalk.dim("(Enter to queue, Esc to cancel)")}\n ${rendered}\u2588`;
496
+ return `\n ${chalk.cyan(">")} ${chalk.bold("Inject next wave")} ${chalk.dim("(Enter to queue, Esc to cancel)")}\n ${rendered}\u2588`;
463
497
  }
464
498
  if (this.inputMode === "ask") {
465
499
  return `\n ${chalk.cyan(">")} ${chalk.bold("Ask the planner")} ${chalk.dim("(Enter to send, Esc to cancel)")}\n ${rendered}\u2588`;
@@ -503,11 +537,18 @@ export class RunDisplay {
503
537
  }
504
538
  /** Handle a pasted block. Returns true if the frame needs a redraw. */
505
539
  handlePaste(text) {
506
- if (this.inputMode === "budget" || this.inputMode === "threshold" || this.inputMode === "concurrency" || this.inputMode === "extra") {
507
- const clean = text.replace(/[^0-9.]/g, "");
508
- if (clean)
509
- appendCharToSegments(this.inputSegs, clean);
510
- return !!clean;
540
+ if (this.inputMode === "settings") {
541
+ const field = SETTINGS_FIELDS[this.settingsField % SETTINGS_FIELDS.length];
542
+ if (NUMERIC_SETTINGS_FIELDS.has(field)) {
543
+ const clean = text.replace(/[^0-9.]/g, "");
544
+ if (clean)
545
+ appendCharToSegments(this.inputSegs, clean);
546
+ return !!clean;
547
+ }
548
+ if (field !== "pause" && text.length + segmentsToString(this.inputSegs).length <= MAX_INPUT_LEN) {
549
+ appendPasteToSegments(this.inputSegs, text);
550
+ return true;
551
+ }
511
552
  }
512
553
  if (this.inputMode === "steer" || this.inputMode === "ask") {
513
554
  if (segmentsToString(this.inputSegs).length + text.length > MAX_INPUT_LEN)
@@ -519,7 +560,7 @@ export class RunDisplay {
519
560
  }
520
561
  /** Keyboard handler used only while the panel is expanded fullscreen.
521
562
  * Handles scroll + close. Swallows everything else so the normal hotkeys
522
- * (b/t/c/p/s/?/d/0-9) do not fire while the user is reading. */
563
+ * (s/p/i/?/d/0-9) do not fire while the user is reading. */
523
564
  handlePanelKey(s) {
524
565
  const bodyRows = Math.max(3, (process.stdout.rows || 40) - 7);
525
566
  // CSI sequences: arrows, PgUp/PgDn, Home/End
@@ -606,14 +647,14 @@ export class RunDisplay {
606
647
  * 3. ESC alone → cancel input / close detail / dismiss panel
607
648
  * 4. numeric input → digits, Enter, Backspace
608
649
  * 5. text input → printable chars, Enter, Backspace, ESC (with lookahead)
609
- * 6. hotkey mode → b, t, c, e, p, s, q, ?, d, 0-9
650
+ * 6. hotkey mode → s (settings), i (inject), q, ?, d, 0-9, f, r, p
610
651
  */
611
652
  handleTyped(s) {
612
653
  const lc = this.liveConfig;
613
654
  // ── 0. Fullscreen panel owns the keyboard ──
614
655
  // While the interactive panel is expanded it takes over the screen and
615
656
  // steals every key except Esc, Ctrl-O (close), Ctrl-C (abort), and scroll.
616
- // Hotkeys like b/t/c/p/s/? are intentionally swallowed so the user can
657
+ // Hotkeys like s/i/p are intentionally swallowed so the user can
617
658
  // read without triggering side effects.
618
659
  if (this.panel.state.expanded) {
619
660
  return this.handlePanelKey(s);
@@ -662,37 +703,93 @@ export class RunDisplay {
662
703
  }
663
704
  return false;
664
705
  }
665
- // ── 4. Input mode: budget / threshold / concurrency / extra ──
666
- if (this.inputMode === "budget" || this.inputMode === "threshold" || this.inputMode === "concurrency" || this.inputMode === "extra") {
706
+ if (s === "\t" && this.inputMode === "settings") {
707
+ const field = SETTINGS_FIELDS[this.settingsField % SETTINGS_FIELDS.length];
708
+ if (field === "pause" && this.swarm) {
709
+ const next = !this.swarm.paused;
710
+ this.swarm.setPaused(next);
711
+ this.liveConfig.paused = next;
712
+ this.liveConfig.dirty = true;
713
+ }
714
+ this.settingsField++;
715
+ this.inputSegs = [];
716
+ if (this.settingsField >= SETTINGS_FIELDS.length)
717
+ this.inputMode = "none";
718
+ return true;
719
+ }
720
+ // ── 4. Settings mode: all mutable fields ──
721
+ if (this.inputMode === "settings") {
667
722
  let dirty = false;
668
723
  for (const ch of s) {
669
724
  if (ch === "\r" || ch === "\n") {
670
- const val = parseFloat(segmentsToString(this.inputSegs));
671
- if (this.inputMode === "budget" && !isNaN(val) && val > 0) {
672
- lc.remaining = Math.round(val);
725
+ const field = SETTINGS_FIELDS[this.settingsField % SETTINGS_FIELDS.length];
726
+ const raw = segmentsToString(this.inputSegs).trim();
727
+ if (field === "budget") {
728
+ const val = parseFloat(raw);
729
+ if (!isNaN(val) && val > 0) {
730
+ lc.remaining = Math.round(val);
731
+ lc.dirty = true;
732
+ this.swarm?.log(-1, `Budget changed to ${lc.remaining} remaining`);
733
+ }
734
+ }
735
+ else if (field === "cap") {
736
+ const val = parseFloat(raw);
737
+ if (!isNaN(val) && val >= 0 && val <= 100) {
738
+ const frac = val / 100;
739
+ lc.usageCap = frac > 0 ? frac : undefined;
740
+ lc.dirty = true;
741
+ if (this.swarm)
742
+ this.swarm.usageCap = lc.usageCap;
743
+ this.swarm?.log(-1, `Usage cap changed to ${val > 0 ? val + "%" : "unlimited"}`);
744
+ }
745
+ }
746
+ else if (field === "conc") {
747
+ const val = parseFloat(raw);
748
+ if (!isNaN(val) && val >= 1) {
749
+ const n = Math.round(val);
750
+ lc.concurrency = n;
751
+ lc.dirty = true;
752
+ this.swarm?.setConcurrency(n);
753
+ }
754
+ }
755
+ else if (field === "extra") {
756
+ const val = parseFloat(raw);
757
+ if (!isNaN(val) && val >= 0) {
758
+ lc.extraUsageBudget = val;
759
+ lc.dirty = true;
760
+ this.swarm?.setExtraUsageBudget(val);
761
+ }
762
+ }
763
+ else if (field === "worker" && raw) {
764
+ lc.workerModel = raw;
673
765
  lc.dirty = true;
674
- this.swarm?.log(-1, `Budget changed to ${lc.remaining} remaining`);
766
+ this.swarm?.setModel(raw);
675
767
  }
676
- else if (this.inputMode === "threshold" && !isNaN(val) && val >= 0 && val <= 100) {
677
- const frac = val / 100;
678
- lc.usageCap = frac > 0 ? frac : undefined;
768
+ else if (field === "planner" && raw) {
769
+ lc.plannerModel = raw;
679
770
  lc.dirty = true;
680
- if (this.swarm)
681
- this.swarm.usageCap = lc.usageCap;
682
- this.swarm?.log(-1, `Usage cap changed to ${val > 0 ? val + "%" : "unlimited"}`);
683
771
  }
684
- else if (this.inputMode === "concurrency" && !isNaN(val) && val >= 1) {
685
- const n = Math.round(val);
686
- lc.concurrency = n;
772
+ else if (field === "fast") {
773
+ lc.fastModel = raw || undefined;
687
774
  lc.dirty = true;
688
- this.swarm?.setConcurrency(n);
689
775
  }
690
- else if (this.inputMode === "extra" && !isNaN(val) && val >= 0) {
691
- lc.extraUsageBudget = val;
776
+ else if (field === "perms" && raw) {
777
+ const m = raw.toLowerCase();
778
+ const mode = m.startsWith("yolo") || m.startsWith("bypass") ? "bypassPermissions"
779
+ : m.startsWith("prompt") || m === "default" ? "default" : "auto";
780
+ lc.permissionMode = mode;
692
781
  lc.dirty = true;
693
- this.swarm?.setExtraUsageBudget(val);
782
+ this.swarm?.setPermissionMode(mode);
694
783
  }
695
- this.inputMode = "none";
784
+ else if (field === "pause" && this.swarm) {
785
+ const next = !this.swarm.paused;
786
+ this.swarm.setPaused(next);
787
+ lc.paused = next;
788
+ lc.dirty = true;
789
+ }
790
+ this.settingsField++;
791
+ if (this.settingsField >= SETTINGS_FIELDS.length)
792
+ this.inputMode = "none";
696
793
  this.inputSegs = [];
697
794
  return true;
698
795
  }
@@ -706,9 +803,19 @@ export class RunDisplay {
706
803
  dirty = true;
707
804
  continue;
708
805
  }
709
- if (/^[0-9.]$/.test(ch)) {
710
- appendCharToSegments(this.inputSegs, ch);
711
- dirty = true;
806
+ const field = SETTINGS_FIELDS[this.settingsField % SETTINGS_FIELDS.length];
807
+ if (NUMERIC_SETTINGS_FIELDS.has(field)) {
808
+ if (/^[0-9.]$/.test(ch)) {
809
+ appendCharToSegments(this.inputSegs, ch);
810
+ dirty = true;
811
+ }
812
+ }
813
+ else if (field !== "pause") {
814
+ const code = ch.charCodeAt(0);
815
+ if (code >= 0x20 && code <= 0x7E) {
816
+ appendCharToSegments(this.inputSegs, ch);
817
+ dirty = true;
818
+ }
712
819
  }
713
820
  }
714
821
  return dirty;
@@ -803,35 +910,14 @@ export class RunDisplay {
803
910
  const code = key.charCodeAt(0);
804
911
  if (code < 0x20 || code > 0x7E)
805
912
  return false;
806
- if (key === "b" || key === "B") {
807
- this.inputMode = "budget";
913
+ if (key === "s" || key === "S") {
914
+ if (!this.swarm)
915
+ return false;
916
+ this.inputMode = "settings";
917
+ this.settingsField = 0;
808
918
  this.inputSegs = [];
809
919
  return true;
810
920
  }
811
- if (key === "t" || key === "T") {
812
- if (this.swarm) {
813
- this.inputMode = "threshold";
814
- this.inputSegs = [];
815
- return true;
816
- }
817
- return false;
818
- }
819
- if (key === "c" || key === "C") {
820
- if (this.swarm) {
821
- this.inputMode = "concurrency";
822
- this.inputSegs = [];
823
- return true;
824
- }
825
- return false;
826
- }
827
- if (key === "e" || key === "E") {
828
- if (this.swarm) {
829
- this.inputMode = "extra";
830
- this.inputSegs = [];
831
- return true;
832
- }
833
- return false;
834
- }
835
921
  if (key === "p" || key === "P") {
836
922
  if (this.swarm) {
837
923
  const next = !this.swarm.paused;
@@ -850,7 +936,7 @@ export class RunDisplay {
850
936
  this.swarm.retryRateLimitNow();
851
937
  return true;
852
938
  }
853
- if ((key === "s" || key === "S") && this.onSteer) {
939
+ if ((key === "i" || key === "I") && this.onSteer) {
854
940
  this.inputMode = "steer";
855
941
  this.inputSegs = [];
856
942
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.25.29",
3
+ "version": "1.25.31",
4
4
  "description": "Parallel Claude agents in git worktrees with a usage cap that reserves headroom for your interactive Claude Code. Crash-safe resume. Provider-agnostic model catalog (Anthropic, Cursor, OpenAI, Gemini, DeepSeek, Llama, Qwen) with capability-based task scoping.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.25.29",
3
+ "version": "1.25.31",
4
4
  "description": "Claude Code skill for understanding, installing, and inspecting claude-overnight runs -- parallel Claude agents in git worktrees with thinking waves, multi-wave steering, and crash-safe resume. Supports Cursor API Proxy, Qwen, OpenRouter.",
5
5
  "author": {
6
6
  "name": "Francesco Fornace"