fix(jcode): address CodeRabbit review on #22539

- Windows posted no payload at all: the shared builder reads `payload@-` from
  stdin, which the gate has already drained and observer hooks never receive, so
  every Windows event was dropped. Write the env var to a temp file and pipe it.
- The daemon pre-warm never fired for daemon-host spawns, which is the default
  local path; it now runs there too, and from the final env so the daemon gets the
  hook port and token.
- A failed runtime-dir mkdir took down every local terminal, jcode or not.
- removeJcodeManagedHooks matched the raw line, so a user hook whose comment
  mentioned the managed script was deleted; matching on Windows never worked.
- A managed entry left by a copied home or a platform switch is now repointed
  instead of being reported as user-owned forever.
- The OSC colour skip only checked launchAgent, so a command- or telemetry-named
  jcode pane still leaked the reply into its composer.
- `['hooks']` and a commented scalar are recognised, instead of appending a
  second [hooks] table that makes jcode reject the whole config.
- A failed tool's error is marked as tool output rather than agent prose.
- The vault keeps a session's stored name, counts its tokens, and skips
  background_task and [Scheduled task] turns.

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 888e3c4003
commit a7b9358c47
12 changed files with 275 additions and 40 deletions
@@ -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)
})
@@ -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<string, 'user' | 'assistant' | 'tool'
tool: 'tool'
}
/** jcode marks injected session-context envelopes with role user + display_role system. */
/** Messages jcode itself renders as internal rather than conversation.
*
* `display_role` is `System | BackgroundTask` (StoredDisplayRole in
* crates/jcode-session-types/src/lib.rs), and a scheduled run opens with a
* `[Scheduled task]` user turn. Counting either inflates the message count and can
* take over the title and preview. */
function isInjectedContextMessage(message: Record<string, unknown>): 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('<system-reminder>')
}
/** Sum of a stored message's usage, matching the other parsers' input+output total. */
function jcodeMessageTokens(message: Record<string, unknown>): 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)
}
+24 -2
View File
@@ -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<voi
// Why: LocalPtyProvider.spawn creates the dir async; daemon-host spawns
// skip that provider, so ensure sync here — an extra await before
// provider.spawn would reorder the pane-spawn reservation race.
let runtimeDirReady = true
if (ctx.isDaemonHostSpawn) {
mkdirSync(jcodeEnv[JCODE_RUNTIME_DIR_ENV_KEY], { recursive: true })
try {
mkdirSync(jcodeEnv[JCODE_RUNTIME_DIR_ENV_KEY], { recursive: true })
} catch {
// Why non-fatal: this dir is stamped on every local pane, so an EACCES on
// a shared /tmp/orca-jcode or a read-only TMPDIR would stop a plain shell
// from opening. Without it jcode falls back to its own default daemon.
runtimeDirReady = false
}
}
if (runtimeDirReady) {
Object.assign(ctx.baseEnv, jcodeEnv)
// Why here too: daemon-host spawns never reach LocalPtyProvider.spawn, so
// without this the pre-warm — the whole point of which is to beat jcode's
// 5s socket budget on a cold runtime dir — would not fire for those panes.
if (ctx.isDaemonHostSpawn) {
prewarmJcodeDaemon({
launchAgent: args.launchAgent,
runtimeDir: jcodeEnv[JCODE_RUNTIME_DIR_ENV_KEY],
cwd: args.cwd,
env: ctx.baseEnv
})
}
}
Object.assign(ctx.baseEnv, jcodeEnv)
}
}
ctx.validatedPaneKey = ctx.stablePaneKey
@@ -51,3 +51,29 @@ it('does not answer jcode startup color queries but keeps keyboard support', ()
getStartupTerminalIngressIntent({ launchAgent: 'jcode', terminalColorQueryReplies: colors })
).toBeUndefined()
})
it('skips jcode startup colors when only the command or telemetry names it', () => {
// 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 })
})
@@ -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({
+33
View File
@@ -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')
+42 -17
View File
@@ -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<string, string>
@@ -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<string, number>()
const staleKeyIndexes = new Map<string, number>()
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
+15 -1
View File
@@ -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)
+17 -2
View File
@@ -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(),
''
+18 -10
View File
@@ -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()])
@@ -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
})
}
+12 -1
View File
@@ -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<Record<string, string> | 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
}