claude-overnight 1.25.45 → 1.25.46

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
@@ -61,6 +61,11 @@ export interface FileArgs {
61
61
  usageCap?: number;
62
62
  flexiblePlan?: boolean;
63
63
  }
64
+ /** Load a markdown plan file. Extracts the first H1 as objective and returns the full body as planContent. */
65
+ export declare function loadPlanFile(file: string): {
66
+ objective: string;
67
+ planContent: string;
68
+ };
64
69
  export declare function loadTaskFile(file: string): FileArgs;
65
70
  export declare function validateConcurrency(value: unknown): asserts value is number;
66
71
  export declare function isGitRepo(cwd: string): boolean;
package/dist/cli/cli.js CHANGED
@@ -7,7 +7,7 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
7
7
  // ── CLI flag parsing ──
8
8
  export function parseCliFlags(argv) {
9
9
  const known = new Set(["concurrency", "model", "timeout", "budget", "usage-cap", "extra-usage-budget", "merge"]);
10
- const booleans = new Set(["--dry-run", "-h", "--help", "-v", "--version", "--no-flex", "--allow-extra-usage", "--worktrees", "--no-worktrees", "--yolo"]);
10
+ const booleans = new Set(["--dry-run", "-h", "--help", "-v", "--version", "--flex", "--no-flex", "--allow-extra-usage", "--worktrees", "--no-worktrees", "--yolo"]);
11
11
  const flags = {};
12
12
  const positional = [];
13
13
  for (let i = 0; i < argv.length; i++) {
@@ -334,6 +334,23 @@ export async function selectKey(label, options) {
334
334
  const KNOWN_TASK_FILE_KEYS = new Set([
335
335
  "tasks", "objective", "concurrency", "cwd", "model", "allowedTools", "beforeWave", "afterWave", "afterRun", "worktrees", "mergeStrategy", "usageCap", "flexiblePlan",
336
336
  ]);
337
+ /** Load a markdown plan file. Extracts the first H1 as objective and returns the full body as planContent. */
338
+ export function loadPlanFile(file) {
339
+ const path = resolve(file);
340
+ let raw;
341
+ try {
342
+ raw = readFileSync(path, "utf-8");
343
+ }
344
+ catch {
345
+ throw new Error(`Cannot read plan file: ${path}`);
346
+ }
347
+ const body = raw.trim();
348
+ if (!body)
349
+ throw new Error(`Plan file is empty: ${path}`);
350
+ const h1 = body.match(/^#\s+(.+)$/m);
351
+ const objective = (h1?.[1] ?? body.split("\n").find(l => l.trim())).trim();
352
+ return { objective, planContent: body };
353
+ }
337
354
  export function loadTaskFile(file) {
338
355
  const path = resolve(file);
339
356
  let raw;
@@ -1 +1 @@
1
- export declare const VERSION = "1.25.45";
1
+ export declare const VERSION = "1.25.46";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by build — do not edit manually.
2
- export const VERSION = "1.25.45";
2
+ export const VERSION = "1.25.46";
@@ -59,27 +59,29 @@ export function verifyToken(token, providerId) {
59
59
  */
60
60
  export function verifyTokenWithResult(token, options = {}) {
61
61
  const { providerId, model, baseURL } = options;
62
- // Unsafely decode the token to extract the `sub` claim so we can derive
63
- // the correct signing key. This does NOT verify the signature yet.
64
- const raw = jwt.decode(token);
65
- if (!raw || typeof raw !== "object") {
62
+ const header = jwt.decode(token, { complete: true });
63
+ if (!header || typeof header === "string") {
66
64
  return { valid: false, reason: "invalid_signature" };
67
65
  }
68
- const sub = raw.sub;
69
- if (typeof sub !== "string" || !sub) {
66
+ const loose = header.payload;
67
+ const subForKey = loose.sub;
68
+ if (!subForKey || typeof subForKey !== "string") {
69
+ return { valid: false, reason: "invalid_signature" };
70
+ }
71
+ let key;
72
+ try {
73
+ key = deriveKey(subForKey);
74
+ }
75
+ catch {
70
76
  return { valid: false, reason: "invalid_signature" };
71
77
  }
72
- const key = deriveKey(sub);
73
78
  try {
74
79
  const decoded = jwt.verify(token, key, {
75
80
  algorithms: ["HS256"],
76
- // Let jwt.verify check expiration for us
77
81
  });
78
- // Reject tokens from older versions
79
82
  if (decoded.ver !== TOKEN_VERSION) {
80
83
  return { valid: false, reason: "wrong_version" };
81
84
  }
82
- // Validate claims if expected values are provided
83
85
  if (providerId && decoded.sub !== providerId) {
84
86
  return { valid: false, reason: "claim_mismatch" };
85
87
  }
@@ -8,6 +8,12 @@ export interface RateLimiterConfig {
8
8
  windowMs: number;
9
9
  minIntervalMs?: number;
10
10
  }
11
+ export interface AcquireOptions {
12
+ /** When true, skip sliding-window / min-interval waits (caller still records after the request). */
13
+ skipWhen?: () => boolean;
14
+ /** Invoked once when `skipWhen()` returned true and the throttle was bypassed. */
15
+ onBypass?: () => void;
16
+ }
11
17
  export declare class RateLimiter {
12
18
  private readonly maxRequests;
13
19
  private readonly windowMs;
@@ -18,6 +24,8 @@ export declare class RateLimiter {
18
24
  record(): void;
19
25
  get currentCount(): number;
20
26
  canRequest(): boolean;
27
+ /** Wait until a request slot is available. Optional `skipWhen` bypasses the throttle entirely. */
28
+ acquire(options?: AcquireOptions): Promise<number>;
21
29
  waitIfNeeded(): Promise<number>;
22
30
  waitMs(): number;
23
31
  reset(): void;
@@ -29,6 +37,8 @@ export declare class RateLimiter {
29
37
  }
30
38
  /** Shared rate limiter for SDK query calls — enforced globally across all workers. */
31
39
  export declare const sdkQueryRateLimiter: RateLimiter;
40
+ /** Acquire SDK query slot. Skips the SDK sliding-window limiter when `CURSOR_PROXY_URL` is set (proxy has its own limiters). */
41
+ export declare function acquireSdkQueryRateLimit(): Promise<number>;
32
42
  /** Shared rate limiter for Cursor proxy direct fetches — enforced globally. */
33
43
  export declare const cursorProxyRateLimiter: RateLimiter;
34
44
  /** Shared rate limiter for direct API endpoint calls — guards against rapid
@@ -38,12 +38,20 @@ export class RateLimiter {
38
38
  return this.timestamps.length < this.maxRequests
39
39
  && (Date.now() - this.lastRequestAt) >= this.minIntervalMs;
40
40
  }
41
- async waitIfNeeded() {
41
+ /** Wait until a request slot is available. Optional `skipWhen` bypasses the throttle entirely. */
42
+ async acquire(options) {
43
+ if (options?.skipWhen?.()) {
44
+ options.onBypass?.();
45
+ return 0;
46
+ }
42
47
  const waited = this.waitMs();
43
48
  if (waited > 0)
44
49
  await new Promise(r => setTimeout(r, waited));
45
50
  return waited;
46
51
  }
52
+ async waitIfNeeded() {
53
+ return this.acquire();
54
+ }
47
55
  waitMs() {
48
56
  this.evict();
49
57
  const volumeWait = this.timestamps.length >= this.maxRequests
@@ -86,6 +94,15 @@ const _cursorProxyLimiter = new RateLimiter({ maxRequests: 4, windowMs: 10_000 }
86
94
  const _apiEndpointLimiter = new RateLimiter({ maxRequests: 6, windowMs: 15_000, minIntervalMs: 1_000 });
87
95
  /** Shared rate limiter for SDK query calls — enforced globally across all workers. */
88
96
  export const sdkQueryRateLimiter = _sdkQueryLimiter;
97
+ /** Acquire SDK query slot. Skips the SDK sliding-window limiter when `CURSOR_PROXY_URL` is set (proxy has its own limiters). */
98
+ export async function acquireSdkQueryRateLimit() {
99
+ return _sdkQueryLimiter.acquire({
100
+ skipWhen: () => !!process.env.CURSOR_PROXY_URL,
101
+ onBypass: () => {
102
+ console.log("[rate-limiter] Skipping SDK rate limit (Cursor proxy has its own limiter)");
103
+ },
104
+ });
105
+ }
89
106
  /** Shared rate limiter for Cursor proxy direct fetches — enforced globally. */
90
107
  export const cursorProxyRateLimiter = _cursorProxyLimiter;
91
108
  /** Shared rate limiter for direct API endpoint calls — guards against rapid
@@ -21,7 +21,7 @@ export function getCachedToken(providerId) {
21
21
  const entry = tokenCache.get(providerId);
22
22
  if (!entry)
23
23
  return null;
24
- if (isRevoked(entry.sessionId)) {
24
+ if (isSessionRevoked(entry.sessionId)) {
25
25
  tokenCache.delete(providerId);
26
26
  return null;
27
27
  }
@@ -51,7 +51,7 @@ export function tryRefreshCachedToken(providerId, refresher) {
51
51
  const entry = tokenCache.get(providerId);
52
52
  if (!entry)
53
53
  return null;
54
- if (isRevoked(entry.sessionId)) {
54
+ if (isSessionRevoked(entry.sessionId)) {
55
55
  tokenCache.delete(providerId);
56
56
  return null;
57
57
  }
@@ -99,11 +99,6 @@ export function clearRevocations() {
99
99
  export function getRevocationCount() {
100
100
  return revokedSessions.size;
101
101
  }
102
- /** Check if a session ID has been revoked, pruning expired entries first. */
103
- function isRevoked(sessionId) {
104
- pruneRevocations();
105
- return revokedSessions.has(sessionId);
106
- }
107
102
  /** Remove expired revocation entries and enforce max size. */
108
103
  function pruneRevocations() {
109
104
  if (revokedSessions.size === 0)
@@ -65,10 +65,9 @@ export function refreshToken(oldToken, providerId) {
65
65
  */
66
66
  export function verifyBearerToken(token, providerId) {
67
67
  const result = verifyTokenWithResult(token, { providerId });
68
- if (!result.valid)
68
+ if (!result.valid || !result.payload)
69
69
  return result;
70
- // Reject if the session was explicitly revoked (check token's jti, not cache)
71
- if (result.payload && isSessionRevoked(result.payload.jti)) {
70
+ if (isSessionRevoked(result.payload.jti)) {
72
71
  return { valid: false, reason: "revoked" };
73
72
  }
74
73
  return result;
@@ -100,11 +99,15 @@ function tryPeekAndRevoke(providerId) {
100
99
  * reducing false positives from unrelated 401/403 responses.
101
100
  */
102
101
  export function isJWTAuthError(err) {
103
- const msg = err instanceof Error ? err.message
104
- : (err !== null && typeof err === "object" && "message" in err && typeof err.message === "string")
102
+ const msg = err instanceof Error
103
+ ? err.message
104
+ : err && typeof err === "object" && "message" in err && typeof err.message === "string"
105
105
  ? err.message
106
106
  : String(err);
107
107
  const lower = msg.toLowerCase();
108
+ if (lower.includes("bearer") && lower.includes("token") && lower.includes("invalid")) {
109
+ return true;
110
+ }
108
111
  // JWT-specific indicators (high confidence)
109
112
  const jwtIndicators = [
110
113
  "token expired", "invalid_token", "jwt", "signature",
@@ -88,6 +88,8 @@ export interface AgentState {
88
88
  peakContextTokens?: number;
89
89
  /** Resolved model this agent is running (task override or swarm default). */
90
90
  model?: string;
91
+ /** Unix timestamp (ms) of the last assistant stream content (text, tool deltas, etc.). Used to detect SDK streams that yield no content. */
92
+ lastContentTimestamp?: number;
91
93
  }
92
94
  /** A timestamped log line from an agent's execution. */
93
95
  export interface LogEntry {
@@ -173,8 +175,13 @@ export interface WaveSummary {
173
175
  status: string;
174
176
  type?: string;
175
177
  filesChanged?: number;
178
+ toolCalls?: number;
176
179
  error?: string;
177
180
  }[];
181
+ /** Sum of `toolCalls` across all agents in this wave (diagnostics). */
182
+ totalToolCalls?: number;
183
+ /** Non-heal tasks landed 0 files but agents invoked tools — possible worktree/merge bug. */
184
+ suspectedInfraFailure?: boolean;
178
185
  }
179
186
  /** Result from the steering function. */
180
187
  export interface SteerResult {
@@ -11,5 +11,9 @@ export interface CoachContext {
11
11
  log?: PlannerLog;
12
12
  coachModel?: string;
13
13
  coachProvider?: ProviderConfig;
14
+ /** Full markdown plan content (e.g. from a .md plan file). Overrides URL fetching. */
15
+ planContent?: string;
16
+ /** When true, show only accept/skip and do not persist user settings. */
17
+ confirmOnly?: boolean;
14
18
  }
15
19
  export declare function runSetupCoach(rawObjective: string, cwd: string, ctx: CoachContext): Promise<CoachResult | null>;
@@ -47,13 +47,15 @@ export async function runSetupCoach(rawObjective, cwd, ctx) {
47
47
  const facts = collectRepoFacts(cwd);
48
48
  if (facts.srcFileCount > 1_000_000)
49
49
  return null;
50
- const urls = rawObjective.match(URL_REGEX) ?? [];
51
- let planContent = null;
52
- if (urls.length > 0) {
53
- const results = await Promise.all(urls.map(u => fetchUrlContent(u, 4_000)));
54
- const fetched = results.filter(Boolean);
55
- if (fetched.length > 0) {
56
- planContent = fetched.map((c, i) => `[URL ${i + 1}: ${urls[i]}]\n${c}`).join("\n\n---\n\n");
50
+ let planContent = ctx.planContent ?? null;
51
+ if (!planContent) {
52
+ const urls = rawObjective.match(URL_REGEX) ?? [];
53
+ if (urls.length > 0) {
54
+ const results = await Promise.all(urls.map(u => fetchUrlContent(u, 4_000)));
55
+ const fetched = results.filter(Boolean);
56
+ if (fetched.length > 0) {
57
+ planContent = fetched.map((c, i) => `[URL ${i + 1}: ${urls[i]}]\n${c}`).join("\n\n---\n\n");
58
+ }
57
59
  }
58
60
  }
59
61
  const userMessage = renderRepoFacts(facts, rawObjective, ctx.providers, ctx.cliFlags, planContent);
@@ -120,14 +122,20 @@ export async function runSetupCoach(rawObjective, cwd, ctx) {
120
122
  return null;
121
123
  }
122
124
  renderCoachBlock(result, elapsedMs, model);
123
- const choice = await selectKey("", [
124
- { key: "y", desc: " accept" },
125
- { key: "e", desc: "dit objective" },
126
- { key: "s", desc: "kip coach" },
127
- { key: "x", desc: " skip coach forever" },
128
- ]);
125
+ const choice = ctx.confirmOnly
126
+ ? await selectKey("", [
127
+ { key: "y", desc: " accept" },
128
+ { key: "s", desc: "kip" },
129
+ ])
130
+ : await selectKey("", [
131
+ { key: "y", desc: " accept" },
132
+ { key: "e", desc: "dit objective" },
133
+ { key: "s", desc: "kip coach" },
134
+ { key: "x", desc: " skip coach forever" },
135
+ ]);
129
136
  if (choice === "y") {
130
- saveUserSettings({ ...loadUserSettings(), lastCoachedAt: Date.now() });
137
+ if (!ctx.confirmOnly)
138
+ saveUserSettings({ ...loadUserSettings(), lastCoachedAt: Date.now() });
131
139
  return result;
132
140
  }
133
141
  if (choice === "e") {
@@ -3,7 +3,7 @@ import { NudgeError, extractToolTarget, sumUsageTokens } from "../core/types.js"
3
3
  import { writeTranscriptEvent } from "../core/transcripts.js";
4
4
  import { getTurn, updateTurn } from "../core/turns.js";
5
5
  import { isRateLimitError, throttlePlanner, addPlannerCost, recordPeakContext, resetPlannerRateLimit, setContextTokens, applyRateLimitEvent, getPlannerRateLimitInfo, } from "./throttle.js";
6
- import { cursorProxyRateLimiter, sdkQueryRateLimiter, apiEndpointLimiter } from "../core/rate-limiter.js";
6
+ import { cursorProxyRateLimiter, sdkQueryRateLimiter, apiEndpointLimiter, acquireSdkQueryRateLimit } from "../core/rate-limiter.js";
7
7
  export { getTotalPlannerCost, getPeakPlannerContext, getPlannerRateLimitInfo, } from "./throttle.js";
8
8
  export { attemptJsonParse, extractTaskJson } from "./json.js";
9
9
  export { postProcess } from "./postprocess.js";
@@ -126,7 +126,7 @@ async function runPlannerQueryOnce(prompt, opts, onLog) {
126
126
  promptBytes: prompt.length,
127
127
  });
128
128
  }
129
- await rl.waitIfNeeded();
129
+ await acquireSdkQueryRateLimit();
130
130
  const pq = query({
131
131
  prompt,
132
132
  options: {
@@ -11,7 +11,7 @@ import { DEFAULT_MODEL } from "../core/models.js";
11
11
  import { isCursorProxyProvider, resolveCursorAgentToken, cachedAgentPaths, } from "./cursor-env.js";
12
12
  import { preflightCursorProxyViaHttp } from "./cursor-proxy.js";
13
13
  import { pickCursorModel } from "./cursor-picker.js";
14
- import { sdkQueryRateLimiter } from "../core/rate-limiter.js";
14
+ import { sdkQueryRateLimiter, acquireSdkQueryRateLimit } from "../core/rate-limiter.js";
15
15
  // Re-export Cursor utilities so callers can keep a single import point.
16
16
  export { PROXY_DEFAULT_URL, isCursorProxyProvider, bundledComposerProxyShellCommand, readCursorProxyLogTail, warnMacCursorAgentShellPatchIfNeeded, hasCursorAgentToken, getCursorAgentToken, } from "./cursor-env.js";
17
17
  export { healthCheckCursorProxy, ensureCursorProxyRunning } from "./cursor-proxy.js";
@@ -243,7 +243,7 @@ export async function preflightProvider(p, cwd, timeoutMs = 20_000, opts) {
243
243
  let pq;
244
244
  const rl = sdkQueryRateLimiter;
245
245
  try {
246
- await rl.waitIfNeeded();
246
+ await acquireSdkQueryRateLimit();
247
247
  pq = query({
248
248
  prompt: "Reply with exactly the word ok and nothing else.",
249
249
  options: {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Autonomous circuit breaker: halt after consecutive waves where no non-heal
3
+ * task landed merged file changes *and* no agent used tools (true idle).
4
+ *
5
+ * If agents used tools but files stayed at 0, treat as possible worktree/merge
6
+ * infrastructure issue — do not advance the halt streak.
7
+ */
8
+ export declare function updateCircuitBreakerStreak(args: {
9
+ waveNum: number;
10
+ prevStreak: number;
11
+ nonHealFiles: number;
12
+ totalToolCallsAllAgents: number;
13
+ }): {
14
+ streak: number;
15
+ shouldHalt: boolean;
16
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Autonomous circuit breaker: halt after consecutive waves where no non-heal
3
+ * task landed merged file changes *and* no agent used tools (true idle).
4
+ *
5
+ * If agents used tools but files stayed at 0, treat as possible worktree/merge
6
+ * infrastructure issue — do not advance the halt streak.
7
+ */
8
+ export function updateCircuitBreakerStreak(args) {
9
+ const { waveNum, prevStreak, nonHealFiles, totalToolCallsAllAgents } = args;
10
+ if (waveNum <= 0 || nonHealFiles > 0) {
11
+ return { streak: 0, shouldHalt: false };
12
+ }
13
+ if (totalToolCallsAllAgents > 0) {
14
+ return { streak: 0, shouldHalt: false };
15
+ }
16
+ const streak = prevStreak + 1;
17
+ return { streak, shouldHalt: streak >= 2 };
18
+ }
@@ -15,6 +15,7 @@ import { getModelCapability } from "../core/models.js";
15
15
  import { isJWTAuthError } from "../core/auth.js";
16
16
  import { saveRunState, saveWaveSession, } from "../state/state.js";
17
17
  import { throttleBeforeWave } from "./throttle.js";
18
+ import { updateCircuitBreakerStreak } from "./circuit-breaker-state.js";
18
19
  import { checkProjectHealth } from "./health.js";
19
20
  import { runPostWaveReview } from "./review.js";
20
21
  import { promptBudgetExtension } from "./budget.js";
@@ -159,18 +160,27 @@ export async function runWaveLoop(host, ctx) {
159
160
  host.currentTasks = neverStarted;
160
161
  // ── Overlay merge outcomes into wave history ──
161
162
  const failedMergeBranches = new Set(swarm.mergeResults.filter(r => !r.ok).map(r => r.branch));
163
+ const tasks = swarm.agents.map(a => {
164
+ const mergeFailed = a.branch && failedMergeBranches.has(a.branch);
165
+ return {
166
+ prompt: a.task.prompt,
167
+ status: a.status,
168
+ type: a.task.type,
169
+ filesChanged: mergeFailed ? 0 : a.filesChanged,
170
+ toolCalls: a.toolCalls,
171
+ error: mergeFailed ? `merge-failed: branch ${a.branch} did not land` : a.error,
172
+ };
173
+ });
174
+ const nonHealTasks = tasks.filter(t => t.type !== "heal");
175
+ const nonHealFiles = nonHealTasks.reduce((sum, t) => sum + (t.filesChanged ?? 0), 0);
176
+ const nonHealToolCalls = nonHealTasks.reduce((sum, t) => sum + (t.toolCalls ?? 0), 0);
177
+ const totalToolCalls = swarm.agents.reduce((sum, a) => sum + a.toolCalls, 0);
178
+ const suspectedInfraFailure = host.waveNum > 0 && nonHealFiles === 0 && nonHealToolCalls > 0;
162
179
  host.waveHistory.push({
163
180
  wave: host.waveNum,
164
- tasks: swarm.agents.map(a => {
165
- const mergeFailed = a.branch && failedMergeBranches.has(a.branch);
166
- return {
167
- prompt: a.task.prompt,
168
- status: a.status,
169
- type: a.task.type,
170
- filesChanged: mergeFailed ? 0 : a.filesChanged,
171
- error: mergeFailed ? `merge-failed: branch ${a.branch} did not land` : a.error,
172
- };
173
- }),
181
+ tasks,
182
+ totalToolCalls,
183
+ suspectedInfraFailure,
174
184
  });
175
185
  // ── Heal fail streak ──
176
186
  const lastWave = host.waveHistory[host.waveHistory.length - 1];
@@ -182,21 +192,26 @@ export async function runWaveLoop(host, ctx) {
182
192
  healFailStreak = 0;
183
193
  }
184
194
  // ── Circuit breaker ──
185
- const nonHealFiles = lastWave?.tasks.filter(t => t.type !== "heal").reduce((sum, t) => sum + (t.filesChanged ?? 0), 0) ?? 0;
186
- if (nonHealFiles === 0 && host.waveNum > 0) {
187
- zeroFileWaves++;
188
- if (zeroFileWaves >= 2) {
189
- ctx.display.appendSteeringEvent(`Circuit breaker: 2 consecutive waves produced no merged changes — halting to prevent budget drain`);
190
- ctx.display.stop();
191
- saveRunState(ctx.runDir, buildRunState(host, "stopped", []));
192
- ctx.display.stop();
193
- console.log(chalk.red(`\n Circuit breaker: 2 consecutive waves produced no merged changes.`));
194
- console.log(chalk.red(` Halting to prevent budget drain. Run preserved at ${ctx.runDir}.`));
195
- process.exit(3);
196
- }
195
+ const { streak: nextZeroFileWaves, shouldHalt: circuitHalt } = updateCircuitBreakerStreak({
196
+ waveNum: host.waveNum,
197
+ prevStreak: zeroFileWaves,
198
+ nonHealFiles,
199
+ totalToolCallsAllAgents: totalToolCalls,
200
+ });
201
+ if (host.waveNum > 0 && nonHealFiles === 0 && nonHealToolCalls > 0) {
202
+ const msg = "[circuit-breaker] Agents completed with tool calls but 0 files landed — possible worktree/merge bug, NOT a stuck agent. Continuing run.";
203
+ console.warn(chalk.yellow(`\n ${msg}`));
204
+ ctx.display.appendSteeringEvent(msg);
197
205
  }
198
- else {
199
- zeroFileWaves = 0;
206
+ zeroFileWaves = nextZeroFileWaves;
207
+ if (circuitHalt) {
208
+ ctx.display.appendSteeringEvent(`Circuit breaker: 2 consecutive waves produced no merged changes — halting to prevent budget drain`);
209
+ ctx.display.stop();
210
+ saveRunState(ctx.runDir, buildRunState(host, "stopped", []));
211
+ ctx.display.stop();
212
+ console.log(chalk.red(`\n Circuit breaker: 2 consecutive waves produced no merged changes.`));
213
+ console.log(chalk.red(` Halting to prevent budget drain. Run preserved at ${ctx.runDir}.`));
214
+ process.exit(3);
200
215
  }
201
216
  // ── Hook-blocked work ──
202
217
  const hookBlocked = swarm.agents.filter(a => swarm.logs.some(l => l.agentId === a.id && l.text.includes("did NOT land")));
@@ -13,10 +13,10 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
13
13
  import { NudgeError } from "../core/types.js";
14
14
  import { gitExec, autoCommit } from "./merge.js";
15
15
  import { createTurn, beginTurn, endTurn, updateTurn } from "../core/turns.js";
16
- import { SIMPLIFY_PROMPT, withCursorWorkspaceHeader } from "./config.js";
17
- import { AgentTimeoutError, isRateLimitError, isTransientError, sleep } from "./errors.js";
18
- import { handleMsg } from "./message-handler.js";
19
- import { sdkQueryRateLimiter } from "../core/rate-limiter.js";
16
+ import { SIMPLIFY_PROMPT, withCursorWorkspaceHeader, getAgentTimeout } from "./config.js";
17
+ import { AgentTimeoutError, StreamStalledError, isRateLimitError, isTransientError, sleep } from "./errors.js";
18
+ import { handleMsg, checkStreamHealth, NO_CONTENT_TIMEOUT_MS } from "./message-handler.js";
19
+ import { sdkQueryRateLimiter, acquireSdkQueryRateLimit } from "../core/rate-limiter.js";
20
20
  export async function runAgent(host, task) {
21
21
  // Guard: if pause was triggered between dispatch and here, re-queue immediately.
22
22
  // The worker already shifted this task, so unshift puts it back for resume.
@@ -87,7 +87,7 @@ export async function runAgent(host, task) {
87
87
  const isResumed = !!task.resumeSessionId;
88
88
  host.log(id, isResumed ? `Resuming: ${task.prompt.slice(0, 60)}` : `Starting: ${task.prompt.slice(0, 60)}`);
89
89
  const maxRetries = host.config.maxRetries ?? 2;
90
- const inactivityMs = host.config.agentTimeoutMs ?? 15 * 60 * 1000;
90
+ const inactivityMs = host.config.agentTimeoutMs ?? getAgentTimeout();
91
91
  const rl = sdkQueryRateLimiter;
92
92
  // Hoisted so the catch block can read the session captured during the turn
93
93
  // when routing a pause-interrupt through the requeue path.
@@ -115,7 +115,7 @@ export async function runAgent(host, task) {
115
115
  // Read host.model (live — setModel updates it between waves), not host.config.model (frozen at construction).
116
116
  const effectiveModel = task.model || host.model;
117
117
  const envOverride = withCursorWorkspaceHeader(host.config.envForModel?.(effectiveModel), agentCwd);
118
- await rl.waitIfNeeded();
118
+ await acquireSdkQueryRateLimit();
119
119
  const agentQuery = query({
120
120
  prompt: agentPrompt,
121
121
  options: {
@@ -129,19 +129,29 @@ export async function runAgent(host, task) {
129
129
  const timeoutMs = isResume ? inactivityMs * 2 : inactivityMs;
130
130
  let sessionId;
131
131
  let lastActivity = Date.now();
132
+ agent.lastContentTimestamp = Date.now();
132
133
  let timer;
133
134
  const watchdog = new Promise((_, reject) => {
134
135
  const check = () => {
136
+ const lastContent = agent.lastContentTimestamp;
137
+ if (lastContent != null && !checkStreamHealth(lastContent, NO_CONTENT_TIMEOUT_MS)) {
138
+ const elapsed = Date.now() - lastContent;
139
+ agentQuery.interrupt().catch(() => agentQuery.close());
140
+ reject(new StreamStalledError(elapsed, NO_CONTENT_TIMEOUT_MS));
141
+ return;
142
+ }
135
143
  const silent = Date.now() - lastActivity;
136
144
  if (silent >= timeoutMs) {
137
145
  agentQuery.interrupt().catch(() => agentQuery.close());
138
146
  reject(isResume ? new AgentTimeoutError(silent) : new NudgeError(sessionId, silent));
139
147
  }
140
148
  else {
141
- timer = setTimeout(check, Math.min(30_000, timeoutMs - silent + 1000));
149
+ const gap = lastContent != null ? Date.now() - lastContent : 0;
150
+ const untilNoContent = lastContent != null ? Math.max(250, NO_CONTENT_TIMEOUT_MS - gap + 50) : Number.POSITIVE_INFINITY;
151
+ timer = setTimeout(check, Math.min(30_000, timeoutMs - silent + 1000, 5_000, untilNoContent));
142
152
  }
143
153
  };
144
- timer = setTimeout(check, timeoutMs);
154
+ timer = setTimeout(check, Math.min(5_000, timeoutMs));
145
155
  });
146
156
  host.activeQueries.add(agentQuery);
147
157
  // Guard: if pause was triggered between runAgent check and here, close the query
@@ -403,7 +413,7 @@ Respond with JSON: {"keep": true/false, "reason": "brief explanation"}`;
403
413
  const rl = sdkQueryRateLimiter;
404
414
  let eq;
405
415
  try {
406
- await rl.waitIfNeeded();
416
+ await acquireSdkQueryRateLimit();
407
417
  eq = query({
408
418
  prompt,
409
419
  options: {
@@ -29,3 +29,10 @@ export declare const SIMPLIFY_PROMPT = "You just finished your task. Review and
29
29
  * all worktree paths) so the header value passes the safety check.
30
30
  */
31
31
  export declare function withCursorWorkspaceHeader(env: Record<string, string> | undefined, cwd: string): Record<string, string> | undefined;
32
+ /** Default per-agent inactivity watchdog (see `agent-run` race with SDK `query`). */
33
+ export declare const DEFAULT_AGENT_TIMEOUT_MS: number;
34
+ /**
35
+ * Per-agent watchdog timeout in ms. Override with `AGENT_TIMEOUT_MS` (integer).
36
+ * Explicit `SwarmConfig.agentTimeoutMs` still wins at call sites that pass it.
37
+ */
38
+ export declare function getAgentTimeout(): number;
@@ -33,3 +33,18 @@ export function withCursorWorkspaceHeader(env, cwd) {
33
33
  : hdr,
34
34
  };
35
35
  }
36
+ /** Default per-agent inactivity watchdog (see `agent-run` race with SDK `query`). */
37
+ export const DEFAULT_AGENT_TIMEOUT_MS = 15 * 60 * 1000;
38
+ /**
39
+ * Per-agent watchdog timeout in ms. Override with `AGENT_TIMEOUT_MS` (integer).
40
+ * Explicit `SwarmConfig.agentTimeoutMs` still wins at call sites that pass it.
41
+ */
42
+ export function getAgentTimeout() {
43
+ const raw = process.env.AGENT_TIMEOUT_MS;
44
+ if (raw == null || raw.trim() === "")
45
+ return DEFAULT_AGENT_TIMEOUT_MS;
46
+ const n = Number.parseInt(raw, 10);
47
+ if (!Number.isFinite(n) || n <= 0)
48
+ return DEFAULT_AGENT_TIMEOUT_MS;
49
+ return n;
50
+ }
@@ -1,6 +1,13 @@
1
1
  export declare class AgentTimeoutError extends Error {
2
2
  constructor(silentMs: number);
3
3
  }
4
+ /** Thrown when the SDK query stream stops emitting assistant content for too long while still open. */
5
+ export declare class StreamStalledError extends Error {
6
+ readonly elapsed: number;
7
+ readonly timeoutMs: number;
8
+ constructor(elapsed: number, timeoutMs: number);
9
+ }
10
+ export declare function isStreamStalledError(err: unknown): err is StreamStalledError;
4
11
  export declare function isRateLimitError(err: unknown): boolean;
5
12
  export declare function isTransientError(err: unknown): boolean;
6
13
  export declare function sleep(ms: number): Promise<void>;
@@ -7,6 +7,20 @@ export class AgentTimeoutError extends Error {
7
7
  this.name = "AgentTimeoutError";
8
8
  }
9
9
  }
10
+ /** Thrown when the SDK query stream stops emitting assistant content for too long while still open. */
11
+ export class StreamStalledError extends Error {
12
+ elapsed;
13
+ timeoutMs;
14
+ constructor(elapsed, timeoutMs) {
15
+ super(`Stream stalled: no content for ${timeoutMs}ms (last gap ${Math.round(elapsed)}ms)`);
16
+ this.elapsed = elapsed;
17
+ this.timeoutMs = timeoutMs;
18
+ this.name = "StreamStalledError";
19
+ }
20
+ }
21
+ export function isStreamStalledError(err) {
22
+ return err instanceof StreamStalledError;
23
+ }
10
24
  export function isRateLimitError(err) {
11
25
  const status = err?.status ?? err?.statusCode;
12
26
  if (status === 429)
@@ -20,7 +34,7 @@ export function isRateLimitError(err) {
20
34
  return false;
21
35
  }
22
36
  export function isTransientError(err) {
23
- if (err instanceof AgentTimeoutError)
37
+ if (err instanceof AgentTimeoutError || err instanceof StreamStalledError)
24
38
  return false;
25
39
  const msg = String(err?.message || err).toLowerCase();
26
40
  const status = err?.status ?? err?.statusCode;
@@ -1,5 +1,9 @@
1
1
  import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
2
2
  import type { AgentState, AITurn, RateLimitWindow } from "../core/types.js";
3
+ /** Default: no assistant content for this long means the SDK stream is stuck. */
4
+ export declare const NO_CONTENT_TIMEOUT_MS = 15000;
5
+ /** @returns false if no stream content has arrived within {@link timeoutMs} of {@link lastContentTimestamp}. */
6
+ export declare function checkStreamHealth(lastContentTimestamp: number, timeoutMs: number): boolean;
3
7
  /** Per-agent pending tool_use block while we wait for the delta stream to
4
8
  * finish materializing the real `input`. */
5
9
  export interface PendingTool {
@@ -11,6 +11,15 @@
11
11
  import { RATE_LIMIT_WINDOW_SHORT, extractToolTarget, sumUsageTokens } from "../core/types.js";
12
12
  import { getModelCapability } from "../core/models.js";
13
13
  import { updateTurn } from "../core/turns.js";
14
+ /** Default: no assistant content for this long means the SDK stream is stuck. */
15
+ export const NO_CONTENT_TIMEOUT_MS = 15_000;
16
+ /** @returns false if no stream content has arrived within {@link timeoutMs} of {@link lastContentTimestamp}. */
17
+ export function checkStreamHealth(lastContentTimestamp, timeoutMs) {
18
+ return Date.now() - lastContentTimestamp <= timeoutMs;
19
+ }
20
+ function markStreamContent(agent) {
21
+ agent.lastContentTimestamp = Date.now();
22
+ }
14
23
  /** Log a tool invocation with a short target extracted from its input. */
15
24
  export function logToolUse(host, agent, name, input) {
16
25
  const target = extractToolTarget(input);
@@ -49,6 +58,10 @@ export function handleMsg(host, agent, msg) {
49
58
  if (!m.message?.content)
50
59
  break;
51
60
  for (const block of m.message.content) {
61
+ const bt = block.type;
62
+ if (bt === "text" || bt === "tool_use" || bt === "thinking" || bt === "redacted_thinking") {
63
+ markStreamContent(agent);
64
+ }
52
65
  if (block.type === "text" && block.text) {
53
66
  const line = block.text.trim().split("\n")[0]?.slice(0, 80);
54
67
  if (line)
@@ -63,6 +76,7 @@ export function handleMsg(host, agent, msg) {
63
76
  if (ev.type === "content_block_start") {
64
77
  const cb = ev.content_block;
65
78
  if (cb?.type === "tool_use") {
79
+ markStreamContent(agent);
66
80
  agent.currentTool = cb.name;
67
81
  agent.toolCalls++;
68
82
  const input = (cb.input ?? {});
@@ -72,13 +86,18 @@ export function handleMsg(host, agent, msg) {
72
86
  logToolUse(host, agent, cb.name, input);
73
87
  }
74
88
  else if (cb?.type === "thinking" || cb?.type === "redacted_thinking") {
89
+ markStreamContent(agent);
75
90
  agent.lastText = "thinking…";
76
91
  }
92
+ else if (cb?.type === "text") {
93
+ markStreamContent(agent);
94
+ }
77
95
  }
78
96
  else if (ev.type === "content_block_delta") {
79
97
  const delta = ev.delta;
80
98
  const pending = host.pendingTools.get(agent);
81
99
  if (delta?.type === "input_json_delta" && pending && typeof delta.partial_json === "string") {
100
+ markStreamContent(agent);
82
101
  pending.buf += delta.partial_json;
83
102
  break;
84
103
  }
@@ -87,6 +106,7 @@ export function handleMsg(host, agent, msg) {
87
106
  : delta?.type === "thinking_delta" ? delta.thinking
88
107
  : undefined;
89
108
  if (typeof raw === "string") {
109
+ markStreamContent(agent);
90
110
  const t = raw.trim();
91
111
  if (t)
92
112
  agent.lastText = t.slice(-80);
@@ -5,6 +5,7 @@ import chalk from "chalk";
5
5
  import { RATE_LIMIT_WINDOW_SHORT } from "../core/types.js";
6
6
  import { gitExec, mergeAllBranches, warnDirtyTree, cleanStaleWorktrees, writeSwarmLog } from "./merge.js";
7
7
  import { ensureCursorProxyRunning, PROXY_DEFAULT_URL } from "../providers/index.js";
8
+ import { getAgentTimeout } from "./config.js";
8
9
  import { sleep } from "./errors.js";
9
10
  import { runAgent as runAgentImpl, buildErroredBranchEvaluator } from "./agent-run.js";
10
11
  export class Swarm {
@@ -315,6 +316,8 @@ export class Swarm {
315
316
  const task = this.queue.shift();
316
317
  if (!task)
317
318
  break;
319
+ const watchdogMs = this.config.agentTimeoutMs ?? getAgentTimeout();
320
+ this.log(-1, `[swarm] Agent watchdog timeout: ${Math.round(watchdogMs / 1000)}s`);
318
321
  try {
319
322
  await this.runAgent(task);
320
323
  }
package/dist/ui/footer.js CHANGED
@@ -27,8 +27,10 @@ function layoutActions(rendered, pendingChip, termW) {
27
27
  }
28
28
  export function Footer({ state, toast }) {
29
29
  const inOverlay = state.input.mode;
30
+ // When an input overlay is active the InputPrompt box renders its own hint
31
+ // line, so the footer stays quiet — just a spacer to keep the layout stable.
30
32
  if (inOverlay !== "none") {
31
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: " " }), _jsx(Text, { children: chalk.dim(" Enter submit \u00b7 Esc cancel") })] }));
33
+ return _jsx(Text, { children: " " });
32
34
  }
33
35
  const actions = deriveFooter(state);
34
36
  const pending = state.runInfo.pendingSteer ?? 0;
package/dist/ui/header.js CHANGED
@@ -1,9 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Text, Box } from "ink";
3
3
  import chalk from "chalk";
4
- import { fmtDur, fmtTokens } from "./primitives.js";
4
+ import { fmtDur, fmtTokens, visibleLen } from "./primitives.js";
5
5
  import { UsageBars, SteeringBars } from "./bars.js";
6
- const HEADER_BAR_W = 30;
6
+ function terminalWidth() { return Math.max((process.stdout.columns ?? 80) || 80, 60); }
7
+ // Scales with terminal width so narrow panes get a short bar and wide ones
8
+ // don't waste the right half of the screen.
9
+ function headerBarWidth(termW) {
10
+ if (termW < 90)
11
+ return 16;
12
+ if (termW < 120)
13
+ return 24;
14
+ return 30;
15
+ }
7
16
  function runPhaseLabel(swarm) {
8
17
  const allDone = swarm.agents.length > 0 && swarm.agents.every(a => a.status !== "running");
9
18
  const doneTag = allDone && !swarm.aborted ? chalk.green("COMPLETE") : "";
@@ -16,6 +25,9 @@ function runPhaseLabel(swarm) {
16
25
  return [phaseLabel, doneTag, pausedTag, stallTag, stoppingTag].filter(Boolean).join(" ");
17
26
  }
18
27
  export function Header({ phase, runInfo, swarm, rlGetter, selectedAgentId }) {
28
+ const termW = terminalWidth();
29
+ const barW = headerBarWidth(termW);
30
+ const narrow = termW < 90;
19
31
  const model = runInfo.model ?? swarm?.model;
20
32
  const modelTag = model ? chalk.dim(` [${model}]`) : "";
21
33
  let phaseTag = "";
@@ -42,24 +54,38 @@ export function Header({ phase, runInfo, swarm, rlGetter, selectedAgentId }) {
42
54
  barPct = runInfo.sessionsBudget > 0 ? sessionsUsed / runInfo.sessionsBudget : 0;
43
55
  barLabel = `${sessionsUsed}/${runInfo.sessionsBudget}`;
44
56
  }
45
- const filled = Math.round(barPct * HEADER_BAR_W);
46
- const bar = chalk.green("\u2588".repeat(filled)) + chalk.gray("\u2591".repeat(HEADER_BAR_W - filled));
57
+ const filled = Math.round(barPct * barW);
58
+ const bar = chalk.green("\u2588".repeat(filled)) + chalk.gray("\u2591".repeat(barW - filled));
47
59
  const working = Math.max(0, active - blocked);
48
60
  const stuck = blocked > 0 && working === 0;
49
61
  const activeChip = active > 0
50
62
  ? (stuck ? chalk.yellow(`${active} blocked`) : chalk.cyan(`${working} active`) + (blocked > 0 ? chalk.yellow(` (${blocked} blocked)`) : ""))
51
63
  : "";
52
- const topLine = ` ${chalk.bold.white("CLAUDE OVERNIGHT")}${modelTag}${phaseTag ? " " + phaseTag : ""} ${bar} ${barLabel} ` +
53
- (activeChip ? activeChip + " " : "") +
54
- (queued > 0 ? chalk.gray(`${queued} queued`) + " " : "") +
55
- chalk.gray(`\u23F1 ${fmtDur(Date.now() - runInfo.startedAt)}`);
64
+ const elapsed = chalk.gray(`\u23F1 ${fmtDur(Date.now() - runInfo.startedAt)}`);
65
+ const queuedChip = queued > 0 ? chalk.gray(`${queued} queued`) : "";
66
+ // Top line: brand · phase · bar · counters · elapsed. Narrow terminals drop
67
+ // the brand and model so the live indicators stay on one row.
68
+ const brand = narrow ? "" : chalk.bold.white("CLAUDE OVERNIGHT") + modelTag;
69
+ const progress = barLabel ? bar + " " + barLabel : bar;
70
+ const topParts = [
71
+ brand,
72
+ phaseTag,
73
+ progress,
74
+ activeChip,
75
+ queuedChip,
76
+ elapsed,
77
+ ].filter(Boolean);
78
+ const topLine = " " + topParts.join(" ");
56
79
  const tokIn = fmtTokens(totalIn);
57
80
  const tokOut = fmtTokens(totalOut);
58
81
  const costStr = totalCost > 0 ? chalk.yellow(`$${totalCost.toFixed(2)}`) : "";
59
- const waveLabel = runInfo.waveNum >= 0 ? `wave ${runInfo.waveNum + 1} \u00b7 ` : "";
60
- const sessionStr = chalk.dim(` ${waveLabel}`) +
61
- chalk.white(`${sessionsUsed}/${runInfo.sessionsBudget}`) +
82
+ const waveLabel = runInfo.waveNum >= 0 ? `wave ${runInfo.waveNum + 1}` : "";
83
+ const tokens = chalk.gray(`\u2191 ${tokIn} in \u2193 ${tokOut} out`);
84
+ const sessions = chalk.white(`${sessionsUsed}/${runInfo.sessionsBudget}`) +
62
85
  chalk.dim(` sessions \u00b7 ${runInfo.remaining} left`);
63
- const bottomLine = chalk.gray(` \u2191 ${tokIn} in \u2193 ${tokOut} out`) + (costStr ? ` ${costStr}` : "") + sessionStr;
86
+ const bottomLeftParts = [tokens, costStr].filter(Boolean).join(" ");
87
+ const bottomRightParts = [waveLabel ? chalk.dim(waveLabel) : "", sessions].filter(Boolean).join(chalk.dim(" \u00b7 "));
88
+ const gap = Math.max(2, termW - visibleLen(bottomLeftParts) - visibleLen(bottomRightParts) - 4);
89
+ const bottomLine = " " + bottomLeftParts + " ".repeat(gap) + bottomRightParts;
64
90
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: " " }), _jsx(Text, { children: topLine }), _jsx(Text, { children: bottomLine }), phase === "run" && swarm ? _jsx(UsageBars, { swarm: swarm, selectedAgentId: selectedAgentId }) : null, phase === "steering" && rlGetter ? _jsx(SteeringBars, { rl: rlGetter() }) : null, _jsx(Text, { children: " " })] }));
65
91
  }
@@ -1,6 +1,13 @@
1
1
  import React from "react";
2
2
  import type { UiStore, HostCallbacks } from "./store.js";
3
3
  export declare const MAX_INPUT_LEN = 600;
4
+ 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. */
7
+ export declare function sanitizeTyped(raw: string): string;
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;
4
11
  interface Props {
5
12
  store: UiStore;
6
13
  callbacks: HostCallbacks;
package/dist/ui/input.js CHANGED
@@ -9,11 +9,34 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  // Text-entry overlays (steer, ask, settings) are *not* separate phases — they
10
10
  // capture typed chars, show a minimal hint in the footer (handled by
11
11
  // footer.tsx), and dispatch on Enter/Esc.
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.
12
18
  import { useState, useSyncExternalStore } from "react";
13
19
  import { Text, Box, useInput } from "ink";
14
20
  import chalk from "chalk";
21
+ import { visibleLen, wrap } from "./primitives.js";
15
22
  import { SETTINGS_FIELDS, SETTINGS_LABELS, NUMERIC_SETTINGS_FIELDS, applySettingEdit, readSettingValue, } from "./settings.js";
16
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`).
27
+ 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. */
30
+ export function sanitizeTyped(raw) {
31
+ return raw.replace(CONTROL_CHAR_RE, "");
32
+ }
33
+ /** 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
+ }
17
40
  export function InputLayer({ store, callbacks, onToast }) {
18
41
  const [buffer, setBuffer] = useState("");
19
42
  const [settingsField, setSettingsField] = useState(0);
@@ -24,7 +47,14 @@ export function InputLayer({ store, callbacks, onToast }) {
24
47
  const lc = state.liveConfig;
25
48
  // ── Text-entry modes ──
26
49
  if (mode !== "none") {
27
- // Esc bails
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)
56
+ return;
57
+ // Esc bails (loses the buffer — always intentional).
28
58
  if (key.escape) {
29
59
  setBuffer("");
30
60
  setSettingsField(0);
@@ -59,25 +89,53 @@ export function InputLayer({ store, callbacks, onToast }) {
59
89
  store.patch({ input: { mode: "none", buffer: "", settingsField: 0 } });
60
90
  return;
61
91
  }
62
- if (key.tab && mode === "settings") {
63
- const field = SETTINGS_FIELDS[settingsField % SETTINGS_FIELDS.length];
64
- if (field === "pause" && swarm && lc) {
65
- const next = !swarm.paused;
66
- swarm.setPaused(next);
67
- lc.paused = next;
68
- lc.dirty = true;
69
- callbacks.settingsTick();
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;
70
108
  }
71
- const next = settingsField + 1;
72
- setBuffer("");
73
- if (next >= SETTINGS_FIELDS.length) {
74
- setSettingsField(0);
75
- store.patch({ input: { mode: "none", buffer: "", settingsField: 0 } });
109
+ if (key.ctrl && raw === "w") {
110
+ const next = deleteWordBackward(buffer);
111
+ setBuffer(next);
112
+ store.patch({ input: { ...state.input, buffer: next } });
113
+ return;
76
114
  }
77
- else {
78
- setSettingsField(next);
79
- store.patch({ input: { mode: "settings", buffer: "", settingsField: next } });
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 } });
132
+ }
133
+ else {
134
+ setSettingsField(next);
135
+ store.patch({ input: { mode: "settings", buffer: "", settingsField: next } });
136
+ }
80
137
  }
138
+ // Tab in steer/ask modes is a no-op, not a submit or a "tab character".
81
139
  return;
82
140
  }
83
141
  if (key.backspace || key.delete) {
@@ -86,9 +144,11 @@ export function InputLayer({ store, callbacks, onToast }) {
86
144
  store.patch({ input: { ...state.input, buffer: next } });
87
145
  return;
88
146
  }
89
- // Typed char(s) — raw is the string for this event
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.
90
150
  if (raw && raw.length > 0) {
91
- let text = raw;
151
+ let text = sanitizeTyped(raw);
92
152
  if (mode === "settings") {
93
153
  const field = SETTINGS_FIELDS[settingsField % SETTINGS_FIELDS.length];
94
154
  if (NUMERIC_SETTINGS_FIELDS.has(field))
@@ -149,6 +209,10 @@ export function InputLayer({ store, callbacks, onToast }) {
149
209
  const code = raw.charCodeAt(0);
150
210
  if (code < 0x20 || code > 0x7E)
151
211
  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
+ if (key.ctrl || key.meta)
215
+ return;
152
216
  const toast = (msg) => onToast(msg);
153
217
  switch (raw.toLowerCase()) {
154
218
  case "?":
@@ -225,22 +289,58 @@ export function InputLayer({ store, callbacks, onToast }) {
225
289
  const state = useSyncExternalStore(store.subscribe, store.get, store.get);
226
290
  if (state.input.mode === "none")
227
291
  return null;
228
- // Caret pulses with the 1 Hz tick visible on even ticks, hidden on odd.
229
- const caret = state.tick % 2 === 0 ? chalk.cyan("\u2588") : " ";
230
- return _jsx(InputPrompt, { mode: state.input.mode, buffer: buffer, settingsField: settingsField, state: state, caret: caret });
292
+ const caretOn = state.tick % 2 === 0;
293
+ return (_jsx(InputPrompt, { mode: state.input.mode, buffer: buffer, settingsField: settingsField, state: state, caretOn: caretOn }));
231
294
  }
232
- function InputPrompt({ mode, buffer, settingsField, state, caret, }) {
295
+ function terminalWidth() { return Math.max((process.stdout.columns ?? 80) || 80, 60); }
296
+ function InputPrompt({ mode, buffer, settingsField, state, caretOn }) {
297
+ const termW = terminalWidth();
298
+ const boxW = Math.min(Math.max(44, termW - 6), 120);
299
+ const innerW = boxW - 6;
300
+ const accent = mode === "settings" ? chalk.yellow : mode === "steer" ? chalk.cyan : chalk.magenta;
301
+ const borderColor = mode === "settings" ? "yellow" : mode === "steer" ? "cyan" : "magenta";
302
+ const title = mode === "steer" ? "Steer next wave"
303
+ : mode === "ask" ? "Ask the planner"
304
+ : "Settings";
305
+ let subtitle;
306
+ let hint;
307
+ let currentLine = null;
308
+ let filteredBuffer = buffer;
233
309
  if (mode === "settings") {
234
310
  const total = SETTINGS_FIELDS.length;
235
311
  const field = SETTINGS_FIELDS[settingsField % total];
236
- const label = SETTINGS_LABELS[field];
312
+ subtitle = SETTINGS_LABELS[field];
237
313
  const current = readSettingValue(field, state.liveConfig, state.swarm);
238
- const hint = field === "pause"
239
- ? chalk.dim(` (Enter toggle, Tab skip, Esc exit)`)
240
- : chalk.dim(` [${settingsField + 1}/${total}] Tab next \u00b7 Esc exit \u00b7 current: ${chalk.white(current)}`);
241
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [" ", chalk.cyan("\u25C6"), " ", chalk.bold(label), hint] }), _jsxs(Text, { children: [" ", buffer, caret] })] }));
314
+ currentLine = chalk.dim(`current: ${chalk.white(current)}`);
315
+ hint = field === "pause"
316
+ ? chalk.dim(`Enter toggle \u00b7 Tab skip \u00b7 Esc exit`)
317
+ : chalk.dim(`[${settingsField + 1}/${total}] Enter save \u00b7 Tab skip \u00b7 Esc exit`);
318
+ }
319
+ else {
320
+ subtitle = mode === "steer"
321
+ ? "queued as the next wave's seed"
322
+ : "planner answers inline — you keep working";
323
+ const action = mode === "steer" ? "queue" : "send";
324
+ hint = chalk.dim(`Enter ${action} \u00b7 Esc cancel \u00b7 Ctrl+U clear \u00b7 Ctrl+W del word`);
242
325
  }
243
- const title = mode === "steer" ? "Inject next wave" : "Ask the planner";
244
- const action = mode === "steer" ? "queue" : "send";
245
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [" ", chalk.cyan(">"), " ", chalk.bold(title), " ", chalk.dim(`(Enter to ${action}, Esc to cancel)`)] }), _jsxs(Text, { children: [" ", buffer, caret] })] }));
326
+ // Word-wrap the buffer so long entries don't blow past the box edge.
327
+ const bufferLines = filteredBuffer.length === 0
328
+ ? [""]
329
+ : wrap(filteredBuffer, Math.max(20, innerW));
330
+ const caret = caretOn ? accent("\u2588") : " ";
331
+ const lastIdx = bufferLines.length - 1;
332
+ // Char counter — dims normally, warns at 80%, red at 95%.
333
+ const pct = buffer.length / MAX_INPUT_LEN;
334
+ const counter = buffer.length === 0 ? "" :
335
+ pct >= 0.95 ? chalk.red(`${buffer.length}/${MAX_INPUT_LEN}`)
336
+ : pct >= 0.8 ? chalk.yellow(`${buffer.length}/${MAX_INPUT_LEN}`)
337
+ : chalk.dim(`${buffer.length}/${MAX_INPUT_LEN}`);
338
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, borderStyle: "round", borderColor: borderColor, width: boxW, children: [_jsxs(Text, { children: [" ", accent("\u25C6"), " ", chalk.bold.white(title), " ", chalk.dim(subtitle)] }), currentLine ? _jsxs(Text, { children: [" ", currentLine] }) : null, bufferLines.map((ln, i) => {
339
+ const showCaret = i === lastIdx;
340
+ const pad = Math.max(0, innerW - visibleLen(ln));
341
+ const counterSuffix = showCaret && counter
342
+ ? " " + counter
343
+ : "";
344
+ return (_jsxs(Text, { children: [" ", accent("\u203A "), ln, showCaret ? caret : " ", " ".repeat(pad), counterSuffix] }, i));
345
+ }), _jsxs(Text, { children: [" ", hint] })] }));
246
346
  }
@@ -3,40 +3,52 @@ import { Text, Box } from "ink";
3
3
  import chalk from "chalk";
4
4
  import { wrap } from "./primitives.js";
5
5
  function terminalWidth() { return Math.max((process.stdout.columns ?? 80) || 80, 60); }
6
+ const ASK_BODY_LINES = 8;
7
+ const DEBRIEF_BODY_LINES = 6;
6
8
  export function Overlay({ ask, debrief }) {
7
9
  if (!ask && !debrief)
8
10
  return null;
9
11
  const w = terminalWidth();
10
- const maxW = Math.min(Math.max(40, w - 6), 120);
12
+ const boxW = Math.min(Math.max(44, w - 6), 120);
13
+ const innerW = boxW - 4;
11
14
  if (ask) {
12
- const title = ask.streaming ? chalk.cyan("Ask (streaming\u2026)") : ask.error ? chalk.red("Ask (error)") : chalk.bold.white("Ask");
13
- const qLines = wrap(ask.question, maxW - 6);
15
+ const title = ask.streaming ? chalk.cyan("Ask") + chalk.dim(" (streaming\u2026)")
16
+ : ask.error ? chalk.red("Ask") + chalk.dim(" (error)")
17
+ : chalk.bold.white("Ask");
18
+ const qLines = wrap(ask.question, innerW - 4);
14
19
  const bodyLines = [];
20
+ let hiddenExtra = 0;
15
21
  if (ask.error) {
16
- for (const wl of wrap(`Error: ${ask.error}`, maxW - 4))
22
+ for (const wl of wrap(`Error: ${ask.error}`, innerW - 2))
17
23
  bodyLines.push(chalk.red(wl));
18
24
  }
19
25
  else if (ask.answer) {
20
- for (const ln of ask.answer.split("\n").slice(0, 8)) {
21
- for (const wl of wrap(ln, maxW - 4))
26
+ const rawLines = ask.answer.split("\n");
27
+ const visible = rawLines.slice(0, ASK_BODY_LINES);
28
+ hiddenExtra = Math.max(0, rawLines.length - visible.length);
29
+ for (const ln of visible) {
30
+ for (const wl of wrap(ln, innerW - 2))
22
31
  bodyLines.push(wl);
23
32
  }
24
33
  }
25
34
  else if (ask.streaming) {
26
35
  bodyLines.push(chalk.dim("\u2026"));
27
36
  }
28
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, borderStyle: "round", borderColor: "cyan", width: maxW, children: [_jsxs(Text, { children: [" ", title] }), qLines.map((ql, i) => (_jsxs(Text, { children: [" ", i === 0 ? chalk.dim("Q:") + " " : " ", ql] }, `q${i}`))), bodyLines.map((l, i) => _jsxs(Text, { children: [" ", l] }, i))] }));
37
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, borderStyle: "round", borderColor: "cyan", width: boxW, children: [_jsxs(Text, { children: [" ", title] }), qLines.map((ql, i) => (_jsxs(Text, { children: [" ", i === 0 ? chalk.dim("Q:") + " " : " ", ql] }, `q${i}`))), bodyLines.map((l, i) => _jsxs(Text, { children: [" ", l] }, i)), hiddenExtra > 0 ? _jsxs(Text, { children: [" ", chalk.dim(`\u2026 + ${hiddenExtra} more line${hiddenExtra === 1 ? "" : "s"} \u00b7 press Enter to open`)] }) : null] }));
29
38
  }
30
39
  if (debrief) {
31
40
  const title = debrief.label
32
41
  ? `${chalk.bold.white("Debrief")} ${chalk.dim("\u00b7")} ${chalk.white(debrief.label)}`
33
42
  : chalk.bold.white("Debrief");
34
43
  const lines = [];
35
- for (const ln of debrief.text.split("\n").slice(0, 6)) {
36
- for (const wl of wrap(ln, maxW - 4))
44
+ const rawLines = debrief.text.split("\n");
45
+ const visible = rawLines.slice(0, DEBRIEF_BODY_LINES);
46
+ const hiddenExtra = Math.max(0, rawLines.length - visible.length);
47
+ for (const ln of visible) {
48
+ for (const wl of wrap(ln, innerW - 2))
37
49
  lines.push(wl);
38
50
  }
39
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, borderStyle: "round", borderColor: "green", width: maxW, children: [_jsxs(Text, { children: [" ", title] }), lines.map((l, i) => _jsxs(Text, { children: [" ", l] }, i))] }));
51
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, borderStyle: "round", borderColor: "green", width: boxW, children: [_jsxs(Text, { children: [" ", title] }), lines.map((l, i) => _jsxs(Text, { children: [" ", l] }, i)), hiddenExtra > 0 ? _jsxs(Text, { children: [" ", chalk.dim(`\u2026 + ${hiddenExtra} more line${hiddenExtra === 1 ? "" : "s"}`)] }) : null] }));
40
52
  }
41
53
  return null;
42
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.25.45",
3
+ "version": "1.25.46",
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.45",
3
+ "version": "1.25.46",
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"