fix(jcode): stop the OSC color skip from crashing every pane connect

Co-authored-by: czzczz <chanzrz_zbf@foxmail.com>
This commit is contained in:
Neil
2026-10-03 01:49:40 -07:00
committed by Neil
co-authored by czzczz
parent 33cab243ab
commit 7fb201f60f
15 changed files with 231 additions and 80 deletions
@@ -345,6 +345,7 @@ describe('registerPtyHandlers', () => {
const tabId = 'tab-1'
const paneKey = makePaneKey(tabId, leafId)
handlers.clear()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite's mock BrowserWindow, widened the same way every other registerPtyHandlers call in this file does; the handler only touches webContents.send.
registerPtyHandlers(mainWindow as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
@@ -354,12 +355,13 @@ describe('registerPtyHandlers', () => {
leafId,
worktreeId: 'wt-1'
})
const spawnOptions = spawnMock.mock.calls.at(-1)![2] as { env: Record<string, string> }
const spawnedEnv: Record<string, string | undefined> =
spawnMock.mock.calls.at(-1)?.[2]?.env ?? {}
// Why: buildJcodeRuntimeDirEnv intentionally omits the var on win32.
if (shouldInjectJcodeRuntimeDir(process.platform)) {
expect(spawnOptions.env.JCODE_RUNTIME_DIR).toBe(buildJcodeRuntimeDir(paneKey))
expect(spawnedEnv.JCODE_RUNTIME_DIR).toBe(buildJcodeRuntimeDir(paneKey))
} else {
expect(spawnOptions.env.JCODE_RUNTIME_DIR).toBeUndefined()
expect(spawnedEnv.JCODE_RUNTIME_DIR).toBeUndefined()
}
})
it('strips inherited Claude child-session stamps from a local spawn env', async () => {
+17 -10
View File
@@ -16,19 +16,26 @@ export function resetJcodeDaemonPrewarmForTests(): void {
prewarmedRuntimeDirs.clear()
}
/** The runtime dir to warm, or null when this pane is not a local jcode launch. */
export function resolveJcodePrewarmRuntimeDir(args: {
launchAgent?: string
runtimeDir?: string
platform?: NodeJS.Platform
}): string | null {
// Why non-Windows only: the runtime dir is a unix-socket directory, and Orca
// only stamps it off Windows (see shouldInjectJcodeRuntimeDir).
if (args.launchAgent !== 'jcode' || (args.platform ?? process.platform) === 'win32') {
return null
}
return args.runtimeDir !== undefined && args.runtimeDir.length > 0 ? args.runtimeDir : null
}
export function shouldPrewarmJcodeDaemon(args: {
launchAgent?: string
runtimeDir?: string
platform?: NodeJS.Platform
}): boolean {
// Why non-Windows only: the runtime dir is a unix-socket directory, and Orca
// only stamps it off Windows (see shouldInjectJcodeRuntimeDir).
return (
args.launchAgent === 'jcode' &&
typeof args.runtimeDir === 'string' &&
args.runtimeDir.length > 0 &&
(args.platform ?? process.platform) !== 'win32'
)
return resolveJcodePrewarmRuntimeDir(args) !== null
}
/**
@@ -45,10 +52,10 @@ export function prewarmJcodeDaemon(args: {
env?: Record<string, string>
platform?: NodeJS.Platform
}): boolean {
if (!shouldPrewarmJcodeDaemon(args) || prewarmedRuntimeDirs.has(args.runtimeDir as string)) {
const runtimeDir = resolveJcodePrewarmRuntimeDir(args)
if (runtimeDir === null || prewarmedRuntimeDirs.has(runtimeDir)) {
return false
}
const runtimeDir = args.runtimeDir as string
prewarmedRuntimeDirs.add(runtimeDir)
try {
const child = spawnProcess({
+1 -1
View File
@@ -7,7 +7,7 @@ import { dirname, join } from 'node:path'
const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() }))
vi.mock('os', async () => {
const actual = (await vi.importActual('os')) as Record<string, unknown>
const actual = await vi.importActual<typeof import('os')>('os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return { ...actual, homedir: homedirMock }
})
+1 -1
View File
@@ -8,7 +8,7 @@ const { homedirMock } = vi.hoisted(() => ({
}))
vi.mock('os', async () => {
const actual = (await vi.importActual('os')) as Record<string, unknown>
const actual = await vi.importActual<typeof import('os')>('os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return { ...actual, homedir: homedirMock }
})
@@ -134,6 +134,19 @@ function expectNoGlobalAtlasRecovery(): void {
expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled()
}
// Why: connectPanePty takes the real pane/manager/deps types and every test in this file
// passes structural fakes. Funnelling the widening through one helper keeps the casts in
// a single reviewable place instead of three per test.
function connectFakePane(
connect: (pane: never, manager: never, deps: never) => { dispose: () => void },
pane: unknown,
manager: unknown,
deps: Parameters<typeof createDeps>[0]
): { dispose: () => void } {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the three values are this suite's structural fakes for pane/manager/deps; connectPanePty only reads the fields they define.
return connect(pane as never, manager as never, createDeps(deps) as never)
}
describe('connectPanePty', () => {
beforeEach(async () => {
vi.resetModules()
@@ -552,24 +565,18 @@ describe('connectPanePty', () => {
})
transportFactoryQueue.push(transport)
const pane = createPane(1)
const manager = createManager(1)
const binding = connectPanePty(
pane as never,
manager as never,
createDeps({
isVisibleRef: { current: false },
startup: {
command: 'jcode',
launchAgent: 'jcode',
telemetry: {
agent_kind: 'jcode',
launch_source: 'tab_bar_quick_launch',
request_kind: 'new'
}
const binding = connectFakePane(connectPanePty, createPane(1), createManager(1), {
isVisibleRef: { current: false },
startup: {
command: 'jcode',
launchAgent: 'jcode',
telemetry: {
agent_kind: 'jcode',
launch_source: 'tab_bar_quick_launch',
request_kind: 'new'
}
}) as never
)
}
})
await flushAsyncTicks(6)
expect(capturedDataCallback.current).not.toBeNull()
@@ -17,6 +17,11 @@ import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence'
import type { TuiAgent } from '../../../../../shared/tui-agent'
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../../shared/tui-agent-config'
import {
paneShouldAnswerOscColorQueries,
resolvePaneLaunchAgentCandidate,
type PaneLaunchAgentPaneSlice
} from './pane-launch-agent-candidate'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
/** Pane agent identity, foreground-agent sampling, and command lifecycle handling. */
@@ -28,22 +33,18 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
const entry = state.agentStatusByPaneKey[session.cacheKey]
return entry?.state !== 'done' && Boolean(agentTypeToIconAgent(entry?.agentType))
}
// Why: one ladder for both launch-agent signals; a second copy could drift.
// Why the shared module: the same ladder answers the 133;D guard, the visible-pane
// resampler, and the OSC color-reply skip, and it is the one piece of this installer
// that is a pure rule worth testing on its own.
const panePane = (): PaneLaunchAgentPaneSlice => ({
worktreeId: session.deps.worktreeId,
tabId: session.deps.tabId,
paneKey: session.cacheKey,
startup: session.paneStartup
})
const resolveLaunchAgentCandidate = (
state: ReturnType<typeof useAppStore.getState>
): string | undefined => {
const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find(
(candidate) => candidate.id === session.deps.tabId
)
const registeredLaunchAgent =
state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity.agentType
return (
tab?.launchAgent ??
session.paneStartup?.launchAgent ??
session.paneStartup?.initialAgentStatus?.agent ??
(isTuiAgent(registeredLaunchAgent) ? registeredLaunchAgent : undefined)
)
}
): string | undefined => resolvePaneLaunchAgentCandidate(state, panePane())
session.paneExpectsLaunchAgent = (state: ReturnType<typeof useAppStore.getState>): boolean =>
Boolean(resolveLaunchAgentCandidate(state))
// Why: the concrete TUI agent a fresh spawn is expected to launch, used to seed
@@ -57,14 +58,14 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
// composer is ready and render the reply as pre-typed text (the main-side
// startup ingress already skips it, the renderer must skip the answer too).
session.shouldAnswerPaneOscColorQueries = (): boolean =>
session.resolveExpectedLaunchTuiAgent() !== 'jcode'
paneShouldAnswerOscColorQueries(useAppStore.getState(), panePane())
// Why: a launched/hook-known agent pane must confirm — not trust — a 133;D so a
// full-screen agent's leaked nested-shell 133;D can't clear its tab identity,
// even on a restore where no command-start read has recorded evidence yet.
session.paneHasKnownAgentIdentity = (): boolean => {
const state = useAppStore.getState()
const registeredLaunchAgent =
state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity.agentType
state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity?.agentType
return (
Boolean(state.paneForegroundAgentByPaneKey[session.cacheKey]?.agent) ||
session.paneHasLiveHookAgentIcon(state) ||
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import {
paneShouldAnswerOscColorQueries,
resolvePaneLaunchAgentCandidate,
resolvePaneLaunchTuiAgent,
type PaneLaunchAgentPaneSlice,
type PaneLaunchAgentStoreSlice
} from './pane-launch-agent-candidate'
const PANE: PaneLaunchAgentPaneSlice = {
worktreeId: 'wt-1',
tabId: 'tab-1',
paneKey: 'tab-1:leaf-1'
}
function store(overrides: Partial<PaneLaunchAgentStoreSlice> = {}): PaneLaunchAgentStoreSlice {
return { tabsByWorktree: {}, agentLaunchConfigByPaneKey: {}, ...overrides }
}
describe('pane launch-agent candidate', () => {
it('survives a launch config registered before its identity lands', () => {
// Why: the OSC color-reply skip made this ladder run on EVERY pane connect, and
// reading through a missing `identity` threw "Cannot read properties of undefined
// (reading 'agentType')" — 24 failures across 8 connectPanePty suites.
const state = store({ agentLaunchConfigByPaneKey: { 'tab-1:leaf-1': {} } })
expect(resolvePaneLaunchAgentCandidate(state, PANE)).toBeUndefined()
expect(resolvePaneLaunchTuiAgent(state, PANE)).toBeNull()
expect(paneShouldAnswerOscColorQueries(state, PANE)).toBe(true)
})
it('prefers the tab launch agent over every later signal', () => {
const state = store({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1', launchAgent: 'jcode' }] } })
const pane = { ...PANE, startup: { launchAgent: 'claude' } }
expect(resolvePaneLaunchTuiAgent(state, pane)).toBe('jcode')
})
it('falls back through startup, then initial status, then the registered config', () => {
expect(resolvePaneLaunchTuiAgent(store(), { ...PANE, startup: { launchAgent: 'jcode' } })).toBe(
'jcode'
)
expect(
resolvePaneLaunchTuiAgent(store(), {
...PANE,
startup: { initialAgentStatus: { agent: 'jcode' } }
})
).toBe('jcode')
expect(
resolvePaneLaunchTuiAgent(
store({
agentLaunchConfigByPaneKey: { 'tab-1:leaf-1': { identity: { agentType: 'jcode' } } }
}),
PANE
)
).toBe('jcode')
})
it('ignores a registered agent name that is not a known TUI agent', () => {
const state = store({
agentLaunchConfigByPaneKey: { 'tab-1:leaf-1': { identity: { agentType: 'not-an-agent' } } }
})
expect(resolvePaneLaunchAgentCandidate(state, PANE)).toBeUndefined()
})
it('skips the OSC color answer for jcode and no one else', () => {
for (const [launchAgent, answers] of [
['jcode', false],
['claude', true],
['codex', true],
[undefined, true]
] as const) {
const state = store({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1', launchAgent }] } })
expect(paneShouldAnswerOscColorQueries(state, PANE)).toBe(answers)
}
})
})
@@ -0,0 +1,60 @@
import { isTuiAgent } from '../../../../../shared/tui-agent-config'
import type { TuiAgent } from '../../../../../shared/tui-agent'
/** The store slice the launch-agent ladder reads. */
export type PaneLaunchAgentStoreSlice = {
tabsByWorktree: Record<string, { id: string; launchAgent?: string }[] | undefined>
agentLaunchConfigByPaneKey: Record<string, { identity?: { agentType?: string } } | undefined>
}
export type PaneLaunchAgentPaneSlice = {
worktreeId: string
tabId: string
paneKey: string
startup?: { launchAgent?: string; initialAgentStatus?: { agent?: string } }
}
/**
* The launch agent a pane is expected to run, from the first signal that has one.
*
* Why one ladder: the 133;D confirmation guard, the visible-pane resampler, and the
* OSC color-reply skip all key off "what agent is this pane", and a second copy would
* drift. Why `identity` is optional: a launch config is registered before its identity
* lands, and reading through it unguarded threw on every pane connect once the
* color-reply skip started calling this on all of them.
*/
export function resolvePaneLaunchAgentCandidate(
state: PaneLaunchAgentStoreSlice,
pane: PaneLaunchAgentPaneSlice
): string | undefined {
const tab = (state.tabsByWorktree[pane.worktreeId] ?? []).find(
(candidate) => candidate.id === pane.tabId
)
const registeredLaunchAgent = state.agentLaunchConfigByPaneKey[pane.paneKey]?.identity?.agentType
return (
tab?.launchAgent ??
pane.startup?.launchAgent ??
pane.startup?.initialAgentStatus?.agent ??
(isTuiAgent(registeredLaunchAgent) ? registeredLaunchAgent : undefined)
)
}
/** The same ladder, narrowed to a recognized TUI agent. */
export function resolvePaneLaunchTuiAgent(
state: PaneLaunchAgentStoreSlice,
pane: PaneLaunchAgentPaneSlice
): TuiAgent | null {
const candidate = resolvePaneLaunchAgentCandidate(state, pane)
return isTuiAgent(candidate) ? candidate : null
}
// Why: jcode paints its own theme and fires its OSC 10/11 burst before its TUI input
// loop is ready, so the cooked reply lands in the composer as pre-typed text (the same
// class as #12112, which fixed opencode). The main-side startup ingress already skips
// it; the renderer's capability handlers must skip the answer too.
export function paneShouldAnswerOscColorQueries(
state: PaneLaunchAgentStoreSlice,
pane: PaneLaunchAgentPaneSlice
): boolean {
return resolvePaneLaunchTuiAgent(state, pane) !== 'jcode'
}
@@ -215,7 +215,7 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void {
sixelSupported: () =>
resolveTerminalInlineImagesEnabled(useAppStore.getState().settings?.terminalInlineImages) &&
terminalRendersInlineImages(session.pane.terminal),
skipOscColorQueryReplies: !session.shouldAnswerPaneOscColorQueries(),
skipOscColorQueryReplies: () => !session.shouldAnswerPaneOscColorQueries(),
...(session.isNativeWindowsConpty ? { da1Response: CONPTY_DA1_RESPONSE } : {})
})
session.respondToTerminalPixelSizeQueries = createTerminalPixelSizeQueryResponder(
@@ -173,7 +173,7 @@ describe('installTerminalCapabilityReplyHandlers', () => {
parser: term.parser,
sendInput,
isReplaying: () => false,
skipOscColorQueryReplies: true
skipOscColorQueryReplies: () => true
})
try {
@@ -22,9 +22,11 @@ type TerminalCapabilityRepliesDeps = {
da1Response?: string
// Resolved per query so a live inline-images toggle changes what the next DA1 advertises.
sixelSupported?: () => boolean
/** Why: jcode themes itself; answering its OSC color burst can land before its
* composer is ready and render the reply as pre-typed text. */
skipOscColorQueryReplies?: boolean
/** Why a thunk, like sixelSupported: the pane's launch agent may not be registered
* in the store yet when the handlers are installed, so a boolean captured here
* answers jcode's burst anyway. Why skip at all: jcode themes itself, and the reply
* can land before its composer is ready and render as pre-typed text. */
skipOscColorQueryReplies?: () => boolean
}
// Adds Sixel to a DA1 response so DA1-detecting image tools emit Sixel; idempotent.
@@ -168,7 +170,7 @@ export function installTerminalCapabilityReplyHandlers(
if (!slots) {
return false
}
if (deps.isReplaying() || deps.skipOscColorQueryReplies === true) {
if (deps.isReplaying() || deps.skipOscColorQueryReplies?.() === true) {
return true
}
return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput)
@@ -181,7 +183,7 @@ export function installTerminalCapabilityReplyHandlers(
if (!slots) {
return false
}
if (deps.isReplaying() || deps.skipOscColorQueryReplies === true) {
if (deps.isReplaying() || deps.skipOscColorQueryReplies?.() === true) {
return true
}
return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput)
@@ -39,7 +39,7 @@ function readJcodeIntent(toolInput: unknown): string | undefined {
if (typeof toolInput !== 'object' || toolInput === null) {
return undefined
}
const intent = (toolInput as Record<string, unknown>).intent
const intent: unknown = Reflect.get(toolInput, 'intent')
return typeof intent === 'string' && intent.trim().length > 0 ? intent : undefined
}
+5 -7
View File
@@ -11,13 +11,11 @@ export function cleanGeneratedCommitMessage(raw: string): string {
// answer field when the output is such an envelope.
if (text.startsWith('{')) {
try {
const parsed = JSON.parse(text) as unknown
if (
typeof parsed === 'object' &&
parsed !== null &&
typeof (parsed as Record<string, unknown>).text === 'string'
) {
text = (parsed as Record<string, string>).text.trim()
const parsed: unknown = JSON.parse(text)
const answer: unknown =
typeof parsed === 'object' && parsed !== null ? Reflect.get(parsed, 'text') : undefined
if (typeof answer === 'string') {
text = answer.trim()
}
} catch {
// not a JSON envelope; fall through to the plain-text cleanup
+1 -1
View File
@@ -8,7 +8,7 @@ const { homedirMock } = vi.hoisted(() => ({
}))
vi.mock('os', async () => {
const actual = (await vi.importActual('os')) as Record<string, unknown>
const actual = await vi.importActual<typeof import('os')>('os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
return { ...actual, homedir: homedirMock }
})
+15 -16
View File
@@ -14,6 +14,10 @@ const JCODE_JOURNAL_CHUNK_BYTES = 64 * 1024
const JCODE_JSON_DOC_MAX_PARSE_BYTES = 8 * 1024 * 1024
const EMPTY_REGION = Buffer.alloc(0)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function resolveJcodeSessionsDir(
env: NodeJS.ProcessEnv = process.env,
homeDir: string = homedir()
@@ -58,11 +62,8 @@ function messageText(message: Record<string, unknown>): string | null {
parts.push(block)
continue
}
if (block && typeof block === 'object') {
const text = (block as Record<string, unknown>).text
if (typeof text === 'string') {
parts.push(text)
}
if (isRecord(block) && typeof block.text === 'string') {
parts.push(block.text)
}
}
const joined = parts.join(' ').replace(/\s+/g, ' ').trim()
@@ -99,19 +100,18 @@ function readLastUserMessageFromJournalLines(
} catch {
continue
}
if (typeof entry !== 'object' || entry === null) {
if (!isRecord(entry)) {
continue
}
const appendMessages = (entry as Record<string, unknown>).append_messages
const appendMessages = entry.append_messages
if (!Array.isArray(appendMessages)) {
continue
}
for (let messageIndex = appendMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = appendMessages[messageIndex]
if (typeof message !== 'object' || message === null) {
const record = appendMessages[messageIndex]
if (!isRecord(record)) {
continue
}
const record = message as Record<string, unknown>
if (record.role !== 'user' || isInjectedContextMessage(record)) {
continue
}
@@ -214,20 +214,19 @@ function readLastUserMessageFromJson(
return null
}
try {
const parsed = JSON.parse(readFileSync(jsonPath, 'utf8')) as unknown
if (typeof parsed !== 'object' || parsed === null) {
const parsed: unknown = JSON.parse(readFileSync(jsonPath, 'utf8'))
if (!isRecord(parsed)) {
return null
}
const messages = (parsed as Record<string, unknown>).messages
const messages = parsed.messages
if (!Array.isArray(messages)) {
return null
}
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index]
if (typeof message !== 'object' || message === null) {
const record = messages[index]
if (!isRecord(record)) {
continue
}
const record = message as Record<string, unknown>
if (record.role !== 'user' || isInjectedContextMessage(record)) {
continue
}