fix(auto-compact): send a multi-line focus prompt as one line

Auto-compact writes `/compact <prompt>\r` through writeViaMux when the token
count crosses the threshold, unattended. AutoCompactSchema.prompt accepts line
breaks, so a prompt set through the API can hold several lines. The mux used to
strip them and run the lines together; it now pastes multi-line text as one
block, and the check that re-presses a stranded Enter cannot recognise that
paste in the composer. That is the case respawn's update and kickstart prompts
were already joined for.

The join moves out of respawn-controller into a shared toSingleLine()
(src/utils/single-line.ts), used by both respawn steps and by auto-compact.
Behaviour for respawn is unchanged. The respawn test file becomes
test/unattended-single-line-prompt.test.ts and gains an auto-compact block that
feeds a real Session a token count over its threshold and reads what reaches
writeViaMux.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claudia[bot]andClaude Opus 5.5 committed 2026-10-21 05:06:42 +02:00
1 parent 3392a69290
commit cf999c1296
6 files changed
+87 -31

No files matched your search

+10 -17
View File
@@ -49,7 +49,13 @@ import { Session } from './session.js';
import { AiIdleChecker, type AiCheckState } from './ai-idle-checker.js';
import { AiPlanChecker } from './ai-plan-checker.js';
import type { TeamWatcher } from './team-watcher.js';
import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
import {
BufferAccumulator,
ANSI_ESCAPE_PATTERN_SIMPLE,
assertNever,
CleanupManager,
toSingleLine,
} from './utils/index.js';
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
import {
isCompletionMessage,
@@ -503,19 +509,6 @@ function formatRemainingSeconds(ms: number): number {
return Math.max(0, Math.ceil(ms / 1000));
}
/**
* A respawn prompt as the one line it is sent as: every run of line breaks, with the
* whitespace around it, becomes a single space; a single-line prompt is returned as
* is. The config may hold several lines (the settings fields are textareas), but the
* steps run unattended through `writeViaMux`, and the check that re-presses a stranded
* Enter cannot recognise a multi-line paste in the composer. Cron rejects multi-line
* prompts for the same reason; respawn joins them instead, so a saved multi-line
* config is still sent rather than refused.
*/
export function singleLineRespawnPrompt(prompt: string): string {
return prompt.replace(/\s*[\r\n]+\s*/g, ' ');
}
/** Default configuration values */
const DEFAULT_CONFIG: RespawnConfig = {
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
@@ -1634,7 +1627,7 @@ export class RespawnController extends EventEmitter {
this.config.interStepDelayMs,
async () => {
if (this._state === 'stopped') return;
const prompt = singleLineRespawnPrompt(this.config.kickstartPrompt!);
const prompt = toSingleLine(this.config.kickstartPrompt!);
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
// COD-51: stop() may have run during the await; re-check before reviving the
@@ -2839,11 +2832,11 @@ export class RespawnController extends EventEmitter {
// Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config
const statusBlock = this.session.ralphTracker?.lastStatusBlock;
let updatePrompt = singleLineRespawnPrompt(this.config.updatePrompt);
let updatePrompt = toSingleLine(this.config.updatePrompt);
if (statusBlock?.recommendation) {
// Append RECOMMENDATION to the update prompt, on the same single line
// (see singleLineRespawnPrompt)
// (see toSingleLine)
const rec = statusBlock.recommendation.replace(/\n/g, ' ').substring(0, 200);
updatePrompt = `${updatePrompt} (Claude's last recommendation: ${rec})`;
this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`);
+6 -1
View File
@@ -20,6 +20,7 @@
import { EventEmitter } from 'node:events';
import { detectUsageLimitPause } from './usage-limit-patterns.js';
import { toSingleLine } from './utils/single-line.js';
// ============================================================================
// Timing Constants
@@ -408,7 +409,11 @@ export class SessionAutoOps extends EventEmitter {
);
const action = async () => {
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
// One line: this fires unattended, and a multi-line focus prompt would go out
// as a multi-line paste (see toSingleLine).
const compactCmd = this._autoCompactPrompt
? `/compact ${toSingleLine(this._autoCompactPrompt)}\r`
: '/compact\r';
await this.callbacks.writeCommand(compactCmd);
this.emit('autoCompact', {
tokens: totalTokens,
+1
View File
@@ -27,6 +27,7 @@ export { MAX_SESSION_TOKENS } from './token-validation.js';
export { isSafePushEndpoint } from './push-endpoint-validation.js';
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
export { assertNever } from './type-safety.js';
export { toSingleLine } from './single-line.js';
export { wrapWithNice } from './nice-wrapper.js';
export { resolveLocalShell, loginShellArgs } from './shell-resolver.js';
export {
+20
View File
@@ -0,0 +1,20 @@
/**
* @fileoverview Collapse a prompt onto the one line an unattended sender writes.
*
* @module utils/single-line
*/
/**
* A prompt as one line: every run of line breaks, with the whitespace around it,
* becomes a single space; a single-line prompt is returned as is.
*
* For the senders that run unattended through `writeViaMux` (respawn's update and
* kickstart steps, auto-compact). The mux pastes a multi-line prompt as one block,
* and the check that re-presses a stranded Enter cannot recognise a multi-line paste
* in the composer, so nobody would notice a prompt left unsubmitted. Cron rejects
* multi-line prompts for the same reason; these senders join them instead, so a
* saved multi-line setting is still sent rather than refused.
*/
export function toSingleLine(prompt: string): string {
return prompt.replace(/\s*[\r\n]+\s*/g, ' ');
}