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 +3 -24
- package/dist/cli/cli.js +59 -92
- package/dist/core/_version.d.ts +1 -1
- package/dist/core/_version.js +1 -1
- package/dist/ui/input.d.ts +4 -4
- package/dist/ui/input.js +154 -166
- package/dist/ui/raw-input.d.ts +38 -0
- package/dist/ui/raw-input.js +241 -0
- package/package.json +1 -1
- package/plugins/claude-overnight/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-overnight/skills/claude-overnight/SKILL.md +15 -5
- package/plugins/claude-overnight/skills/claude-overnight/authoring.md +107 -0
- package/plugins/claude-overnight/skills/claude-overnight/recipes.md +48 -0
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
|
-
*
|
|
37
|
-
*
|
|
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
|
-
// ──
|
|
70
|
+
// ── Interactive primitives ──
|
|
70
71
|
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
+
appendTypedChar(segs, norm);
|
|
126
93
|
return;
|
|
127
94
|
}
|
|
128
95
|
segs.push({ type: "paste", content: norm });
|
|
129
96
|
}
|
|
130
|
-
|
|
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
|
-
*
|
|
153
|
-
*
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/core/_version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "1.25.
|
|
1
|
+
export declare const VERSION = "1.25.49";
|
package/dist/core/_version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by build — do not edit manually.
|
|
2
|
-
export const VERSION = "1.25.
|
|
2
|
+
export const VERSION = "1.25.49";
|
package/dist/ui/input.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
10
|
-
export declare
|
|
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
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
import {
|
|
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
|
-
//
|
|
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
|
|
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
|
-
*
|
|
35
|
-
export
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 (
|
|
183
|
+
if (s.selectedAgentId != null) {
|
|
184
184
|
callbacks.clearSelectedAgent();
|
|
185
185
|
return;
|
|
186
186
|
}
|
|
187
|
-
if (
|
|
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 (
|
|
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 (!
|
|
215
|
+
if (!s.hasOnAsk)
|
|
220
216
|
return toast("Ask not wired for this run");
|
|
221
|
-
if (
|
|
217
|
+
if (s.askBusy || s.ask?.streaming)
|
|
222
218
|
return toast("Ask already in flight");
|
|
223
|
-
if (
|
|
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 (!
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
7
|
-
claude-overnight, a `.claude-overnight/` folder, an "overnight" or
|
|
8
|
-
"swarm" run,
|
|
9
|
-
|
|
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.
|