diff --git a/src/main/ai-vault/session-scanner-jcode-parser.test.ts b/src/main/ai-vault/session-scanner-jcode-parser.test.ts index 1374ad087bb..d97bf4dad52 100644 --- a/src/main/ai-vault/session-scanner-jcode-parser.test.ts +++ b/src/main/ai-vault/session-scanner-jcode-parser.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { parseJcodeSessionFile } from './session-scanner-jcode-parser' +import { parseJcodeSessionContent, parseJcodeSessionFile } from './session-scanner-jcode-parser' let tempDirs: string[] = [] @@ -93,3 +93,39 @@ describe('parseJcodeSessionFile', () => { expect(session?.messageCount).toBe(0) }) }) + +it('keeps the session\u2019s stored name, counts tokens, and skips internal turns', () => { + const session = parseJcodeSessionContent( + { + path: '/home/u/.jcode/sessions/session_x.json', + mtimeMs: 1, + modifiedAt: '2026-05-01T10:12:00.000Z' + }, + JSON.stringify({ + id: 'session_x', + title: 'Release prep', + model: 'claude-haiku-4-5', + messages: [ + // display_role background_task is StoredDisplayRole::BackgroundTask. + { id: 'm0', role: 'user', display_role: 'background_task', content: 'internal' }, + { id: 'm1', role: 'user', content: '[Scheduled task] nightly sweep' }, + { + id: 'm2', + role: 'user', + content: 'Fix the greet helper', + token_usage: { input_tokens: 10, output_tokens: 4 } + }, + { + id: 'm3', + role: 'assistant', + content: 'Done.', + token_usage: { input_tokens: 2, output_tokens: 6 } + } + ] + }), + 'linux' + ) + expect(session?.title).toBe('Release prep') + expect(session?.messageCount).toBe(2) + expect(session?.totalTokens).toBe(22) +}) diff --git a/src/main/ai-vault/session-scanner-jcode-parser.ts b/src/main/ai-vault/session-scanner-jcode-parser.ts index 0310bd77d63..e425b896af8 100644 --- a/src/main/ai-vault/session-scanner-jcode-parser.ts +++ b/src/main/ai-vault/session-scanner-jcode-parser.ts @@ -17,6 +17,7 @@ import { extractString, normalizeTitleText } from './session-scanner-values' +import { numberValue } from './session-scanner-token-values' type ParserSessionOptions = { executionHostId?: ExecutionHostId @@ -31,9 +32,27 @@ const PREVIEW_ROLE_BY_MESSAGE_ROLE: Record): boolean { - return message.display_role === 'system' || message.role === 'system' + if (message.display_role === 'system' || message.display_role === 'background_task') { + return true + } + if (message.role === 'system') { + return true + } + const text = extractContentText(message.content) ?? '' + return text.startsWith('[Scheduled task]') || text.startsWith('') +} + +/** Sum of a stored message's usage, matching the other parsers' input+output total. */ +function jcodeMessageTokens(message: Record): number { + const usage = asRecord(message.token_usage) + return usage ? numberValue(usage.input_tokens) + numberValue(usage.output_tokens) : 0 } export async function parseJcodeSessionFile( @@ -75,6 +94,12 @@ export function parseJcodeSessionContent( messages: options.messages }) accumulator.model = extractString(record.model) + // Why before the message walk: a session the user named keeps that name, rather + // than being retitled from whatever its first prompt happened to say. + accumulator.title = + normalizeTitleText(extractString(record.custom_title) ?? '') || + normalizeTitleText(extractString(record.title) ?? '') || + null accumulator.cwd = extractString(record.working_dir) ?? extractString(record.working_directory) updateTimeline(accumulator, record.created_at) updateTimeline(accumulator, record.updated_at) @@ -108,5 +133,6 @@ function consumeJcodeMessage( } } accumulator.messageCount++ + accumulator.totalTokens += jcodeMessageTokens(messageRecord) addPreviewContent(accumulator, role, content, messageRecord.timestamp) } diff --git a/src/main/ipc/pty/ipc/spawn-env.ts b/src/main/ipc/pty/ipc/spawn-env.ts index 34865eabde1..df947520e7f 100644 --- a/src/main/ipc/pty/ipc/spawn-env.ts +++ b/src/main/ipc/pty/ipc/spawn-env.ts @@ -17,6 +17,7 @@ import { CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' +import { prewarmJcodeDaemon } from '../../../jcode/daemon-prewarm' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { resolvePathEnvKey } from '../../../pty/windows-environment-path' import { routesFreshSpawnsToLocalProvider } from '../host-env/fresh-spawn-routing' @@ -153,10 +154,31 @@ export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise { + // Why: a quick-launch pane carries no launchAgent, and it leaks the same + // composer text the launchAgent-based skip was added to prevent. + const colors = { foreground: '#ffffff', background: '#282c34' } + for (const launch of [ + { command: 'jcode', telemetry: { agent_kind: 'jcode' } }, + { telemetry: { agent_kind: 'jcode' } }, + { command: 'jcode' } + ]) { + expect( + getStartupTerminalIngressIntent({ + ...launch, + terminalColorQueryReplies: colors, + terminalKittyKeyboardProtocol: true + }) + ).toEqual({ colors: {}, kittyKeyboardProtocol: true, deadlineMs: 5000 }) + } + // A different agent still gets its colors. + expect( + getStartupTerminalIngressIntent({ + launchAgent: 'claude', + terminalColorQueryReplies: colors + }) + ).toEqual({ colors, deadlineMs: 5000 }) +}) diff --git a/src/main/ipc/terminal-startup-color-query-replies.ts b/src/main/ipc/terminal-startup-color-query-replies.ts index c081d0982e4..b6060f6be5c 100644 --- a/src/main/ipc/terminal-startup-color-query-replies.ts +++ b/src/main/ipc/terminal-startup-color-query-replies.ts @@ -28,8 +28,21 @@ function isAgentLaunch(args: { // Why: jcode paints its own theme and fires its OSC 10/11 burst before its TUI // input loop is ready, so the cooked reply (`10;rgb:…`) lands in the composer as // pre-typed text (same class as #12112, which fixed opencode). -export function agentSkipsStartupOscColorQueryReplies(launchAgent: unknown): boolean { - return launchAgent === 'jcode' +// +// Why all three signals and not just launchAgent: a pane can name jcode through the +// telemetry kind or the command alone (a `jcode` quick-launch carries no launchAgent), +// and those panes leak exactly the same composer text. +export function agentSkipsStartupOscColorQueryReplies(args: { + launchAgent?: unknown + telemetry?: { agent_kind?: unknown } | undefined + command?: string + launchConfig?: SleepingAgentLaunchConfig +}): boolean { + if (args.launchAgent === 'jcode' || args.telemetry?.agent_kind === 'jcode') { + return true + } + const command = args.launchConfig?.agentCommand?.trim() || args.command?.trim() || '' + return command.length > 0 && recognizeAgentProcessFromCommandLine(command)?.agent === 'jcode' } export function getStartupTerminalIngressIntent(args: { @@ -42,7 +55,7 @@ export function getStartupTerminalIngressIntent(args: { }) { // Why colours for every PTY: an agent typed into a plain shell later queries too, and these // seed an owner that has not been pushed the host's viewer colours yet. - const colors = agentSkipsStartupOscColorQueryReplies(args.launchAgent) + const colors = agentSkipsStartupOscColorQueryReplies(args) ? {} : (normalizeColorQueryReplyColors(args.terminalColorQueryReplies) ?? {}) return parsePtyStartupIngressIntent({ diff --git a/src/main/jcode/hook-config.test.ts b/src/main/jcode/hook-config.test.ts index c51093b941d..b82e6a417b9 100644 --- a/src/main/jcode/hook-config.test.ts +++ b/src/main/jcode/hook-config.test.ts @@ -119,6 +119,39 @@ turn_end = ${tomlQuoteString(MANAGED_COMMAND)} expect(result.content).not.toContain(`turn_end = ${tomlQuoteString(MANAGED_COMMAND)}`) }) + it('never deletes a user hook whose comment merely mentions the managed script', () => { + // Why: matching the raw line treated the comment as the value — real config loss. + const source = `[hooks] +turn_end = "~/bin/mine" # replaces agent-hooks/jcode-hook.sh +` + const removed = removeJcodeManagedHooks(source, 'jcode-hook.sh') + expect(removed.changed).toBe(false) + expect(removed.content).toContain('~/bin/mine') + const applied = applyJcodeManagedHooks(source, EVENTS, MANAGED_COMMAND, 'jcode-hook.sh') + expect(applied.userOwnedEvents).toContain('turn_end') + }) + + it('repoints a managed entry left behind by a copied home or a platform switch', () => { + // Why: isManaged matches any agent-hooks/jcode-hook path, but getStatus demands + // the exact script path — a stale entry stuck the install on `partial` forever + // with no Orca action able to repair it. + const stale = '/Users/old/.orca/agent-hooks/jcode-hook.sh' + const source = `[hooks]\nturn_end = ${tomlQuoteString(stale)}\n` + const result = applyJcodeManagedHooks(source, EVENTS, MANAGED_COMMAND, 'jcode-hook.sh') + expect(result.userOwnedEvents).toEqual([]) + expect(result.content).toContain(`turn_end = ${tomlQuoteString(MANAGED_COMMAND)}`) + expect(result.content).not.toContain(stale) + }) + + it('accepts a quoted hooks table and a commented scalar', () => { + const source = `['hooks']\npre_tool_timeout_ms = 5000 # ms\n` + expect(parseJcodeHooksTable(source)).toEqual({}) + const result = applyJcodeManagedHooks(source, EVENTS, MANAGED_COMMAND, 'jcode-hook.sh') + // Why: a second [hooks] table makes jcode reject the whole config. + expect(result.content.match(/^\[?'?hooks/gm)?.length).toBe(1) + expect(result.content).toContain('pre_tool_timeout_ms = 5000 # ms') + }) + it('keeps CRLF line endings when editing a Windows-owned config', () => { const source = '[hooks]\r\nturn_end = "~/bin/mine"\r\n' const result = applyJcodeManagedHooks(source, EVENTS, MANAGED_COMMAND, 'jcode-hook.sh') diff --git a/src/main/jcode/hook-config.ts b/src/main/jcode/hook-config.ts index b5bea534c8c..57d666cbd04 100644 --- a/src/main/jcode/hook-config.ts +++ b/src/main/jcode/hook-config.ts @@ -10,7 +10,7 @@ import { updateTomlLineScanState, parseTomlSingleLineStringValue } from '../codex/config-toml-line-scan' -import { parseTomlKeyPath } from '../codex/config-toml-key-path' +import { parseTomlKeyPath, parseTomlTableHeaderPath } from '../codex/config-toml-key-path' import { createManagedCommandMatcher } from '../agent-hooks/installer-utils' export type JcodeHooksTable = Record @@ -27,7 +27,7 @@ export function parseJcodeHooksTable(content: string): JcodeHooksTable | null { } const header = getTomlTableHeader(line) if (header) { - inHooksTable = parseTomlTablePath(header)?.join('.') === 'hooks' + inHooksTable = isHooksTableHeader(header) state = updateTomlLineScanState(state, line) continue } @@ -59,18 +59,21 @@ export function parseJcodeHooksTable(content: string): JcodeHooksTable | null { return hooks } -const TOML_SCALAR_VALUE_RE = /^\s*(?:true|false|[-+]?\d[\d_]*(?:\.[\d_]+)?(?:[eE][-+]?\d+)?)\s*$/ +// Why the comment tail: jcode ships `pre_tool_timeout_ms = 5000` and a user may +// annotate it. Rejecting that set parseError, which made install() and getStatus() +// report `error` and blocked the whole integration over one ordinary comment. +const TOML_SCALAR_VALUE_RE = + /^\s*(?:true|false|[-+]?\d[\d_]*(?:\.[\d_]+)?(?:[eE][-+]?\d+)?)\s*(?:#.*)?\r?$/ function isTomlScalarValue(line: string, offset: number): boolean { return TOML_SCALAR_VALUE_RE.test(line.slice(offset)) } -function parseTomlTablePath(header: string): string[] | null { - const trimmed = header.trim().replace(/^\[+|\]+$/g, '') - if (trimmed.length === 0) { - return null - } - return trimmed.split('.').map((segment) => segment.trim().replace(/^"|"$/g, '')) +// Why the shared parser: it already handles quoted (`['hooks']`) and dotted keys. +// Rolling our own missed those spellings, so apply() appended a SECOND [hooks] +// table and jcode then failed to parse the whole config. +function isHooksTableHeader(header: string): boolean { + return parseTomlTableHeaderPath(header)?.segments.join('.') === 'hooks' } export function tomlQuoteString(value: string): string { @@ -99,6 +102,7 @@ export function applyJcodeManagedHooks( let inHooksTable = false let hooksHeaderIndex = -1 const existingKeyIndexes = new Map() + const staleKeyIndexes = new Map() const userOwnedEvents: string[] = [] for (let index = 0; index < lines.length; index += 1) { const line = lines[index] ?? '' @@ -111,7 +115,7 @@ export function applyJcodeManagedHooks( if (inHooksTable) { break } - if (parseTomlTablePath(header)?.join('.') === 'hooks') { + if (isHooksTableHeader(header)) { inHooksTable = true hooksHeaderIndex = index } @@ -123,10 +127,20 @@ export function applyJcodeManagedHooks( if (parsed && line[parsed.end] === '=' && parsed.segments.length === 1) { const key = parsed.segments[0] if (events.includes(key)) { - if (isManaged(line)) { + // Why the parsed value and not the raw line: a user-owned command whose + // trailing comment merely mentions the managed script would otherwise read + // as managed, and on Windows tomlQuoteString escapes `\` so the raw line + // never matched Orca's own value at all. + const value = parseTomlSingleLineStringValue(line, parsed.end + 1)?.value + if (!isManaged(value)) { + userOwnedEvents.push(key) + } else if (value === managedCommand) { existingKeyIndexes.set(key, index) } else { - userOwnedEvents.push(key) + // A managed entry pointing at a script that no longer exists — a copied + // ~/.jcode, or a platform switch between .sh and .cmd. Rewrite it, or the + // hooks stay broken with no Orca action able to repair them. + staleKeyIndexes.set(key, index) } } } @@ -135,20 +149,27 @@ export function applyJcodeManagedHooks( } const missing = events.filter( - (event) => !existingKeyIndexes.has(event) && !userOwnedEvents.includes(event) + (event) => + !existingKeyIndexes.has(event) && + !staleKeyIndexes.has(event) && + !userOwnedEvents.includes(event) ) const insertions: string[] = [] for (const event of missing) { insertions.push(`${event} = ${tomlQuoteString(managedCommand)}`) } - let result = content + const repointed = [...lines] + for (const [event, index] of staleKeyIndexes) { + repointed[index] = `${event} = ${tomlQuoteString(managedCommand)}` + } + let result = staleKeyIndexes.size > 0 ? repointed.join(eol) : content if (insertions.length > 0) { if (hooksHeaderIndex === -1) { // Why: append a new [hooks] table at the end; jcode re-reads config on // reload, so placement at EOF is safe. result = `${result.endsWith('\n') || result.length === 0 ? result : `${result}\n`}[hooks]${eol}${insertions.join(eol)}${eol}` } else { - const insertionLines = [...lines] + const insertionLines = [...repointed] insertionLines.splice(hooksHeaderIndex + 1, 0, ...insertions) result = insertionLines.join(eol) } @@ -181,14 +202,18 @@ export function removeJcodeManagedHooks( // Why: leaving the table stops the removal, but the rest of the file must // still be copied out — `kept` is the whole result, so breaking here once // truncated every table declared after [hooks]. - inHooksTable = parseTomlTablePath(header)?.join('.') === 'hooks' + inHooksTable = isHooksTableHeader(header) kept.push(line) state = updateTomlLineScanState(state, line) continue } if (inHooksTable) { const parsed = parseTomlKeyPath(line) - if (parsed && line[parsed.end] === '=' && parsed.segments.length === 1 && isManaged(line)) { + const value = + parsed && line[parsed.end] === '=' && parsed.segments.length === 1 + ? parseTomlSingleLineStringValue(line, parsed.end + 1)?.value + : undefined + if (value !== undefined && isManaged(value)) { changed = true state = updateTomlLineScanState(state, line) continue diff --git a/src/main/jcode/hook-gate-script.test.ts b/src/main/jcode/hook-gate-script.test.ts index 16d24dfc471..3378a4d83d3 100644 --- a/src/main/jcode/hook-gate-script.test.ts +++ b/src/main/jcode/hook-gate-script.test.ts @@ -40,7 +40,9 @@ describe.runIf(process.platform !== 'win32')('jcode managed hook as jcode runs i input: JSON.stringify({ content: 'y'.repeat(512 * 1024) }), // No ORCA_PANE_KEY: the script exits early, but only after taking stdin. env: { ...process.env, JCODE_HOOK_EVENT: 'pre_tool', ORCA_PANE_KEY: '' }, - timeout: 20_000, + // Generous on purpose: the claim is "does not hang", not "is fast", and a + // tight bound here is the same flake the dropped latency test had. + timeout: 60_000, stdio: ['pipe', 'pipe', 'pipe'] }) } finally { @@ -92,6 +94,18 @@ describe('the Windows managed hook', () => { expect(script).toContain('more.com') }) + it('posts jcode\u2019s payload, which never reaches stdin on Windows', () => { + const script = windowsScript() + // Why: the shared builder reads `payload@-`, but the gate has already drained + // stdin and observer hooks get a null one — so the payload has to come from the + // env var via a temp file, or the server sees no event name and drops everything. + expect(script).toContain('setlocal EnableDelayedExpansion') + expect(script).toContain('echo(!JCODE_HOOK_PAYLOAD!') + expect(script).toMatch(/type "%ORCA_JCODE_PAYLOAD_FILE%" \| .*curl\.exe/) + expect(script).toContain('hook_event_name=%JCODE_HOOK_EVENT%') + expect(script).toContain('del "%ORCA_JCODE_PAYLOAD_FILE%"') + }) + it('is a CRLF batch file that always exits 0', () => { const script = windowsScript() expect(script.startsWith('@echo off\r\n')).toBe(true) diff --git a/src/main/jcode/hook-service.ts b/src/main/jcode/hook-service.ts index 805664633b9..957deadef56 100644 --- a/src/main/jcode/hook-service.ts +++ b/src/main/jcode/hook-service.ts @@ -35,9 +35,17 @@ import { function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { + // Why a temp file rather than stdin: the shared builder posts `payload@-`, and on + // Windows jcode's payload never reaches stdin — the pre_tool gate has already + // drained it, and observer hooks are given a null stdin. Without this the server + // sees no event name and normalizeJcodeEvent drops every event, so a Windows pane + // would show no jcode status at all. + const payloadFile = '%ORCA_JCODE_PAYLOAD_FILE%' return [ '@echo off', - 'setlocal', + // EnableDelayedExpansion so `!JCODE_HOOK_PAYLOAD!` is written verbatim: plain + // `%VAR%` expansion re-parses the JSON's quotes and `&` as batch syntax. + 'setlocal EnableDelayedExpansion', // Why: endpoint file holds the live port/token; a PTY that outlives an Orca restart carries stale env, so `call` it to refresh (else PTY env). 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', // Why the guard comes first here, unlike the POSIX script: on Windows a hook @@ -50,7 +58,14 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // to our stdin and waits for us, so drain it before the POST or a tool input // larger than the pipe buffer stalls the agent mid-write. `if "%JCODE_HOOK_EVENT%"=="pre_tool" ${WINDOWS_HOOK_STDIN_DRAIN_COMMAND}`, - buildWindowsAgentHookPostCommand('jcode'), + `set "ORCA_JCODE_PAYLOAD_FILE=%TEMP%\\orca-jcode-hook-%RANDOM%%RANDOM%.json"`, + `>"${payloadFile}" echo(!JCODE_HOOK_PAYLOAD!`, + `type "${payloadFile}" | ${buildWindowsAgentHookPostCommand('jcode', [ + ' --data-urlencode "hook_event_name=%JCODE_HOOK_EVENT%" ^', + ' --data-urlencode "session_id=%JCODE_HOOK_SESSION_ID%" ^', + ' --data-urlencode "cwd=%JCODE_HOOK_CWD%" ^' + ])}`, + `del "${payloadFile}" 2>nul`, 'exit /b 0', ...buildWindowsHookStdinDrainEpilogue(), '' diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts index 46b8769d90b..9ae7966eca7 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -49,16 +49,14 @@ export async function spawnLocalPty( // filesystem syscalls. const jcodeRuntimeDir = args.env?.[JCODE_RUNTIME_DIR_ENV_KEY] if (jcodeRuntimeDir) { - await mkdir(jcodeRuntimeDir, { recursive: true }) - // Why here and not earlier: the dir must exist before the daemon binds its - // socket in it, and this is the last point before the shell starts — the - // head start the client's 5s ready budget needs on a cold runtime dir. - prewarmJcodeDaemon({ - launchAgent: args.launchAgent, - runtimeDir: jcodeRuntimeDir, - cwd: args.cwd, - env: args.env - }) + try { + await mkdir(jcodeRuntimeDir, { recursive: true }) + } catch { + // Why non-fatal: the dir is stamped on every local pane, so an EACCES on a + // shared /tmp/orca-jcode or a read-only TMPDIR would otherwise stop a plain + // shell from opening. Drop the variable and let jcode use its own default. + delete args.env?.[JCODE_RUNTIME_DIR_ENV_KEY] + } } const id = allocatePtyId(reattachId ?? undefined) return runCancelableLocalPtySpawn(id, async (throwIfCanceled, cancellation) => { @@ -85,6 +83,16 @@ export async function spawnLocalPty( plan, env: finalEnv }) + // Why here rather than beside the mkdir above: the daemon inherits this env, and + // only finalEnv carries the hook port and token that buildPtyHostEnv adds — the + // managed hook script exits without them, so a daemon warmed from the raw spawn + // env would report no lifecycle events at all. + prewarmJcodeDaemon({ + launchAgent: args.launchAgent, + runtimeDir: finalEnv[JCODE_RUNTIME_DIR_ENV_KEY], + cwd: args.cwd, + env: finalEnv + }) const fallbackHistory = historyResult?.historyDir ? historyResult : undefined const [spawn] = await Promise.all([loadLocalPtyRuntimeSpawn(), prepareMacosTccLoginShell()]) diff --git a/src/shared/agent-hook-listener/providers/jcode-events.ts b/src/shared/agent-hook-listener/providers/jcode-events.ts index de0d37f7414..b7a9e4a1df2 100644 --- a/src/shared/agent-hook-listener/providers/jcode-events.ts +++ b/src/shared/agent-hook-listener/providers/jcode-events.ts @@ -52,6 +52,9 @@ export function normalizeJcodeEvent( ) // Why the error text first: a failed turn's own message beats the reply it never replaced. const errorText = hookPayload.status === 'error' ? readString(hookPayload, 'error') : undefined + // Why flag it: an unmarked message reads as assistant prose downstream, so a failed + // tool's stderr would render as jcode's reply in native chat. + const errorIsToolOutput = errorText !== undefined && eventName === 'post_tool' return normalizeAgentStatusPayload({ state: stateName, @@ -63,6 +66,9 @@ export function normalizeJcodeEvent( toolName: snapshot.toolName, toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: errorText ?? snapshot.lastAssistantMessage + lastAssistantMessage: errorText ?? snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: errorIsToolOutput + ? true + : snapshot.lastAssistantMessageIsToolOutput }) } diff --git a/src/shared/jcode-runtime-dir.ts b/src/shared/jcode-runtime-dir.ts index 13345c2cbbb..697781f8c99 100644 --- a/src/shared/jcode-runtime-dir.ts +++ b/src/shared/jcode-runtime-dir.ts @@ -39,14 +39,25 @@ export function buildJcodeRuntimeDirEnv( * Ensures the per-pane jcode runtime dir exists before a PTY spawn (jcode fails * fast when it is missing) and returns the env to merge into the spawn env. * Async so the spawn hot path never blocks on a filesystem syscall. + * + * Why a failure returns undefined rather than throwing: this dir is stamped on + * EVERY local pane, not only jcode ones, so letting an EACCES on a shared + * /tmp/orca-jcode or a read-only TMPDIR propagate would stop a plain shell from + * opening. Without the dir jcode falls back to its own default daemon, which is + * the behaviour Orca had before per-pane isolation. */ export async function ensureJcodeRuntimeDir( paneKey: string, platform: NodeJS.Platform = process.platform ): Promise | undefined> { const env = buildJcodeRuntimeDirEnv(paneKey, platform) - if (env) { + if (!env) { + return undefined + } + try { await mkdir(env[JCODE_RUNTIME_DIR_ENV_KEY], { recursive: true }) + } catch { + return undefined } return env }