claude-overnight 1.25.47 → 1.25.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/cli.d.ts CHANGED
@@ -9,32 +9,11 @@ import { isJWTAuthError } from "../core/auth.js";
9
9
  export declare const isAuthError: typeof isJWTAuthError;
10
10
  export { isJWTAuthError };
11
11
  export declare function fetchModels(timeoutMs?: number): Promise<ModelInfo[]>;
12
- export declare const PASTE_START = "\u001B[200~";
13
- export declare const PASTE_END = "\u001B[201~";
14
12
  export declare const PASTE_PLACEHOLDER_MAX = 80;
15
- export type InputSegment = {
16
- type: "text";
17
- content: string;
18
- } | {
19
- type: "paste";
20
- content: string;
21
- };
22
- /** Split a raw stdin chunk into typed and pasted segments. */
23
- export declare function splitPaste(chunk: string): Array<{
24
- type: "typed" | "paste";
25
- text: string;
26
- }>;
27
- export declare function segmentsToString(segs: InputSegment[]): string;
28
- export declare function renderSegments(segs: InputSegment[]): string;
29
- export declare function appendCharToSegments(segs: InputSegment[], ch: string): void;
30
- /** Appends a pasted block. Short single-line pastes inline as text; the rest become placeholders. */
31
- export declare function appendPasteToSegments(segs: InputSegment[], text: string): void;
32
- /** Backspace removes one char, or an entire paste block atomically. */
33
- export declare function backspaceSegments(segs: InputSegment[]): void;
34
13
  /**
35
- * Read a line from the user with bracketed-paste awareness.
36
- * Pasted multi-line text stays in the buffer as a single block -- only a typed
37
- * Enter submits. Falls back to cooked readline when stdin isn't a TTY.
14
+ * Read a line from the user with bracketed-paste awareness. Pasted multi-line
15
+ * text stays in the buffer as a single block -- only a typed Enter submits.
16
+ * Falls back to cooked readline when stdin isn't a TTY.
38
17
  */
39
18
  export declare function ask(question: string): Promise<string>;
