diff --git a/src/skills/bundled/updateConfig.test.ts b/src/skills/bundled/updateConfig.test.ts
new file mode 100644
index 00000000..f0a2bea6
--- /dev/null
+++ b/src/skills/bundled/updateConfig.test.ts
@@ -0,0 +1,23 @@
+import { afterEach, expect, test } from 'bun:test'
+
+import { clearBundledSkills, getBundledSkills } from '../bundledSkills.js'
+import { registerUpdateConfigSkill } from './updateConfig.js'
+
+afterEach(() => {
+ clearBundledSkills()
+})
+
+test('update-config skill can generate its prompt without JSON Schema conversion errors', async () => {
+ registerUpdateConfigSkill()
+
+ const skill = getBundledSkills().find(command => command.name === 'update-config')
+ expect(skill).toBeDefined()
+ expect(skill?.type).toBe('prompt')
+
+ const blocks = await skill!.getPromptForCommand('', {} as never)
+ expect(blocks.length).toBeGreaterThan(0)
+ expect(blocks[0]).toMatchObject({ type: 'text' })
+ expect((blocks[0] as { text: string }).text).toContain(
+ '## Full Settings JSON Schema',
+ )
+})
diff --git a/src/utils/sessionStorage.test.ts b/src/utils/sessionStorage.test.ts
index 085a395a..442dc1e3 100644
--- a/src/utils/sessionStorage.test.ts
+++ b/src/utils/sessionStorage.test.ts
@@ -3,7 +3,11 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { buildConversationChain, loadTranscriptFile } from './sessionStorage.ts'
+import {
+ buildConversationChain,
+ loadTranscriptFile,
+ stripPersistedToolUseResultsFromJSONLBuffer,
+} from './sessionStorage.ts'
const tempDirs: string[] = []
const sessionId = '00000000-0000-4000-8000-000000000999'
@@ -194,3 +198,64 @@ test('loadTranscriptFile fails closed when preserved-segment anchor is missing',
const chain = buildConversationChain(messages, messages.get(id(25))!)
expect(chain.map(message => message.uuid)).toEqual([id(25)])
})
+
+test('stripPersistedToolUseResultsFromJSONLBuffer drops raw toolUseResult while preserving persisted preview content', () => {
+ const persisted = user(id(31), null, 'placeholder')
+ persisted.message = {
+ role: 'user',
+ content: [
+ {
+ type: 'tool_result',
+ tool_use_id: 'tool-31',
+ is_error: false,
+ content: '\nPreview text\n',
+ },
+ ],
+ }
+ ;(persisted as typeof persisted & { toolUseResult?: unknown }).toolUseResult = {
+ stdout: 'x'.repeat(200_000),
+ stderr: '',
+ }
+
+ const raw = Buffer.from(`${JSON.stringify(persisted)}\n`)
+ const sanitized = stripPersistedToolUseResultsFromJSONLBuffer(raw)
+ const [parsed] = JSON.parse(`[${sanitized.toString('utf8').trim()}]`) as Array<
+ typeof persisted & { toolUseResult?: unknown }
+ >
+
+ expect(parsed?.toolUseResult).toBeUndefined()
+ expect(
+ (parsed?.message.content as Array<{ content: string }>)[0]?.content,
+ ).toContain('Preview text')
+})
+
+test('loadTranscriptFile omits raw toolUseResult for persisted-output transcript entries', async () => {
+ const persisted = user(id(41), null, 'placeholder')
+ persisted.message = {
+ role: 'user',
+ content: [
+ {
+ type: 'tool_result',
+ tool_use_id: 'tool-41',
+ is_error: false,
+ content: '\nPreview text\n',
+ },
+ ],
+ }
+ ;(persisted as typeof persisted & { toolUseResult?: unknown }).toolUseResult = {
+ stdout: 'y'.repeat(200_000),
+ stderr: '',
+ }
+
+ const filePath = await writeJsonl([persisted])
+ const { messages } = await loadTranscriptFile(filePath)
+ const loaded = messages.get(id(41)) as (typeof persisted & {
+ toolUseResult?: unknown
+ }) | undefined
+
+ expect(loaded).toBeDefined()
+ expect(loaded?.toolUseResult).toBeUndefined()
+ expect(
+ (loaded?.message.content as Array<{ content: string }>)[0]?.content,
+ ).toContain('Preview text')
+})
diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts
index 8753c239..37457ba7 100644
--- a/src/utils/sessionStorage.ts
+++ b/src/utils/sessionStorage.ts
@@ -2013,6 +2013,199 @@ function applyPreservedSegmentRelinks(
return { relinkFailed }
}
+const PERSISTED_OUTPUT_TAG = Buffer.from('')
+const TOOL_USE_RESULT_KEY = Buffer.from('"toolUseResult":')
+
+function isJsonWhitespaceByte(byte: number | undefined): boolean {
+ return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d
+}
+
+function skipJsonWhitespace(buf: Buffer, index: number, end: number): number {
+ let i = index
+ while (i < end && isJsonWhitespaceByte(buf[i])) i++
+ return i
+}
+
+function findJsonValueEnd(buf: Buffer, start: number, end: number): number {
+ let i = skipJsonWhitespace(buf, start, end)
+ const first = buf[i]
+ if (first === undefined) return end
+
+ if (first === 0x22) {
+ i++
+ let escapeNext = false
+ while (i < end) {
+ const byte = buf[i]!
+ if (escapeNext) {
+ escapeNext = false
+ } else if (byte === 0x5c) {
+ escapeNext = true
+ } else if (byte === 0x22) {
+ return i + 1
+ }
+ i++
+ }
+ return end
+ }
+
+ if (first === 0x7b || first === 0x5b) {
+ const stack = [first]
+ i++
+ let inString = false
+ let escapeNext = false
+ while (i < end) {
+ const byte = buf[i]!
+ if (escapeNext) {
+ escapeNext = false
+ } else if (inString) {
+ if (byte === 0x5c) escapeNext = true
+ else if (byte === 0x22) inString = false
+ } else if (byte === 0x22) {
+ inString = true
+ } else if (byte === 0x7b || byte === 0x5b) {
+ stack.push(byte)
+ } else if (byte === 0x7d || byte === 0x5d) {
+ const open = stack.at(-1)
+ if (
+ (byte === 0x7d && open === 0x7b) ||
+ (byte === 0x5d && open === 0x5b)
+ ) {
+ stack.pop()
+ if (stack.length === 0) return i + 1
+ }
+ }
+ i++
+ }
+ return end
+ }
+
+ while (i < end) {
+ const byte = buf[i]!
+ if (byte === 0x2c || byte === 0x7d) return i
+ i++
+ }
+ return end
+}
+
+function stripPersistedToolUseResultFromLine(line: Buffer): Buffer {
+ if (
+ line.indexOf(PERSISTED_OUTPUT_TAG) === -1 ||
+ line.indexOf(TOOL_USE_RESULT_KEY) === -1
+ ) {
+ return line
+ }
+
+ let depth = 0
+ let inString = false
+ let escapeNext = false
+ const keyLen = TOOL_USE_RESULT_KEY.length
+
+ for (let i = 0; i <= line.length - keyLen; i++) {
+ const byte = line[i]!
+ if (escapeNext) {
+ escapeNext = false
+ continue
+ }
+ if (inString) {
+ if (byte === 0x5c) escapeNext = true
+ else if (byte === 0x22) inString = false
+ continue
+ }
+ if (byte === 0x22) {
+ if (
+ depth === 1 &&
+ line.compare(TOOL_USE_RESULT_KEY, 0, keyLen, i, i + keyLen) === 0
+ ) {
+ const valueEnd = findJsonValueEnd(line, i + keyLen, line.length)
+ let removeStart = i
+ let removeEnd = valueEnd
+ const afterValue = skipJsonWhitespace(line, valueEnd, line.length)
+
+ if (afterValue < line.length && line[afterValue] === 0x2c) {
+ removeEnd = afterValue + 1
+ } else {
+ let beforeKey = i - 1
+ while (beforeKey >= 0 && isJsonWhitespaceByte(line[beforeKey])) {
+ beforeKey--
+ }
+ if (beforeKey >= 0 && line[beforeKey] === 0x2c) {
+ removeStart = beforeKey
+ }
+ }
+
+ return Buffer.concat([
+ line.subarray(0, removeStart),
+ line.subarray(removeEnd),
+ ])
+ }
+ inString = true
+ continue
+ }
+ if (byte === 0x7b || byte === 0x5b) depth++
+ else if (byte === 0x7d || byte === 0x5d) depth--
+ }
+
+ return line
+}
+
+export function stripPersistedToolUseResultsFromJSONLBuffer(buf: Buffer): Buffer {
+ if (
+ buf.indexOf(PERSISTED_OUTPUT_TAG) === -1 ||
+ buf.indexOf(TOOL_USE_RESULT_KEY) === -1
+ ) {
+ return buf
+ }
+
+ const NEWLINE = 0x0a
+ const chunks: Buffer[] = []
+ let changed = false
+ let start = 0
+
+ while (start < buf.length) {
+ let end = buf.indexOf(NEWLINE, start)
+ let hasNewline = true
+ if (end === -1) {
+ end = buf.length
+ hasNewline = false
+ }
+ const line = buf.subarray(start, end)
+ const sanitized = stripPersistedToolUseResultFromLine(line)
+ if (sanitized !== line) changed = true
+ chunks.push(sanitized)
+ if (hasNewline) chunks.push(buf.subarray(end, end + 1))
+ start = end + (hasNewline ? 1 : 0)
+ }
+
+ return changed ? Buffer.concat(chunks) : buf
+}
+
+function forEachParsedJSONLBufferEntry(
+ buf: Buffer,
+ visit: (entry: T) => void,
+): void {
+ const bufLen = buf.length
+ let start = 0
+
+ if (buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
+ start = 3
+ }
+
+ while (start < bufLen) {
+ let end = buf.indexOf(0x0a, start)
+ if (end === -1) end = bufLen
+
+ const line = buf.toString('utf8', start, end).trim()
+ start = end + 1
+ if (!line) continue
+
+ try {
+ visit(jsonParse(line) as T)
+ } catch {
+ // Skip malformed lines
+ }
+ }
+}
+
/**
* Delete messages that Snip executions removed from the in-memory array,
* and relink parentUuid across the gaps.
@@ -3636,15 +3829,19 @@ export async function loadTranscriptFile(
buf = walkChainBeforeParse(buf)
}
+ // Resume path: once a tool_result has been replaced with a persisted
+ // preview, loading the original toolUseResult blob back into memory is
+ // wasted work and can OOM before the replacement re-hydration runs.
+ buf = stripPersistedToolUseResultsFromJSONLBuffer(buf)
+
// First pass: process metadata-only lines collected during the boundary scan.
// These populate the session-scoped maps (agentSettings, modes, prNumbers,
// etc.) for entries written before the compact boundary. Any overlap with
// the post-boundary buffer is harmless — later values overwrite earlier ones.
if (metadataLines && metadataLines.length > 0) {
- const metaEntries = parseJSONL(
+ forEachParsedJSONLBufferEntry(
Buffer.from(metadataLines.join('\n')),
- )
- for (const entry of metaEntries) {
+ entry => {
if (entry.type === 'summary' && entry.leafUuid) {
summaries.set(entry.leafUuid, entry.summary)
} else if (entry.type === 'custom-title' && entry.sessionId) {
@@ -3666,11 +3863,9 @@ export async function loadTranscriptFile(
prUrls.set(entry.sessionId, entry.prUrl)
prRepositories.set(entry.sessionId, entry.prRepository)
}
- }
+ })
}
- const entries = parseJSONL(buf)
-
// Bridge map for legacy progress entries: progress_uuid → progress_parent_uuid.
// PR #24099 removed progress from isTranscriptMessage, so old transcripts with
// progress in the parentUuid chain would truncate at buildConversationChain
@@ -3680,7 +3875,7 @@ export async function loadTranscriptFile(
// rewrite any subsequent message whose parentUuid lands in the bridge.
const progressBridge = new Map()
- for (const entry of entries) {
+ forEachParsedJSONLBufferEntry(buf, entry => {
// Legacy progress check runs before the Entry-typed else-if chain —
// progress is not in the Entry union, so checking it after TypeScript
// has narrowed `entry` intersects to `never`.
@@ -3695,9 +3890,7 @@ export async function loadTranscriptFile(
? (progressBridge.get(parent) ?? null)
: parent,
)
- continue
- }
- if (isTranscriptMessage(entry)) {
+ } else if (isTranscriptMessage(entry)) {
if (entry.parentUuid && progressBridge.has(entry.parentUuid)) {
entry.parentUuid = progressBridge.get(entry.parentUuid) ?? null
}
@@ -3754,7 +3947,7 @@ export async function loadTranscriptFile(
} else if (entry.type === 'marble-origami-snapshot') {
contextCollapseSnapshot = entry
}
- }
+ })
} catch {
// File doesn't exist or can't be read
}
diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts
index e53d6601..65e0d906 100644
--- a/src/utils/settings/types.ts
+++ b/src/utils/settings/types.ts
@@ -559,7 +559,7 @@ export const SettingsSchema = lazySchema(() =>
enabledPlugins: z
.record(
z.string(),
- z.union([z.array(z.string()), z.boolean(), z.undefined()]),
+ z.union([z.array(z.string()), z.boolean()]),
)
.optional()
.describe(