mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 16:02:37 +00:00
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:
committed by
Neil
co-authored by
czzczz
parent
888e3c4003
commit
a7b9358c47
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
''
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user