40
19
  export declare function select<T>(label: string, items: {
package/dist/cli/cli.js CHANGED
@@ -4,6 +4,7 @@ import { resolve } from "path";
4
4
  import { createInterface } from "readline";
5
5
  import chalk from "chalk";
6
6
  import { query } from "@anthropic-ai/claude-agent-sdk";
7
+ import { parseChunk, setBracketedPaste, deleteWordBackward } from "../ui/raw-input.js";
7
8
  // ── CLI flag parsing ──
8
9
  export function parseCliFlags(argv) {
9
10
  const known = new Set(["concurrency", "model", "timeout", "budget", "usage-cap", "extra-usage-budget", "merge"]);
@@ -66,69 +67,34 @@ export async function fetchModels(timeoutMs = 10_000) {
66
67
  return [];
67
68
  }
68
69
  }
69
- // ── Bracketed paste + segment-based input ──
70
+ // ── Interactive primitives ──
70
71
  //
71
- // When the terminal is in bracketed paste mode, pasted content is wrapped with
72
- // \x1B[200~ ... \x1B[201~ so we can distinguish typed Enter from pasted newlines.
73
- // Multi-line or long pastes are stored as opaque segments and shown as a compact
74
- // [Pasted +N lines] placeholder while editing -- the full text is substituted on submit.
75
- export const PASTE_START = "\x1B[200~";
76
- export const PASTE_END = "\x1B[201~";
72
+ // Text entry goes through the shared raw-input parser in `../ui/raw-input.ts`,
73
+ // which enforces the single invariant that used to be duplicated (and buggy)
74
+ // here and in the Ink overlay:
75
+ // - Typed Enter = a stdin chunk that is exactly "\r", "\n", or "\r\n".
76
+ // - Anything else with embedded newlines is a paste, not a submit.
77
+ // Multi-line pastes render as a compact `[Pasted +N lines]` placeholder while
78
+ // editing — the full content is substituted on submit.
77
79
  export const PASTE_PLACEHOLDER_MAX = 80;
78
- /** Split a raw stdin chunk into typed and pasted segments. */
79
- export function splitPaste(chunk) {
80
- const out = [];
81
- let i = 0;
82
- while (i < chunk.length) {
83
- const start = chunk.indexOf(PASTE_START, i);
84
- if (start === -1) {
85
- out.push({ type: "typed", text: chunk.slice(i) });
86
- break;
87
- }
88
- if (start > i)
89
- out.push({ type: "typed", text: chunk.slice(i, start) });
90
- const bodyStart = start + PASTE_START.length;
91
- const end = chunk.indexOf(PASTE_END, bodyStart);
92
- if (end === -1) {
93
- out.push({ type: "paste", text: chunk.slice(bodyStart) });
94
- break;
95
- }
96
- out.push({ type: "paste", text: chunk.slice(bodyStart, end) });
97
- i = end + PASTE_END.length;
98
- }
99
- return out;
100
- }
101
- export function segmentsToString(segs) {
102
- return segs.map((s) => s.content).join("");
103
- }
104
- export function renderSegments(segs) {
105
- return segs.map((s) => {
106
- if (s.type === "text")
107
- return s.content;
108
- const lines = s.content.split("\n").length;
109
- return chalk.dim(`[Pasted +${lines} line${lines === 1 ? "" : "s"}]`);
110
- }).join("");
111
- }
112
- export function appendCharToSegments(segs, ch) {
80
+ function appendTypedChar(segs, ch) {
113
81
  const last = segs[segs.length - 1];
114
82
  if (last && last.type === "text")
115
83
  last.content += ch;
116
84
  else
117
85
  segs.push({ type: "text", content: ch });
118
86
  }
119
- /** Appends a pasted block. Short single-line pastes inline as text; the rest become placeholders. */
120
- export function appendPasteToSegments(segs, text) {
87
+ function appendPaste(segs, text) {
121
88
  if (!text)
122
89
  return;
123
90
  const norm = text.replace(/\r\n?/g, "\n");
124
91
  if (!norm.includes("\n") && norm.length <= PASTE_PLACEHOLDER_MAX) {
125
- appendCharToSegments(segs, norm);
92
+ appendTypedChar(segs, norm);
126
93
  return;
127
94
  }
128
95
  segs.push({ type: "paste", content: norm });
129
96
  }
130
- /** Backspace removes one char, or an entire paste block atomically. */
131
- export function backspaceSegments(segs) {
97
+ function backspaceSegs(segs) {
132
98
  while (segs.length > 0) {
133
99
  const last = segs[segs.length - 1];
134
100
  if (last.type === "paste") {
@@ -143,14 +109,22 @@ export function backspaceSegments(segs) {
143
109
  return;
144
110
  }
145
111
  }
112
+ function segsToString(segs) { return segs.map((s) => s.content).join(""); }
113
+ function renderSegs(segs) {
114
+ return segs.map((s) => {
115
+ if (s.type === "text")
116
+ return s.content;
117
+ const lines = s.content.split("\n").length;
118
+ return chalk.dim(`[Pasted +${lines} line${lines === 1 ? "" : "s"}]`);
119
+ }).join("");
120
+ }
146
121
  function stripAnsi(s) {
147
122
  return s.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
148
123
  }
149
- // ── Interactive primitives ──
150
124
  /**
151
- * Read a line from the user with bracketed-paste awareness.
152
- * Pasted multi-line text stays in the buffer as a single block -- only a typed
153
- * Enter submits. Falls back to cooked readline when stdin isn't a TTY.
125
+ * Read a line from the user with bracketed-paste awareness. Pasted multi-line
126
+ * text stays in the buffer as a single block -- only a typed Enter submits.
127
+ * Falls back to cooked readline when stdin isn't a TTY.
154
128
  */
155
129
  export function ask(question) {
156
130
  const { stdin, stdout } = process;
@@ -163,28 +137,25 @@ export function ask(question) {
163
137
  const tail = question.split("\n").pop() ?? "";
164
138
  const tailVisibleLen = stripAnsi(tail).length;
165
139
  let prevWrapRows = 0;
166
- // Only rewrite the input line (and any wrapped continuation rows). The
167
- // question header above is never touched, so redraws can't stack copies
168
- // even if the initial write scrolled the viewport.
169
140
  const redraw = () => {
170
141
  const cols = stdout.columns || 80;
171
142
  if (prevWrapRows > 0)
172
143
  stdout.write(`\x1B[${prevWrapRows}A`);
173
144
  stdout.write("\r\x1B[J");
174
- const rendered = renderSegments(segs);
145
+ const rendered = renderSegs(segs);
175
146
  stdout.write(tail + rendered);
176
147
  const visible = tailVisibleLen + stripAnsi(rendered).length;
177
148
  prevWrapRows = visible > 0 ? Math.floor((visible - 1) / cols) : 0;
178
149
  };
179
150
  stdout.write(question);
180
- stdout.write("\x1B[?2004h");
151
+ setBracketedPaste(stdout, true);
181
152
  try {
182
153
  stdin.setRawMode(true);
183
154
  }
184
155
  catch { }
185
156
  stdin.resume();
186
157
  const cleanup = () => {
187
- stdout.write("\x1B[?2004l");
158
+ setBracketedPaste(stdout, false);
188
159
  try {
189
160
  stdin.setRawMode(false);
190
161
  }
@@ -192,48 +163,44 @@ export function ask(question) {
192
163
  stdin.removeListener("data", onData);
193
164
  stdin.pause();
194
165
  };
166
+ const submit = () => { stdout.write("\n"); cleanup(); resolve(segsToString(segs).trim()); };
195
167
  const onData = (buf) => {
196
- const chunk = buf.toString();
197
- for (const seg of splitPaste(chunk)) {
198
- if (seg.type === "paste") {
199
- appendPasteToSegments(segs, seg.text);
200
- redraw();
201
- continue;
202
- }
203
- for (let ci = 0; ci < seg.text.length; ci++) {
204
- const ch = seg.text[ci];
205
- if (ch === "\r" || ch === "\n") {
206
- stdout.write("\n");
207
- cleanup();
208
- resolve(segmentsToString(segs).trim());
209
- return;
168
+ for (const ev of parseChunk(buf.toString())) {
169
+ switch (ev.type) {
170
+ case "char":
171
+ appendTypedChar(segs, ev.text);
172
+ break;
173
+ case "paste":
174
+ appendPaste(segs, ev.text);
175
+ break;
176
+ case "backspace":
177
+ backspaceSegs(segs);
178
+ break;
179
+ case "word-delete": {
180
+ const s = segsToString(segs);
181
+ const next = deleteWordBackward(s);
182
+ segs.length = 0;
183
+ if (next)
184
+ segs.push({ type: "text", content: next });
185
+ break;
210
186
  }
211
- if (ch === "\x03") {
187
+ case "clear-line":
188
+ segs.length = 0;
189
+ break;
190
+ case "submit":
191
+ submit();
192
+ return;
193
+ case "cancel":
194
+ submit();
195
+ return; // lone ESC = submit, preserves old behavior
196
+ case "interrupt":
212
197
  cleanup();
213
198
  stdout.write("\n");
214
199
  process.exit(130);
215
- }
216
- if (ch === "\x7F" || ch === "\b") {
217
- backspaceSegments(segs);
218
- redraw();
219
- continue;
220
- }
221
- // ESC submits the current input (same as Enter)
222
- if (ch === "\x1B") {
223
- stdout.write("\n");
224
- cleanup();
225
- resolve(segmentsToString(segs).trim());
226
- return;
227
- }
228
- const code = ch.charCodeAt(0);
229
- if (code < 0x20)
230
- continue; // control chars
231
- if (code >= 0x7F && code < 0xA0)
232
- continue; // DEL + C1 controls
233
- appendCharToSegments(segs, ch);
200
+ // tab + nav: ignore during single-line prompts
234
201
  }
235
- redraw();
236
202
  }
203
+ redraw();
237
204
  };
238
205
  stdin.on("data", onData);
239
206
  });
@@ -1 +1 @@
1
- export declare const VERSION = "1.25.47";
1
+ export declare const VERSION = "1.25.49";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by build — do not edit manually.
2
- export const VERSION = "1.25.47";
2
+ export const VERSION = "1.25.49";
@@ -1,13 +1,13 @@
1
1
  import React from "react";
2
2
  import type { UiStore, HostCallbacks } from "./store.js";
3
+ import { deleteWordBackward as rawDeleteWordBackward } from "./raw-input.js";
3
4
  export declare const MAX_INPUT_LEN = 600;
4
5
  export declare const CONTROL_CHAR_RE: RegExp;
5
- /** Strip control characters from typed raw input so escape flushes, newlines,
6
- * and C1 bytes never end up in the user's buffer. Exported for tests. */
6
+ /** Strip control characters from typed raw input. Exported for tests. */
7
7
  export declare function sanitizeTyped(raw: string): string;
8
8
  /** Delete the previous word including any trailing whitespace, readline-style.
9
- * Bound to Ctrl+W and Opt/Cmd+Backspace. Exported for tests. */
10
- export declare function deleteWordBackward(s: string): string;
9
+ * Exported for tests. */
10
+ export declare const deleteWordBackward: typeof rawDeleteWordBackward;
11
11
  interface Props {
12
12
  store: UiStore;
13
13
  callbacks: HostCallbacks;
package/dist/ui/input.js CHANGED
@@ -1,171 +1,172 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Keyboard + input-overlay layer.
3
3
  //
4
- // `useInput` dispatches typed characters and special keys. The hotkey table
5
- // here is the executable mirror of the canonical footer contract:
6
- // ? ask · i steer · d debrief · p pause · s settings · f fallback ·
7
- // r skip-rl · q quit · arrows+0-9 for agent detail nav
4
+ // Two input paths, chosen by `state.input.mode`:
8
5
  //
9
- // Text-entry overlays (steer, ask, settings) are *not* separate phases they
10
- // capture typed chars, show a minimal hint in the footer (handled by
11
- // footer.tsx), and dispatch on Enter/Esc.
6
+ // - mode === "none" → Ink's `useInput` dispatches hotkeys (?, i, d, p, s, …)
7
+ // - mode !== "none" → a raw stdin tap using the shared parser in
8
+ // `./raw-input.ts`. Ink's useInput is disabled while
9
+ // the overlay is open so paste never gets fragmented
10
+ // into per-char keypress events (which used to fire
11
+ // `key.return` on any `\n` in the paste and submit).
12
12
  //
13
- // Text-entry input hygiene: terminals send a zoo of escape/control sequences
14
- // for navigation keys (arrows, cmd+arrow ctrl+a/e on macOS, option+letter,
15
- // pageup/pgdn, home/end, lone ESC flushes). We explicitly swallow all of them
16
- // instead of letting them fall through to the "typed char" branch, which used
17
- // to corrupt the buffer or dismiss the overlay and discard unfinished text.
18
- import { useState, useSyncExternalStore } from "react";
19
- import { Text, Box, useInput } from "ink";
13
+ // The shared parser is the same one `cli.ts` `ask()` uses, so preflight
14
+ // prompts and in-run overlays behave identically: typed Enter = a stdin chunk
15
+ // that's exactly "\r"/"\n"/"\r\n"; anything else with embedded newlines is a
16
+ // paste, not a submit.
17
+ import { useEffect, useState, useSyncExternalStore } from "react";
18
+ import { Text, Box, useInput, useStdin } from "ink";
20
19
  import chalk from "chalk";
21
20
  import { visibleLen, wrap } from "./primitives.js";
22
21
  import { SETTINGS_FIELDS, SETTINGS_LABELS, NUMERIC_SETTINGS_FIELDS, applySettingEdit, readSettingValue, } from "./settings.js";
22
+ import { parseChunk, setBracketedPaste, deleteWordBackward as rawDeleteWordBackward } from "./raw-input.js";
23
23
  export const MAX_INPUT_LEN = 600;
24
- // Any printable char is kept verbatim; everything else is filtered out before
25
- // touching the buffer. Matches: ASCII C0 controls (0x00-0x1F), DEL (0x7F), C1
26
- // controls (0x80-0x9F), and lone ESC (already handled by `key.escape`).
24
+ // Kept for backwards compatibility with existing tests. Matches C0, DEL, C1.
27
25
  export const CONTROL_CHAR_RE = /[\x00-\x1f\x7f-\x9f]/g;
28
- /** Strip control characters from typed raw input so escape flushes, newlines,
29
- * and C1 bytes never end up in the user's buffer. Exported for tests. */
26
+ /** Strip control characters from typed raw input. Exported for tests. */
30
27
  export function sanitizeTyped(raw) {
31
28
  return raw.replace(CONTROL_CHAR_RE, "");
32
29
  }
33
30
  /** Delete the previous word including any trailing whitespace, readline-style.
34
- * Bound to Ctrl+W and Opt/Cmd+Backspace. Exported for tests. */
35
- export function deleteWordBackward(s) {
36
- const trimmed = s.replace(/\s+$/, "");
37
- const idx = trimmed.search(/\S+$/);
38
- return idx < 0 ? "" : trimmed.slice(0, idx);
39
- }
31
+ * Exported for tests. */
32
+ export const deleteWordBackward = rawDeleteWordBackward;
40
33
  export function InputLayer({ store, callbacks, onToast }) {
41
34
  const [buffer, setBuffer] = useState("");
42
35
  const [settingsField, setSettingsField] = useState(0);
43
- useInput((raw, key) => {
44
- const state = store.get();
45
- const mode = state.input.mode;
46
- const swarm = state.swarm;
47
- const lc = state.liveConfig;
48
- // ── Text-entry modes ──
49
- if (mode !== "none") {
50
- // Navigation keys must NEVER touch the buffer or dismiss the overlay.
51
- // (Bug: cmd+arrow on macOS Terminal sends ctrl+a/ctrl+e which used to
52
- // leak through and append "a"/"e"; arrows on some terminals flushed
53
- // partial ESC sequences that dropped the user's unfinished text.)
54
- if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow ||
55
- key.pageUp || key.pageDown || key.home || key.end)
36
+ const state = useSyncExternalStore(store.subscribe, store.get, store.get);
37
+ const mode = state.input.mode;
38
+ const textEntry = mode !== "none";
39
+ // ── Text-entry path: raw stdin tap via the shared parser ──
40
+ const { stdin, setRawMode, isRawModeSupported } = useStdin();
41
+ useEffect(() => {
42
+ if (!textEntry || !stdin || !isRawModeSupported)
43
+ return;
44
+ setRawMode(true);
45
+ setBracketedPaste(process.stdout, true);
46
+ // Snapshot the overlay-relevant state locally; callbacks always pull the
47
+ // latest live state via `store.get()` on each event.
48
+ const onData = (buf) => {
49
+ const cur = store.get();
50
+ const m = cur.input.mode;
51
+ if (m === "none")
56
52
  return;
57
- // Esc bails (loses the buffer — always intentional).
58
- if (key.escape) {
53
+ const swarm = cur.swarm;
54
+ const lc = cur.liveConfig;
55
+ const closeOverlay = () => {
59
56
  setBuffer("");
60
57
  setSettingsField(0);
61
58
  store.patch({ input: { mode: "none", buffer: "", settingsField: 0 } });
62
- return;
63
- }
64
- if (key.return) {
65
- const text = buffer.trim();
66
- if (mode === "steer" && text)
67
- callbacks.onSteer(text);
68
- else if (mode === "ask" && text)
69
- callbacks.onAsk(text);
70
- else if (mode === "settings") {
71
- const field = SETTINGS_FIELDS[settingsField % SETTINGS_FIELDS.length];
72
- if (lc)
73
- applySettingEdit(field, text, lc, swarm);
74
- callbacks.settingsTick();
75
- const next = settingsField + 1;
76
- setBuffer("");
77
- if (next >= SETTINGS_FIELDS.length) {
78
- setSettingsField(0);
79
- store.patch({ input: { mode: "none", buffer: "", settingsField: 0 } });
80
- }
81
- else {
82
- setSettingsField(next);
83
- store.patch({ input: { mode: "settings", buffer: "", settingsField: next } });
84
- }
85
- return;
86
- }
59
+ };
60
+ const advanceSettings = () => {
61
+ const next = settingsField + 1;
87
62
  setBuffer("");
88
- setSettingsField(0);
89
- store.patch({ input: { mode: "none", buffer: "", settingsField: 0 } });
90
- return;
91
- }
92
- // Word-delete: option/alt + backspace — expected on macOS.
93
- if ((key.meta || key.ctrl) && (key.backspace || key.delete)) {
94
- const next = deleteWordBackward(buffer);
95
- setBuffer(next);
96
- store.patch({ input: { ...state.input, buffer: next } });
97
- return;
98
- }
99
- // Swallow modifier combos so they can't leak as stray letters.
100
- // (cmd+→ on macOS Terminal = \x05 = ctrl+e → input handler sees raw='e';
101
- // without this guard we used to append 'e'.)
102
- if (key.ctrl || key.meta) {
103
- if (mode !== "settings" && key.ctrl && raw === "u") {
104
- // ctrl+U: clear the whole line — standard readline behavior.
105
- setBuffer("");
106
- store.patch({ input: { ...state.input, buffer: "" } });
107
- return;
63
+ if (next >= SETTINGS_FIELDS.length) {
64
+ setSettingsField(0);
65
+ store.patch({ input: { mode: "none", buffer: "", settingsField: 0 } });
108
66
  }
109
- if (key.ctrl && raw === "w") {
110
- const next = deleteWordBackward(buffer);
111
- setBuffer(next);
112
- store.patch({ input: { ...state.input, buffer: next } });
113
- return;
67
+ else {
68
+ setSettingsField(next);
69
+ store.patch({ input: { mode: "settings", buffer: "", settingsField: next } });
114
70
  }
115
- return;
116
- }
117
- if (key.tab) {
118
- if (mode === "settings") {
119
- const field = SETTINGS_FIELDS[settingsField % SETTINGS_FIELDS.length];
120
- if (field === "pause" && swarm && lc) {
121
- const next = !swarm.paused;
122
- swarm.setPaused(next);
123
- lc.paused = next;
124
- lc.dirty = true;
125
- callbacks.settingsTick();
126
- }
127
- const next = settingsField + 1;
128
- setBuffer("");
129
- if (next >= SETTINGS_FIELDS.length) {
130
- setSettingsField(0);
131
- store.patch({ input: { mode: "none", buffer: "", settingsField: 0 } });
71
+ };
72
+ for (const ev of parseChunk(buf.toString())) {
73
+ switch (ev.type) {
74
+ case "cancel":
75
+ closeOverlay();
76
+ return;
77
+ case "interrupt":
78
+ // Treat ^C inside overlay as cancel, not process exit.
79
+ closeOverlay();
80
+ return;
81
+ case "submit": {
82
+ const text = buffer.trim();
83
+ if (m === "steer" && text)
84
+ callbacks.onSteer(text);
85
+ else if (m === "ask" && text)
86
+ callbacks.onAsk(text);
87
+ else if (m === "settings") {
88
+ const field = SETTINGS_FIELDS[settingsField % SETTINGS_FIELDS.length];
89
+ if (lc)
90
+ applySettingEdit(field, text, lc, swarm);
91
+ callbacks.settingsTick();
92
+ advanceSettings();
93
+ return;
94
+ }
95
+ closeOverlay();
96
+ return;
132
97
  }
133
- else {
134
- setSettingsField(next);
135
- store.patch({ input: { mode: "settings", buffer: "", settingsField: next } });
98
+ case "tab":
99
+ if (m === "settings") {
100
+ const field = SETTINGS_FIELDS[settingsField % SETTINGS_FIELDS.length];
101
+ if (field === "pause" && swarm && lc) {
102
+ const next = !swarm.paused;
103
+ swarm.setPaused(next);
104
+ lc.paused = next;
105
+ lc.dirty = true;
106
+ callbacks.settingsTick();
107
+ }
108
+ advanceSettings();
109
+ }
110
+ break;
111
+ case "backspace":
112
+ setBuffer((prev) => {
113
+ const next = prev.slice(0, -1);
114
+ store.patch({ input: { ...store.get().input, buffer: next } });
115
+ return next;
116
+ });
117
+ break;
118
+ case "word-delete":
119
+ setBuffer((prev) => {
120
+ const next = rawDeleteWordBackward(prev);
121
+ store.patch({ input: { ...store.get().input, buffer: next } });
122
+ return next;
123
+ });
124
+ break;
125
+ case "clear-line":
126
+ if (m !== "settings") {
127
+ setBuffer("");
128
+ store.patch({ input: { ...store.get().input, buffer: "" } });
129
+ }
130
+ break;
131
+ case "nav":
132
+ // Navigation keys are a no-op inside the overlay. Used to leak
133
+ // as stray letters because cmd+→ on macOS sends ctrl+e.
134
+ break;
135
+ case "char":
136
+ case "paste": {
137
+ let text = ev.type === "paste" ? ev.text.replace(/\r\n?/g, "\n") : ev.text;
138
+ // Settings mode is single-line and numeric fields are digits-only.
139
+ if (m === "settings") {
140
+ const field = SETTINGS_FIELDS[settingsField % SETTINGS_FIELDS.length];
141
+ if (field === "pause")
142
+ break;
143
+ if (NUMERIC_SETTINGS_FIELDS.has(field))
144
+ text = text.replace(/[^0-9.]/g, "");
145
+ text = text.replace(/\n/g, " ");
146
+ }
147
+ if (!text)
148
+ break;
149
+ setBuffer((prev) => {
150
+ const next = (prev + text).slice(0, MAX_INPUT_LEN);
151
+ store.patch({ input: { ...store.get().input, buffer: next } });
152
+ return next;
153
+ });
154
+ break;
136
155
  }
137
156
  }
138
- // Tab in steer/ask modes is a no-op, not a submit or a "tab character".
139
- return;
140
- }
141
- if (key.backspace || key.delete) {
142
- const next = buffer.slice(0, -1);
143
- setBuffer(next);
144
- store.patch({ input: { ...state.input, buffer: next } });
145
- return;
146
- }
147
- // Typed printable char(s) — raw is the string for this event. Strip any
148
- // control chars (lone ESC flushes, \n linefeeds parseKeypress labels as
149
- // 'enter', partial ESC [ sequences) before touching the buffer.
150
- if (raw && raw.length > 0) {
151
- let text = sanitizeTyped(raw);
152
- if (mode === "settings") {
153
- const field = SETTINGS_FIELDS[settingsField % SETTINGS_FIELDS.length];
154
- if (NUMERIC_SETTINGS_FIELDS.has(field))
155
- text = text.replace(/[^0-9.]/g, "");
156
- if (field === "pause")
157
- return;
158
- }
159
- if (!text)
160
- return;
161
- const next = (buffer + text).slice(0, MAX_INPUT_LEN);
162
- setBuffer(next);
163
- store.patch({ input: { ...state.input, buffer: next } });
164
157
  }
165
- return;
166
- }
167
- // ── Hotkey mode ──
168
- // Arrow keys — agent detail cycle
158
+ };
159
+ stdin.on("data", onData);
160
+ return () => {
161
+ stdin.off("data", onData);
162
+ setBracketedPaste(process.stdout, false);
163
+ };
164
+ }, [textEntry, stdin, setRawMode, isRawModeSupported, buffer, settingsField, store, callbacks]);
165
+ // ── Hotkey path: Ink's useInput, only active when no overlay is open ──
166
+ useInput((raw, key) => {
167
+ const s = store.get();
168
+ const swarm = s.swarm;
169
+ const lc = s.liveConfig;
169
170
  if (key.rightArrow || key.downArrow) {
170
171
  callbacks.cycleAgent(1);
171
172
  return;
@@ -178,19 +179,17 @@ export function InputLayer({ store, callbacks, onToast }) {
178
179
  callbacks.clearSelectedAgent();
179
180
  return;
180
181
  }
181
- // Escape in hotkey mode — clear agent selection or dismiss answered ask
182
182
  if (key.escape) {
183
- if (state.selectedAgentId != null) {
183
+ if (s.selectedAgentId != null) {
184
184
  callbacks.clearSelectedAgent();
185
185
  return;
186
186
  }
187
- if (state.ask && !state.ask.streaming) {
187
+ if (s.ask && !s.ask.streaming) {
188
188
  callbacks.clearAsk();
189
189
  return;
190
190
  }
191
191
  return;
192
192
  }
193
- // Ctrl-C: abort swarm or exit
194
193
  if (key.ctrl && raw === "c") {
195
194
  if (swarm && !swarm.aborted) {
196
195
  swarm.abort();
@@ -198,9 +197,8 @@ export function InputLayer({ store, callbacks, onToast }) {
198
197
  }
199
198
  process.exit(0);
200
199
  }
201
- // Enter in hotkey mode — reveal ask answer file in Finder if we have one
202
200
  if (key.return) {
203
- if (state.askTempFileAvailable)
201
+ if (s.askTempFileAvailable)
204
202
  callbacks.openAskTempFile();
205
203
  return;
206
204
  }
@@ -209,18 +207,16 @@ export function InputLayer({ store, callbacks, onToast }) {
209
207
  const code = raw.charCodeAt(0);
210
208
  if (code < 0x20 || code > 0x7E)
211
209
  return;
212
- // Any ctrl/meta combo (that isn't one of the specific hotkeys above) is
213
- // nav-adjacent on most terminals; ignore instead of matching "c"/"i" etc.
214
210
  if (key.ctrl || key.meta)
215
211
  return;
216
212
  const toast = (msg) => onToast(msg);
217
213
  switch (raw.toLowerCase()) {
218
214
  case "?":
219
- if (!state.hasOnAsk)
215
+ if (!s.hasOnAsk)
220
216
  return toast("Ask not wired for this run");
221
- if (state.askBusy || state.ask?.streaming)
217
+ if (s.askBusy || s.ask?.streaming)
222
218
  return toast("Ask already in flight");
223
- if (state.ask && !state.ask.streaming) {
219
+ if (s.ask && !s.ask.streaming) {
224
220
  callbacks.clearAsk();
225
221
  return;
226
222
  }
@@ -228,17 +224,16 @@ export function InputLayer({ store, callbacks, onToast }) {
228
224
  setBuffer("");
229
225
  return;
230
226
  case "i":
231
- if (!state.hasOnSteer)
227
+ if (!s.hasOnSteer)
232
228
  return toast("Steering not wired for this run");
233
229
  store.patch({ input: { mode: "steer", buffer: "", settingsField: 0 } });
234
230
  setBuffer("");
235
231
  return;
236
232
  case "d":
237
- // Show latest debrief entry in the overlay; if nothing yet, toast.
238
- if (state.debrief)
239
- return; // already visible
240
- if (state.debriefHistory.length > 0) {
241
- const last = state.debriefHistory[state.debriefHistory.length - 1];
233
+ if (s.debrief)
234
+ return;
235
+ if (s.debriefHistory.length > 0) {
236
+ const last = s.debriefHistory[s.debriefHistory.length - 1];
242
237
  store.patch({ debrief: { text: last.text, label: last.label } });
243
238
  return;
244
239
  }
@@ -270,11 +265,8 @@ export function InputLayer({ store, callbacks, onToast }) {
270
265
  swarm.retryRateLimitNow();
271
266
  return;
272
267
  case "q":
273
- // Second press with the current swarm already aborted = hard exit.
274
268
  if (swarm?.aborted)
275
269
  process.exit(0);
276
- // Always request quit: flips the runner's `stopping` flag so the wave
277
- // loop breaks instead of advancing to steering / post-run review.
278
270
  callbacks.requestQuit();
279
271
  return;
280
272
  }
@@ -284,9 +276,7 @@ export function InputLayer({ store, callbacks, onToast }) {
284
276
  if (n < running.length)
285
277
  callbacks.selectAgent(running[n].id);
286
278
  }
287
- });
288
- // Render the active text-entry prompt under the footer hint.
289
- const state = useSyncExternalStore(store.subscribe, store.get, store.get);
279
+ }, { isActive: !textEntry });
290
280
  if (state.input.mode === "none")
291
281
  return null;
292
282
  const caretOn = state.tick % 2 === 0;
@@ -305,7 +295,7 @@ function InputPrompt({ mode, buffer, settingsField, state, caretOn }) {
305
295
  let subtitle;
306
296
  let hint;
307
297
  let currentLine = null;
308
- let filteredBuffer = buffer;
298
+ const filteredBuffer = buffer;
309
299
  if (mode === "settings") {
310
300
  const total = SETTINGS_FIELDS.length;
311
301
  const field = SETTINGS_FIELDS[settingsField % total];
@@ -323,13 +313,11 @@ function InputPrompt({ mode, buffer, settingsField, state, caretOn }) {
323
313
  const action = mode === "steer" ? "queue" : "send";
324
314
  hint = chalk.dim(`Enter ${action} \u00b7 Esc cancel \u00b7 Ctrl+U clear \u00b7 Ctrl+W del word`);
325
315
  }
326
- // Word-wrap the buffer so long entries don't blow past the box edge.
327
316
  const bufferLines = filteredBuffer.length === 0
328
317
  ? [""]
329
318
  : wrap(filteredBuffer, Math.max(20, innerW));
330
319
  const caret = caretOn ? accent("\u2588") : " ";
331
320
  const lastIdx = bufferLines.length - 1;
332
- // Char counter — dims normally, warns at 80%, red at 95%.
333
321
  const pct = buffer.length / MAX_INPUT_LEN;
334
322
  const counter = buffer.length === 0 ? "" :
335
323
  pct >= 0.95 ? chalk.red(`${buffer.length}/${MAX_INPUT_LEN}`)
@@ -0,0 +1,38 @@
1
+ export type InputEvent = {
2
+ type: "char";
3
+ text: string;
4
+ } | {
5
+ type: "paste";
6
+ text: string;
7
+ } | {
8
+ type: "backspace";
9
+ } | {
10
+ type: "word-delete";
11
+ } | {
12
+ type: "clear-line";
13
+ } | {
14
+ type: "submit";
15
+ } | {
16
+ type: "cancel";
17
+ } | {
18
+ type: "interrupt";
19
+ } | {
20
+ type: "tab";
21
+ } | {
22
+ type: "nav";
23
+ name: "up" | "down" | "left" | "right" | "home" | "end" | "pgup" | "pgdn";
24
+ };
25
+ export declare const PASTE_START = "\u001B[200~";
26
+ export declare const PASTE_END = "\u001B[201~";
27
+ export declare function sanitize(raw: string): string;
28
+ /** Split a chunk into events. Honors bracketed paste, detects paste-by-shape
29
+ * (multi-byte chunk containing newlines), and cleanly consumes escape
30
+ * sequences without leaking their terminator as a typed char. */
31
+ export declare function parseChunk(chunk: string): InputEvent[];
32
+ /** Enable/disable bracketed paste on the given stdout. Best-effort — terminals
33
+ * that don't support it simply ignore the sequence, and `parseChunk`'s
34
+ * shape-based paste detection covers them. */
35
+ export declare function setBracketedPaste(stdout: NodeJS.WriteStream, enabled: boolean): void;
36
+ /** Readline-style word delete: strip trailing whitespace, then strip the last
37
+ * non-whitespace run. */
38
+ export declare function deleteWordBackward(s: string): string;
@@ -0,0 +1,241 @@
1
+ // Shared raw-stdin parser for text entry.
2
+ //
3
+ // Single source of truth for what a chunk from stdin means. Used by both the
4
+ // Ink overlay (src/ui/input.tsx) during a run and the preflight `ask()` prompt
5
+ // (src/cli/cli.ts) before a run. Fixes two classes of bugs that existed in
6
+ // both copies:
7
+ //
8
+ // 1. "@ triggered send" — CSI/SS3 terminator check was `< 0x7E` (missed `~`)
9
+ // and the ESC+printable branch silently dropped the next char.
10
+ // 2. "paste with newline sent early" — Ink's useInput fragments multi-byte
11
+ // chunks into per-char keypress events, firing key.return on any `\n` in
12
+ // a paste. Here we keep the whole chunk and decide paste-vs-typed-enter
13
+ // by whether the chunk is exactly a newline (typed) or contains
14
+ // newlines alongside other bytes (pasted).
15
+ //
16
+ // The parser is pure: takes a string chunk, returns an ordered event list.
17
+ // Bracketed-paste markers are honored when present but we don't rely on them.
18
+ export const PASTE_START = "\x1B[200~";
19
+ export const PASTE_END = "\x1B[201~";
20
+ // Control chars to strip from any text we append to a buffer. Matches C0
21
+ // (0x00-0x1F), DEL (0x7F), and C1 (0x80-0x9F). Newlines are kept — the caller
22
+ // decides per segment whether to strip or preserve them.
23
+ const CONTROL_STRIP_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g;
24
+ export function sanitize(raw) {
25
+ return raw.replace(CONTROL_STRIP_RE, "");
26
+ }
27
+ // CSI/SS3 final byte is in the range 0x40..0x7E inclusive. (The old code used
28
+ // `< 0x7E` which dropped past the `~` terminator used by function keys and
29
+ // the bracketed-paste close marker.)
30
+ function isCsiFinal(code) {
31
+ return code >= 0x40 && code <= 0x7E;
32
+ }
33
+ // Recognized CSI/SS3 sequences for navigation. Anything else with a valid
34
+ // CSI/SS3 shape is silently consumed.
35
+ function csiToNav(body) {
36
+ // body = everything between ESC[ (or ESCO) and the terminator, plus terminator
37
+ // Common sequences: A=up, B=down, C=right, D=left, H=home, F=end,
38
+ // 5~=pgup, 6~=pgdn, 1~/7~=home, 4~/8~=end
39
+ if (body === "A")
40
+ return { type: "nav", name: "up" };
41
+ if (body === "B")
42
+ return { type: "nav", name: "down" };
43
+ if (body === "C")
44
+ return { type: "nav", name: "right" };
45
+ if (body === "D")
46
+ return { type: "nav", name: "left" };
47
+ if (body === "H" || body === "1~" || body === "7~")
48
+ return { type: "nav", name: "home" };
49
+ if (body === "F" || body === "4~" || body === "8~")
50
+ return { type: "nav", name: "end" };
51
+ if (body === "5~")
52
+ return { type: "nav", name: "pgup" };
53
+ if (body === "6~")
54
+ return { type: "nav", name: "pgdn" };
55
+ return null;
56
+ }
57
+ /** Split a chunk into events. Honors bracketed paste, detects paste-by-shape
58
+ * (multi-byte chunk containing newlines), and cleanly consumes escape
59
+ * sequences without leaking their terminator as a typed char. */
60
+ export function parseChunk(chunk) {
61
+ const out = [];
62
+ if (!chunk)
63
+ return out;
64
+ // First pass: carve out bracketed-paste blocks. Everything outside those
65
+ // markers is "free text" — we still apply shape-based paste detection to it.
66
+ const parts = [];
67
+ let i = 0;
68
+ while (i < chunk.length) {
69
+ const ps = chunk.indexOf(PASTE_START, i);
70
+ if (ps === -1) {
71
+ parts.push({ kind: "free", text: chunk.slice(i) });
72
+ break;
73
+ }
74
+ if (ps > i)
75
+ parts.push({ kind: "free", text: chunk.slice(i, ps) });
76
+ const bodyStart = ps + PASTE_START.length;
77
+ const pe = chunk.indexOf(PASTE_END, bodyStart);
78
+ if (pe === -1) {
79
+ parts.push({ kind: "paste", text: chunk.slice(bodyStart) });
80
+ break;
81
+ }
82
+ parts.push({ kind: "paste", text: chunk.slice(bodyStart, pe) });
83
+ i = pe + PASTE_END.length;
84
+ }
85
+ for (const part of parts) {
86
+ if (part.kind === "paste") {
87
+ if (part.text)
88
+ out.push({ type: "paste", text: part.text });
89
+ continue;
90
+ }
91
+ // Free text: walk byte by byte, consuming escape sequences and control
92
+ // bytes. Typed enter = a chunk that's EXACTLY "\r", "\n", or "\r\n".
93
+ const s = part.text;
94
+ if (!s)
95
+ continue;
96
+ // Fast path: a chunk that is just enter, tab, backspace, or ^C.
97
+ if (s === "\r" || s === "\n" || s === "\r\n") {
98
+ out.push({ type: "submit" });
99
+ continue;
100
+ }
101
+ if (s === "\x03") {
102
+ out.push({ type: "interrupt" });
103
+ continue;
104
+ }
105
+ if (s === "\x7F" || s === "\b") {
106
+ out.push({ type: "backspace" });
107
+ continue;
108
+ }
109
+ if (s === "\t") {
110
+ out.push({ type: "tab" });
111
+ continue;
112
+ }
113
+ if (s === "\x1B") {
114
+ out.push({ type: "cancel" });
115
+ continue;
116
+ }
117
+ if (s === "\x15") {
118
+ out.push({ type: "clear-line" });
119
+ continue;
120
+ } // ^U
121
+ if (s === "\x17") {
122
+ out.push({ type: "word-delete" });
123
+ continue;
124
+ } // ^W
125
+ // Shape-based paste: multi-char chunk containing a newline that is NOT
126
+ // just "\r\n". Terminals buffer keypresses at ~1 byte; paste comes in as
127
+ // a single large chunk. Anything multi-char with embedded newlines is
128
+ // paste, not a sequence of typed Enters.
129
+ if ((s.includes("\n") || s.includes("\r")) && s.length > 2) {
130
+ const stripped = s.replace(/\r/g, "");
131
+ if (stripped)
132
+ out.push({ type: "paste", text: stripped });
133
+ continue;
134
+ }
135
+ // Otherwise walk the chunk one logical token at a time.
136
+ let j = 0;
137
+ let buf = "";
138
+ const flushBuf = () => { if (buf) {
139
+ out.push({ type: "char", text: buf });
140
+ buf = "";
141
+ } };
142
+ while (j < s.length) {
143
+ const ch = s[j];
144
+ if (ch === "\r" || ch === "\n") {
145
+ // Bare newline embedded inside a short non-paste chunk: treat as
146
+ // submit if it's the tail, otherwise drop (shouldn't happen in well-
147
+ // formed terminal input — paste goes through the shape path above).
148
+ flushBuf();
149
+ out.push({ type: "submit" });
150
+ j++;
151
+ continue;
152
+ }
153
+ if (ch === "\x03") {
154
+ flushBuf();
155
+ out.push({ type: "interrupt" });
156
+ j++;
157
+ continue;
158
+ }
159
+ if (ch === "\x7F" || ch === "\b") {
160
+ flushBuf();
161
+ out.push({ type: "backspace" });
162
+ j++;
163
+ continue;
164
+ }
165
+ if (ch === "\t") {
166
+ flushBuf();
167
+ out.push({ type: "tab" });
168
+ j++;
169
+ continue;
170
+ }
171
+ if (ch === "\x15") {
172
+ flushBuf();
173
+ out.push({ type: "clear-line" });
174
+ j++;
175
+ continue;
176
+ }
177
+ if (ch === "\x17") {
178
+ flushBuf();
179
+ out.push({ type: "word-delete" });
180
+ j++;
181
+ continue;
182
+ }
183
+ if (ch === "\x1B") {
184
+ flushBuf();
185
+ const next = j + 1 < s.length ? s[j + 1] : null;
186
+ if (next === null) {
187
+ out.push({ type: "cancel" });
188
+ j++;
189
+ continue;
190
+ }
191
+ if (next === "[" || next === "O") {
192
+ // CSI or SS3: consume up to and including the final byte (0x40..0x7E).
193
+ let k = j + 2;
194
+ let body = "";
195
+ while (k < s.length) {
196
+ const code = s.charCodeAt(k);
197
+ body += s[k];
198
+ k++;
199
+ if (isCsiFinal(code))
200
+ break;
201
+ }
202
+ const nav = csiToNav(body);
203
+ if (nav)
204
+ out.push(nav);
205
+ j = k;
206
+ continue;
207
+ }
208
+ // ESC + printable = Meta/Alt + key. Drop both bytes; upstream can
209
+ // surface specific combos later if needed.
210
+ j += 2;
211
+ continue;
212
+ }
213
+ const code = ch.charCodeAt(0);
214
+ // Skip remaining control bytes silently.
215
+ if (code < 0x20 || (code >= 0x7F && code < 0xA0)) {
216
+ j++;
217
+ continue;
218
+ }
219
+ buf += ch;
220
+ j++;
221
+ }
222
+ flushBuf();
223
+ }
224
+ return out;
225
+ }
226
+ /** Enable/disable bracketed paste on the given stdout. Best-effort — terminals
227
+ * that don't support it simply ignore the sequence, and `parseChunk`'s
228
+ * shape-based paste detection covers them. */
229
+ export function setBracketedPaste(stdout, enabled) {
230
+ try {
231
+ stdout.write(enabled ? "\x1B[?2004h" : "\x1B[?2004l");
232
+ }
233
+ catch { }
234
+ }
235
+ /** Readline-style word delete: strip trailing whitespace, then strip the last
236
+ * non-whitespace run. */
237
+ export function deleteWordBackward(s) {
238
+ const trimmed = s.replace(/\s+$/, "");
239
+ const idx = trimmed.search(/\S+$/);
240
+ return idx < 0 ? "" : trimmed.slice(0, idx);
241
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.25.47",
3
+ "version": "1.25.49",
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.47",
3
+ "version": "1.25.49",
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"
@@ -1,12 +1,13 @@
1
1
  ---
2
2
  name: claude-overnight
3
3
  description: >
4
- Understand, install, and inspect claude-overnight runs -- a CLI that
4
+ Understand, author, install, and inspect claude-overnight runs -- a CLI that
5
5
  launches parallel Claude agents in git worktrees with thinking waves,
6
- multi-wave steering, three-layer review, and crash-safe resume. Use when the user mentions
7
- claude-overnight, a `.claude-overnight/` folder, an "overnight" or
8
- "swarm" run, or asks to check status / resume / continue a
9
- multi-phase plan. Not for Vercel Workflow DevKit.
6
+ multi-wave steering, three-layer review, and crash-safe resume. Use when the user
7
+ mentions claude-overnight, a `.claude-overnight/` folder, an "overnight" or
8
+ "swarm" run, asks to check status / resume / continue a multi-phase plan,
9
+ or asks to plan / design / write a `tasks.json` / objective / overnight workflow.
10
+ Not for Vercel Workflow DevKit.
10
11
  ---
11
12
 
12
13
  # What it is
@@ -49,6 +50,15 @@ Live keys while running: `b` change budget · `t` change usage cap · `q` gracef
49
50
 
50
51
  Exit codes: `0` all ok · `1` some failed · `2` all/none.
51
52
 
53
+ # Authoring a run (tasks.json / objective)
54
+
55
+ When the user asks you to *plan*, *design*, or *write* an overnight run (not inspect one), load the authoring knowledge **on demand** — don't carry it by default:
56
+
57
+ - `recipes.md` (next to this file) — scenario → recipe matrix: objective shape, `flexiblePlan`, initial tasks, `concurrency`, budget range, planner/worker pairing, phases to skip. Read this when picking a run shape for a known scenario (refactor, feature batch, migration, test/docs sprint, bug hunt, research).
58
+ - `authoring.md` (next to this file) — decision tree (fixed vs flex vs inline; when to `--no-flex`; when thinking wave is wasted), pre-flight critic checklist (no "do anything" prompts, language-agnostic phrasing, verify-before-done, budget ≥ per-wave cost × expected waves, decomposition sanity), and anti-patterns. Read this before finalizing any tasks.json or before pressing Run.
59
+
60
+ Rule of thumb: if the user has a concrete list of tasks and a clear endpoint, prefer fixed-plan (`--no-flex`) and skip the thinking wave. If the user has a fuzzy objective ("modernize X", "audit Y"), prefer `objective + flexiblePlan: true` with a small seed task list and let steering drive. Never send a single "do anything" prompt to one agent — decompose first (see authoring.md).
61
+
52
62
  # On-disk layout (this is how you inspect status)
53
63
 
54
64
  Every run lives at `<repo>/.claude-overnight/runs/<ISO-timestamp>/`:
@@ -0,0 +1,107 @@
1
+ # Authoring a claude-overnight Run
2
+
3
+ Read this before finalizing a `tasks.json` or telling the user to press Run. Pair with `recipes.md` for the scenario matrix.
4
+
5
+ ## Decision tree
6
+
7
+ 1. **Does the user have a concrete list of tasks with a clear endpoint?**
8
+ - Yes → **fixed plan**: `tasks.json` with explicit `tasks[]`, `--no-flex`, skip thinking wave (auto-skipped below budget 15; for higher budgets pass a pre-written `tasks.json` — the CLI will not re-plan).
9
+ - No → continue.
10
+
11
+ 2. **Is the objective fuzzy ("modernize", "audit", "clean up", "make it amazing")?**
12
+ - Yes → **flex plan**: `objective` + `flexiblePlan: true` + 2–5 seed tasks. Let the thinking wave explore and steering drive. Budget ≥ 30.
13
+ - No but also not concrete → write 3–5 seed tasks you *know* are needed, enable flex, and let steering add the rest.
14
+
15
+ 3. **Is this a single-wave mechanical job (docs, formatting, coverage fill)?**
16
+ - Yes → `--no-flex`, skip thinking, low-cost worker (Qwen or Sonnet), high concurrency OK.
17
+
18
+ 4. **Is this a shared-surface problem (migration, bug hunt, one-file refactor)?**
19
+ - Yes → low concurrency (2–4). Merge conflicts dominate otherwise.
20
+
21
+ 5. **Does completion require running the app (not just reading code)?**
22
+ - Yes → task prompts must *explicitly* instruct run-and-test. Add `afterWave` hook to run tests. See *verify-before-done* below.
23
+
24
+ ## Pre-flight critic checklist
25
+
26
+ Walk the proposed run against each item before Run. One fail = revise.
27
+
28
+ ### Task shape
29
+ - [ ] **No "do anything" prompts.** Every task names a scope (files, module, feature) and a concrete outcome. If a task reads "improve X", decompose it first.
30
+ - [ ] **Language-agnostic phrasing.** Don't bake in `npm`, `jest`, `pnpm`, etc., in the objective unless the repo is pinned to them. Shape meta-prompts ("run the project's test suite"), not tool names.
31
+ - [ ] **Verify-before-done.** Each task that changes behavior must include "run and test the change" — not just "edit the code". For UI tasks, require browser verification (Playwright MCP). For backend, require the test suite or a repro script.
32
+ - [ ] **Decomposition is real.** If a task is >1 day of human work, split. If two tasks touch the same file heavily, merge or serialize (low concurrency).
33
+ - [ ] **One outcome per task.** No "refactor auth AND add tests AND update docs" — that's three tasks.
34
+
35
+ ### Budget & economics
36
+ - [ ] **Budget ≥ per-wave cost × expected waves.** For flex runs, expect 3–6 waves. Per-wave cost = planner (~$1–3) + tasks × worker cost.
37
+ - [ ] **Thinking wave justified.** Skip if tasks are already concrete or budget < 15. Thinking at budget=2000 costs $15–40 — worth it only for genuine exploration.
38
+ - [ ] **Planner isn't cheaped out.** Planner quality = run quality ceiling. Opus for high-stakes, Sonnet for everyday, never Qwen for planner.
39
+ - [ ] **Usage cap set.** Default 90% leaves headroom for interactive Claude. `--allow-extra-usage` off unless the user explicitly opts in, and only with `--extra-usage-budget=N`.
40
+
41
+ ### Environment & safety
42
+ - [ ] **Clean git tree** (or user has explicitly OK'd uncommitted changes being swept into worktrees).
43
+ - [ ] **`.claude-overnight/` in `.gitignore`** (with trailing slash — the `.md` log file at repo root stays committable).
44
+ - [ ] **Required env / keys present.** API keys, DB URLs, auth tokens — if the worker needs them, the repo's `.env` must have them (worktrees inherit).
45
+ - [ ] **MCP servers configured** for parallel Playwright (one `--isolated` entry per concurrency slot, or shared `--isolated --headless` if no login needed).
46
+ - [ ] **Hooks don't abort the run.** `beforeWave`/`afterWave`/`afterRun` failures surface but never stop — make sure that's what the user wants.
47
+
48
+ ### Circuit-breaker awareness
49
+ - [ ] **User knows to watch for 2 consecutive zero-file-change waves** — that's the halt signal. Silent try/catch in wave loops is a landmine; if the run looks "busy but unchanged", stop it.
50
+ - [ ] **First-attempt failure mitigation.** First planner call is expensive ($2–4). If the objective can be expressed as concrete tasks, skip the planner entirely.
51
+
52
+ ## Common anti-patterns
53
+
54
+ ### The "overnight hail-mary"
55
+ User dumps a vague wish + $1000 budget + flex + max concurrency and walks away. Output: 200 worktrees, 50 merge conflicts, no coherent diff, $400 of steering context-shuffling.
56
+ **Fix:** decompose the wish into 5–10 seed tasks, start at budget=50, verify the first wave delivers before topping up (live `b` key).
57
+
58
+ ### The "single agent, do everything"
59
+ One task prompt: "refactor the whole auth system". One agent touches 40 files, simplify pass can't review a sprawl, merge succeeds but the result is incoherent.
60
+ **Fix:** decompose into per-surface tasks (middleware, session store, tokens, tests). Let steering add integration work.
61
+
62
+ ### The "verification theater"
63
+ Tasks say "add tests" but don't say "run them". Agent writes plausible-looking tests that don't compile. Final gate catches it — but 10 waves in.
64
+ **Fix:** every behavior-changing task ends with "run the test suite and ensure it passes". `afterWave: "pnpm test"` adds a safety net.
65
+
66
+ ### The "wrong tool for the job"
67
+ Using flex mode for a mechanical docs sprint — planner burns budget steering a problem that needs no steering.
68
+ **Fix:** `--no-flex` for mechanical work.
69
+
70
+ ### The "proxied model mystery"
71
+ Worker is on Cursor proxy. User wonders why there are no thinking deltas in transcripts.
72
+ **Fix:** expected behavior — Cursor proxy suppresses thinking phase (see README table). Don't chase it.
73
+
74
+ ## Writing a good objective (for flex runs)
75
+
76
+ Structure: `<verb> <scope> so that <outcome / quality bar>`.
77
+
78
+ Good:
79
+ - "Modernize the auth system so that session tokens meet SOC2 storage requirements and existing flows continue to work."
80
+ - "Raise test coverage in `packages/api` to >80% line coverage, prioritizing error paths and boundary cases."
81
+
82
+ Bad:
83
+ - "Make the code better." → no scope, no outcome.
84
+ - "Do whatever needs doing on auth." → no quality bar.
85
+ - "Refactor everything and add features." → two objectives.
86
+
87
+ The `goal.md` file lets steering evolve the "north star" — but it can only evolve a seed that's already grounded. A vague seed stays vague.
88
+
89
+ ## Writing good seed tasks (flex mode)
90
+
91
+ Each seed should:
92
+ 1. Name a scope (file, module, feature, package).
93
+ 2. Name an outcome (what "done" looks like).
94
+ 3. Be independently verifiable (a test, a build step, a visible UI change).
95
+ 4. Not overlap heavily with siblings (otherwise serialize or drop concurrency).
96
+
97
+ Example seeds for "Modernize auth":
98
+ - "Audit `packages/auth/middleware.ts` and document the session-token storage approach, flagging SOC2 gaps."
99
+ - "Add a reproduction test for the current session-token storage that fails under the new SOC2 requirement."
100
+ - "Design a migration path from cookie storage to encrypted-at-rest store; output as `designs/auth-migration.md`."
101
+
102
+ Steering will add execution tasks (the actual migration code) in later waves, grounded in what the seeds found.
103
+
104
+ ## When to invoke the coach skill vs. this one
105
+
106
+ - **`claude-overnight` skill** (this one): Claude helps the user plan an overnight run *outside* the CLI — picking shape, writing tasks.json, critiquing budget.
107
+ - **`claude-overnight-coach` skill**: runs *inside* the CLI at startup, turns a raw objective into recommended settings + checklist. Different entry point, overlapping knowledge. Don't invoke coach from here.
@@ -0,0 +1,48 @@
1
+ # Overnight Run Recipes
2
+
3
+ Scenario → recommended run shape. These are defaults, not laws — adjust when the repo or user constraints say so. Always pair with `authoring.md` (decision tree + pre-flight).
4
+
5
+ ## Recipe matrix
6
+
7
+ | Scenario | Shape | `flexiblePlan` | Budget | Concurrency | Planner / Worker | Skip phases | Notes |
8
+ |---|---|---|---|---|---|---|---|
9
+ | **Fixed refactor** (concrete file list, clear endpoint) | `tasks.json` with explicit tasks | `false` (`--no-flex`) | 1× tasks + ~20% headroom | 3–5 | Sonnet / Sonnet | thinking wave, post-wave review | Each task = one cohesive unit of work. Cheapest mode. |
10
+ | **Feature batch** (N independent features) | `tasks.json`, one task per feature | `false` initially; `true` if features bleed into shared code | 2–3× feature count | 4–6 | Opus / Sonnet | thinking wave if features are well-scoped | Require verify-before-done per task. |
11
+ | **Framework migration** (Next 14→16, React 18→19, etc.) | `objective` + seed tasks per package | `true` | 50–200 | 3–5 | Opus / Sonnet | none — keep thinking + review | Steering re-plans as breakage surfaces. `beforeWave`: install deps. |
12
+ | **Test sprint** (raise coverage, fill gaps) | `objective` + seed per module | `true` | 30–100 | 5–8 | Sonnet / Sonnet (or Qwen for cost) | thinking if coverage report is attached | `afterWave`: run test suite, feed failures forward. |
13
+ | **Docs sprint** (API docs, guides) | `tasks.json` per doc surface | `false` | 1× docs + 10% | 4–6 | Sonnet / Sonnet (or Qwen) | thinking wave, reflection | Pure output task — flex mode wastes planner. |
14
+ | **Bug hunt** (unknown cause, repro unstable) | `objective` + the repro | `true` | 20–80 | 2–4 | Opus / Opus | none | Low concurrency — workers step on each other on shared bug surface. Verify fix via reproduction script. |
15
+ | **Codebase audit / research** (no code changes) | `objective` + focus list | `true` | 30–80 | 5–10 | Opus / Sonnet | n/a — architects *are* the work | Output is `designs/*.md` + `milestones/`. Set `permissionMode: "default"` so workers can't write. |
16
+ | **Framework-wide cleanup** (dead code, consistency) | `objective` + seed tasks | `true` | 100–300 | 5–8 | Opus / Sonnet + fast Qwen | thinking if scope is obvious | Use fast worker for well-scoped mechanical tasks. |
17
+ | **Long research run** (exploration, prototypes) | `objective` + loose tasks | `true` | 200–1000 | 3–5 | Opus / Opus | none | `usageCap: 90`, `--allow-extra-usage` off unless explicitly requested. |
18
+
19
+ ## Budget heuristics
20
+
21
+ - **Per-wave cost floor** ≈ $2–4 planner + $N workers × avg task cost (Sonnet ≈ $0.15–0.40, Opus ≈ $0.50–1.50, Qwen ≈ <$0.05). Budget must cover *expected waves × per-wave cost*, not just task count.
22
+ - **Thinking wave cost** scales with budget: 5 architects at budget=50 (~$3–8), 10 at budget=2000 (~$15–40). Skip when you don't need exploration.
23
+ - **Flex overhead**: each steering pass is one planner call (~$0.50–2 on Opus). For 10-wave flex runs, reserve ~$10 for steering alone.
24
+
25
+ ## Model pairing defaults
26
+
27
+ | Run type | Planner | Main worker | Fast worker |
28
+ |---|---|---|---|
29
+ | High-stakes (production refactor, migration) | Opus | Sonnet | — |
30
+ | Everyday (features, tests, cleanups) | Sonnet | Sonnet | Qwen 3.6 Plus |
31
+ | Cost-sensitive (docs, mechanical batch) | Sonnet | Qwen 3.6 Plus | — |
32
+ | Research / audit (read-heavy) | Opus | Opus | — |
33
+
34
+ Rationale: planner quality is the ceiling for the whole run. Never cheap out on planner unless the run is purely mechanical.
35
+
36
+ ## Phase-skip cheatsheet
37
+
38
+ - **Skip thinking wave** when: tasks are already concrete · user has already explored the code · scenario is "docs/tests/mechanical batch" · budget < 15 (auto-skipped).
39
+ - **Skip flex / steering** (`--no-flex`) when: endpoint is crisp · tasks are independent · no assessment needed between waves.
40
+ - **Skip post-wave review** when: single-wave run · budget is tight · tasks are trivially verifiable (docs, formatting).
41
+ - **Always keep final gate** (post-run review) unless `--dry-run`. It's the last quality check before the diff lands.
42
+
43
+ ## Anti-recipes (don't do these)
44
+
45
+ - "Do everything in `src/`" → one agent, no decomposition. See `authoring.md` → *decompose fallback*.
46
+ - `budget=5` + `flexiblePlan: true` → planner eats most of the budget, workers starve.
47
+ - High concurrency on shared-file scenarios (migrations, bug hunts) → merge conflicts dominate. Drop to 2–4.
48
+ - `usageCap: 100` + `--allow-extra-usage` without `--extra-usage-budget` → silent overage. Always cap extra spend explicitly.