#!/usr/bin/env node const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); function resolveOpenClawPackageRoot() { const wrapperPath = path.join(os.homedir(), '.local', 'bin', 'openclaw'); const wrapper = fs.readFileSync(wrapperPath, 'utf8'); const match = wrapper.match(/"([^"]*node_modules\/openclaw)\/openclaw\.mjs"/); if (!match) throw new Error(`Could not resolve openclaw package root from ${wrapperPath}`); const raw = match[1]; if (raw.startsWith('$basedir/')) { return path.resolve(path.dirname(wrapperPath), raw.replace(/^\$basedir\//, '')); } return raw; } function replaceOnce(content, oldText, newText, label, filePath) { if (content.includes(newText)) return { content, changed: false, already: true }; if (!content.includes(oldText)) { throw new Error(`Patch block not found for ${label} in ${filePath}`); } return { content: content.replace(oldText, newText), changed: true, already: false }; } function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); } const extractAssistantTextOld = `function extractAssistantText(message) { \tif (!message || typeof message !== "object") return; \tif (message.role !== "assistant") return; \tconst content = message.content; \tif (!Array.isArray(content)) return; \tconst joined = extractTextFromChatContent(content, { \t\tsanitizeText: sanitizeTextContent, \t\tjoinWith: "", \t\tnormalizeText: (text) => text.trim() \t}) ?? ""; \tconst stopReason = message.stopReason; \tconst errorMessage = message.errorMessage; \tconst errorContext = stopReason === "error" || typeof errorMessage === "string" && Boolean(errorMessage.trim()); \treturn joined ? sanitizeUserFacingText(joined, { errorContext }) : void 0; }`; const extractAssistantTextNew = `function extractAssistantText(message) { \tif (!message || typeof message !== "object") return; \tif (message.role !== "assistant") return; \tconst content = message.content; \tif (!Array.isArray(content)) return; \tconst joined = extractTextFromChatContent(content, { \t\tsanitizeText: sanitizeTextContent, \t\tjoinWith: "", \t\tnormalizeText: (text) => text.trim() \t}) ?? ""; \tconst stopReason = message.stopReason; \tconst errorMessage = message.errorMessage; \tconst errorContext = stopReason === "error" || typeof errorMessage === "string" && Boolean(errorMessage.trim()); \treturn joined ? sanitizeUserFacingText(joined, { errorContext }) : void 0; } function extractAssistantTerminalText(message) { \tif (!message || typeof message !== "object") return { isError: false }; \tif (message.role !== "assistant") return { isError: false }; \tconst stopReason = message.stopReason; \tconst rawErrorMessage = message.errorMessage; \tconst isError = stopReason === "error" || typeof rawErrorMessage === "string" && Boolean(rawErrorMessage.trim()); \tconst text = extractAssistantText(message); \tif (text?.trim()) return { text, isError }; \tif (typeof rawErrorMessage === "string" && rawErrorMessage.trim()) { \t\treturn { \t\t\ttext: sanitizeUserFacingText(rawErrorMessage.trim(), { errorContext: true }), \t\t\tisError: true \t\t}; \t} \treturn { isError }; }`; const readLatestAssistantReplyOld = `async function readLatestAssistantReply(params) { \tconst history = await callGateway({ \t\tmethod: "chat.history", \t\tparams: { \t\t\tsessionKey: params.sessionKey, \t\t\tlimit: params.limit ?? 50 \t\t} \t}); \tconst filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []); \tfor (let i = filtered.length - 1; i >= 0; i -= 1) { \t\tconst candidate = filtered[i]; \t\tif (!candidate || typeof candidate !== "object") continue; \t\tif (candidate.role !== "assistant") continue; \t\tconst text = extractAssistantText(candidate); \t\tif (!text?.trim()) continue; \t\treturn text; \t} }`; const readLatestAssistantReplyNew = `async function readLatestAssistantOutcome(params) { \tconst history = await callGateway({ \t\tmethod: "chat.history", \t\tparams: { \t\t\tsessionKey: params.sessionKey, \t\t\tlimit: params.limit ?? 50 \t\t} \t}); \tconst filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []); \tfor (let i = filtered.length - 1; i >= 0; i -= 1) { \t\tconst candidate = filtered[i]; \t\tif (!candidate || typeof candidate !== "object") continue; \t\tif (candidate.role !== "assistant") continue; \t\treturn extractAssistantTerminalText(candidate); \t} \treturn { isError: false }; } async function readLatestAssistantReply(params) { \tconst outcome = await readLatestAssistantOutcome(params); \treturn outcome.text?.trim() ? outcome.text : void 0; }`; const waitOutcomeOld = `\t\tconst waitError = typeof wait.error === "string" ? wait.error : void 0; \t\tconst outcome = wait.status === "error" ? { \t\t\tstatus: "error", \t\t\terror: waitError \t\t} : wait.status === "timeout" ? { status: "timeout" } : { status: "ok" }; \t\tif (!runOutcomesEqual(entry.outcome, outcome)) { \t\t\tentry.outcome = outcome; \t\t\tmutated = true; \t\t} \t\tif (mutated) persistSubagentRuns(); \t\tawait completeSubagentRun({ \t\t\trunId, \t\t\tendedAt: entry.endedAt, \t\t\toutcome, \t\t\treason: wait.status === "error" ? SUBAGENT_ENDED_REASON_ERROR : SUBAGENT_ENDED_REASON_COMPLETE, \t\t\tsendFarewell: true, \t\t\taccountId: entry.requesterOrigin?.accountId, \t\t\ttriggerCleanup: true \t\t});`; const waitOutcomeNew = `\t\tconst waitError = typeof wait.error === "string" ? wait.error : void 0; \t\tlet outcome = wait.status === "error" ? { \t\t\tstatus: "error", \t\t\terror: waitError \t\t} : wait.status === "timeout" ? { status: "timeout" } : { status: "ok" }; \t\tif (outcome.status === "ok") try { \t\t\tconst latestAssistant = await readLatestAssistantOutcome({ \t\t\t\tsessionKey: entry.childSessionKey, \t\t\t\tlimit: 50 \t\t\t}); \t\t\tif (latestAssistant.isError) outcome = { \t\t\t\tstatus: "error", \t\t\t\terror: latestAssistant.text?.trim() || waitError \t\t\t}; \t\t} catch {} \t\tif (!runOutcomesEqual(entry.outcome, outcome)) { \t\t\tentry.outcome = outcome; \t\t\tmutated = true; \t\t} \t\tif (mutated) persistSubagentRuns(); \t\tawait completeSubagentRun({ \t\t\trunId, \t\t\tendedAt: entry.endedAt, \t\t\toutcome, \t\t\treason: outcome.status === "error" ? SUBAGENT_ENDED_REASON_ERROR : SUBAGENT_ENDED_REASON_COMPLETE, \t\t\tsendFarewell: true, \t\t\taccountId: entry.requesterOrigin?.accountId, \t\t\ttriggerCleanup: true \t\t});`; const announceGuardOld = `\t\tif (!outcome) outcome = { status: "unknown" };`; const announceGuardNew = `\t\tif (outcome?.status === "ok") try { \t\t\tconst latestAssistant = await readLatestAssistantOutcome({ \t\t\t\tsessionKey: params.childSessionKey, \t\t\t\tlimit: 50 \t\t\t}); \t\t\tif (latestAssistant.isError) { \t\t\t\tif (!reply?.trim() && latestAssistant.text?.trim()) reply = latestAssistant.text; \t\t\t\toutcome = { \t\t\t\t\tstatus: "error", \t\t\t\t\terror: latestAssistant.text?.trim() || outcome.error \t\t\t\t}; \t\t\t} \t\t} catch {} \t\tif (!outcome) outcome = { status: "unknown" };`; function main() { const pkgRoot = resolveOpenClawPackageRoot(); const targets = [ path.join(pkgRoot, 'dist', 'reply-DeXK9BLT.js'), path.join(pkgRoot, 'dist', 'compact-D3emcZgv.js'), path.join(pkgRoot, 'dist', 'pi-embedded-CrsFdYam.js'), path.join(pkgRoot, 'dist', 'pi-embedded-jHMb7qEG.js'), path.join(pkgRoot, 'dist', 'plugin-sdk', 'dispatch-CJdFmoH9.js'), ].filter((file) => fs.existsSync(file)); const backupRoot = path.join(os.homedir(), '.openclaw', 'workspace', 'tmp', 'openclaw-subagent-outcome-hotfix'); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const thisBackupDir = path.join(backupRoot, stamp); let touched = 0; for (const file of targets) { let content = fs.readFileSync(file, 'utf8'); let changed = false; for (const [label, oldText, newText] of [ ['extractAssistantTerminalText', extractAssistantTextOld, extractAssistantTextNew], ['readLatestAssistantOutcome', readLatestAssistantReplyOld, readLatestAssistantReplyNew], ['wait outcome downgrade', waitOutcomeOld, waitOutcomeNew], ['announce error guard', announceGuardOld, announceGuardNew], ]) { const result = replaceOnce(content, oldText, newText, label, file); content = result.content; changed = changed || result.changed; } if (changed) { ensureDir(thisBackupDir); const backupPath = path.join(thisBackupDir, path.basename(file)); fs.copyFileSync(file, backupPath); fs.writeFileSync(file, content, 'utf8'); touched += 1; console.log(`patched ${file}`); } else { console.log(`already patched ${file}`); } } console.log(`done; touched ${touched} file(s)`); if (touched > 0) console.log(`backup: ${thisBackupDir}`); } main();