Files
openclaw/src/agents/command/attempt-execution.helpers.ts
Peter Steinberger f7a52573b0 fix: clear phantom Claude CLI resumes (#70317)
Verify Claude CLI session transcripts before reuse and clear phantom bindings with transcript-missing instead of passing stale --resume ids.\n\nFixes #70177.
2026-04-22 20:29:17 +01:00

215 lines
6.4 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import readline from "node:readline";
import {
isSilentReplyPrefixText,
isSilentReplyText,
SILENT_REPLY_TOKEN,
startsWithSilentToken,
stripLeadingSilentToken,
} from "../../auto-reply/tokens.js";
/** Maximum number of JSONL records to inspect before giving up. */
const SESSION_FILE_MAX_RECORDS = 500;
const CLAUDE_PROJECTS_RELATIVE_DIR = path.join(".claude", "projects");
function normalizeClaudeCliSessionId(sessionId: string | undefined): string | undefined {
const trimmed = sessionId?.trim();
if (!trimmed || trimmed.includes("\0") || trimmed.includes("/") || trimmed.includes("\\")) {
return undefined;
}
return trimmed;
}
async function jsonlFileHasAssistantMessage(filePath: string | undefined): Promise<boolean> {
if (!filePath) {
return false;
}
try {
const stat = await fs.lstat(filePath);
if (stat.isSymbolicLink() || !stat.isFile()) {
return false;
}
const fh = await fs.open(filePath, "r");
try {
const rl = readline.createInterface({ input: fh.createReadStream({ encoding: "utf-8" }) });
let recordCount = 0;
for await (const line of rl) {
if (!line.trim()) {
continue;
}
recordCount++;
if (recordCount > SESSION_FILE_MAX_RECORDS) {
break;
}
let obj: unknown;
try {
obj = JSON.parse(line);
} catch {
continue;
}
const rec = obj as Record<string, unknown> | null;
if ((rec?.message as Record<string, unknown> | undefined)?.role === "assistant") {
return true;
}
}
return false;
} finally {
await fh.close();
}
} catch {
return false;
}
}
/**
* Check whether a session transcript file exists and contains at least one
* assistant message, indicating that the SessionManager has flushed the
* initial user+assistant exchange to disk.
*/
export async function sessionFileHasContent(sessionFile: string | undefined): Promise<boolean> {
return await jsonlFileHasAssistantMessage(sessionFile);
}
export async function claudeCliSessionTranscriptHasContent(params: {
sessionId: string | undefined;
homeDir?: string;
}): Promise<boolean> {
const sessionId = normalizeClaudeCliSessionId(params.sessionId);
if (!sessionId) {
return false;
}
const homeDir = params.homeDir?.trim() || process.env.HOME || os.homedir();
const projectsDir = path.join(homeDir, CLAUDE_PROJECTS_RELATIVE_DIR);
let projectEntries: import("node:fs").Dirent[];
try {
projectEntries = await fs.readdir(projectsDir, { withFileTypes: true });
} catch {
return false;
}
for (const entry of projectEntries) {
if (!entry.isDirectory()) {
continue;
}
const candidate = path.join(projectsDir, entry.name, `${sessionId}.jsonl`);
if (await jsonlFileHasAssistantMessage(candidate)) {
return true;
}
}
return false;
}
export function resolveFallbackRetryPrompt(params: {
body: string;
isFallbackRetry: boolean;
sessionHasHistory?: boolean;
}): string {
if (!params.isFallbackRetry) {
return params.body;
}
if (!params.sessionHasHistory) {
return params.body;
}
// Even with persisted session history, fully replacing the body with a
// generic "continue where you left off" message strips the original task
// from the fallback model's view. Agents then have to reconstruct the
// instruction from history alone, which is fragile and sometimes
// impossible. Prepend the retry context to the original body instead so
// the fallback model has both the recovery signal AND the task. (#65760)
return `[Retry after the previous model attempt failed or timed out]\n\n${params.body}`;
}
export function createAcpVisibleTextAccumulator() {
let pendingSilentPrefix = "";
let visibleText = "";
let rawVisibleText = "";
const startsWithWordChar = (chunk: string): boolean => /^[\p{L}\p{N}]/u.test(chunk);
const resolveNextCandidate = (base: string, chunk: string): string => {
if (!base) {
return chunk;
}
if (
isSilentReplyText(base, SILENT_REPLY_TOKEN) &&
!chunk.startsWith(base) &&
startsWithWordChar(chunk)
) {
return chunk;
}
if (chunk.startsWith(base) && chunk.length > base.length) {
return chunk;
}
return `${base}${chunk}`;
};
const mergeVisibleChunk = (base: string, chunk: string): { rawText: string; delta: string } => {
if (!base) {
return { rawText: chunk, delta: chunk };
}
if (chunk.startsWith(base) && chunk.length > base.length) {
const delta = chunk.slice(base.length);
return { rawText: chunk, delta };
}
return {
rawText: `${base}${chunk}`,
delta: chunk,
};
};
return {
consume(chunk: string): { text: string; delta: string } | null {
if (!chunk) {
return null;
}
if (!visibleText) {
const leadCandidate = resolveNextCandidate(pendingSilentPrefix, chunk);
const trimmedLeadCandidate = leadCandidate.trim();
if (
isSilentReplyText(trimmedLeadCandidate, SILENT_REPLY_TOKEN) ||
isSilentReplyPrefixText(trimmedLeadCandidate, SILENT_REPLY_TOKEN)
) {
pendingSilentPrefix = leadCandidate;
return null;
}
if (startsWithSilentToken(trimmedLeadCandidate, SILENT_REPLY_TOKEN)) {
const stripped = stripLeadingSilentToken(leadCandidate, SILENT_REPLY_TOKEN);
if (stripped) {
pendingSilentPrefix = "";
rawVisibleText = leadCandidate;
visibleText = stripped;
return { text: stripped, delta: stripped };
}
pendingSilentPrefix = leadCandidate;
return null;
}
if (pendingSilentPrefix) {
pendingSilentPrefix = "";
rawVisibleText = leadCandidate;
visibleText = leadCandidate;
return {
text: visibleText,
delta: leadCandidate,
};
}
}
const nextVisible = mergeVisibleChunk(rawVisibleText, chunk);
rawVisibleText = nextVisible.rawText;
if (!nextVisible.delta) {
return null;
}
visibleText = `${visibleText}${nextVisible.delta}`;
return { text: visibleText, delta: nextVisible.delta };
},
finalize(): string {
return visibleText.trim();
},
finalizeRaw(): string {
return visibleText;
},
};
}