fix(respawn): send a multi-line update or kickstart prompt as one line

The update and kickstart prompts are textareas in the respawn settings and
the schema accepts line breaks, so a saved config can hold several lines.
Both steps went out through writeViaMux, which used to strip every `\n` and
run the lines together. Since the mux now pastes multi-line text as one
block, a multi-line respawn prompt would have gone out as a multi-line paste,
and the check that re-presses a stranded Enter cannot recognise that paste in
the composer. Respawn runs unattended, the same situation for which cron
keeps rejecting multi-line prompts.

singleLineRespawnPrompt() joins the lines with a space before the write; a
single-line prompt is passed through unchanged. Respawn joins rather than
rejects, so a multi-line config that was accepted before is still sent.

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 04:53:56 +02:00
1 parent fe10cad83f
commit f06635512a
3 files changed
+112 -6

No files matched your search

+18 -5
View File
@@ -503,6 +503,19 @@ 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)
@@ -1621,7 +1634,7 @@ export class RespawnController extends EventEmitter {
this.config.interStepDelayMs,
async () => {
if (this._state === 'stopped') return;
const prompt = this.config.kickstartPrompt!;
const prompt = singleLineRespawnPrompt(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
@@ -2826,13 +2839,13 @@ export class RespawnController extends EventEmitter {
// Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config
const statusBlock = this.session.ralphTracker?.lastStatusBlock;
let updatePrompt = this.config.updatePrompt;
let updatePrompt = singleLineRespawnPrompt(this.config.updatePrompt);
if (statusBlock?.recommendation) {
// Append RECOMMENDATION to the update prompt (single-line: the submit check cannot
// recognise a multi-line paste in the composer)
// Append RECOMMENDATION to the update prompt, on the same single line
// (see singleLineRespawnPrompt)
const rec = statusBlock.recommendation.replace(/\n/g, ' ').substring(0, 200);
updatePrompt = `${this.config.updatePrompt} (Claude's last recommendation: ${rec})`;
updatePrompt = `${updatePrompt} (Claude's last recommendation: ${rec})`;
this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`);
}