feat(jcode): evidence-backed status pipeline (turn_start, live pre_tool, questions)

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 4049e63714
commit 2ca85d2ba2
16 changed files with 566 additions and 165 deletions
+35
View File
@@ -84,6 +84,41 @@ session_start = "~/bin/mine"
expect(result.content).toContain('session_start = "~/bin/mine"')
})
it('keeps every table declared after [hooks] when removing managed entries', () => {
// Why: `remote` rebuilds the file from the lines it keeps, so an early exit at
// the next table header silently truncated the rest of a user's config.
const source = `[hooks]
turn_end = ${tomlQuoteString(MANAGED_COMMAND)}
pre_tool_timeout_ms = 5000
[terminal]
preferred = "ghostty"
[ui]
theme = "dark"
`
const result = removeJcodeManagedHooks(source, 'jcode-hook.sh')
expect(result.changed).toBe(true)
expect(result.content).not.toContain(MANAGED_COMMAND)
expect(result.content).toContain('pre_tool_timeout_ms = 5000')
expect(result.content).toContain('[terminal]')
expect(result.content).toContain('preferred = "ghostty"')
expect(result.content).toContain('[ui]')
expect(result.content).toContain('theme = "dark"')
})
it('does not touch a managed-looking command outside the [hooks] table', () => {
const source = `[terminal]
spawn_hook = ${tomlQuoteString(MANAGED_COMMAND)}
[hooks]
turn_end = ${tomlQuoteString(MANAGED_COMMAND)}
`
const result = removeJcodeManagedHooks(source, 'jcode-hook.sh')
expect(result.content).toContain(`spawn_hook = ${tomlQuoteString(MANAGED_COMMAND)}`)
expect(result.content).not.toContain(`turn_end = ${tomlQuoteString(MANAGED_COMMAND)}`)
})
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')
+4 -6
View File
@@ -178,12 +178,10 @@ export function removeJcodeManagedHooks(
}
const header = getTomlTableHeader(line)
if (header) {
if (inHooksTable) {
break
}
if (parseTomlTablePath(header)?.join('.') === 'hooks') {
inHooksTable = true
}
// 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'
kept.push(line)
state = updateTomlLineScanState(state, line)
continue
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, it, vi } from 'vitest'
import { execFileSync } from 'node:child_process'
import { createServer, type Server } from 'node:net'
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
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>
return { ...actual, homedir: homedirMock }
})
import { JcodeHookService } from './hook-service'
import { getJcodeManagedScriptPath } from './hook-settings'
/** Installs the managed hook into a throwaway home and returns the script path. */
function installManagedScript(): { scriptPath: string; cleanup: () => void } {
const homeDir = mkdtempSync(join(tmpdir(), 'orca-jcode-gate-'))
homedirMock.mockReturnValue(homeDir)
vi.stubEnv('JCODE_HOME', join(homeDir, '.jcode'))
new JcodeHookService().install()
const scriptPath = getJcodeManagedScriptPath()
return {
scriptPath,
cleanup: () => {
vi.unstubAllEnvs()
rmSync(homeDir, { recursive: true, force: true })
}
}
}
describe.runIf(process.platform !== 'win32')('jcode managed hook as jcode runs it', () => {
it('returns immediately on pre_tool even when the hook server never answers', async () => {
const { scriptPath, cleanup } = installManagedScript()
// A server that accepts the connection and then never replies: curl holds it
// open until its own --max-time 1.5, which is what a synchronous gate would
// hand straight to the agent on every single tool call.
const blackHole: Server = createServer(() => {})
await new Promise<void>((resolve) => blackHole.listen(0, '127.0.0.1', resolve))
const address = blackHole.address()
const port = typeof address === 'object' && address ? address.port : 0
const endpointDir = mkdtempSync(join(tmpdir(), 'orca-jcode-endpoint-'))
try {
const endpoint = join(endpointDir, 'endpoint.sh')
writeFileSync(
endpoint,
`ORCA_AGENT_HOOK_PORT=${port}\nORCA_AGENT_HOOK_TOKEN=t\nexport ORCA_AGENT_HOOK_PORT ORCA_AGENT_HOOK_TOKEN\n`
)
// A tool input far larger than a 64 KB pipe buffer: jcode write_all()s this
// to the gate's stdin and awaits it, so a gate that never reads stdin stalls.
const bigToolInput = JSON.stringify({ content: 'x'.repeat(512 * 1024) })
const startedAt = Date.now()
execFileSync('/bin/sh', [scriptPath], {
input: bigToolInput,
env: {
...process.env,
ORCA_AGENT_HOOK_ENDPOINT: endpoint,
ORCA_PANE_KEY: 'tab-1:leaf-1',
JCODE_HOOK_EVENT: 'pre_tool',
JCODE_HOOK_SESSION_ID: 'session_gate_1',
JCODE_HOOK_PAYLOAD: JSON.stringify({ event: 'pre_tool', tool_name: 'write' })
},
// Why: the assertion below is the real gate; this only stops a regression
// from hanging the suite instead of failing it.
timeout: 20_000,
// stdio is the point of the test: jcode reads stderr to EOF, so an
// inherited pipe in a backgrounded child would hold the gate open for as
// long as the POST ran, detached or not.
stdio: ['pipe', 'pipe', 'pipe']
})
const elapsed = Date.now() - startedAt
// Comfortably under curl's 1.5s ceiling: a synchronous POST would sit on
// that ceiling for every tool call, and jcode's own budget is only 5s.
expect(elapsed).toBeLessThan(1_000)
} finally {
rmSync(endpointDir, { recursive: true, force: true })
await new Promise<void>((resolve) => blackHole.close(() => resolve()))
cleanup()
}
})
it('drains the gate stdin before exiting on a missing Orca environment', () => {
const { scriptPath, cleanup } = installManagedScript()
try {
const startedAt = Date.now()
execFileSync('/bin/sh', [scriptPath], {
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,
stdio: ['pipe', 'pipe', 'pipe']
})
expect(Date.now() - startedAt).toBeLessThan(2_000)
} finally {
cleanup()
}
})
it('posts synchronously for observer events, which jcode never waits on', () => {
const { scriptPath, cleanup } = installManagedScript()
try {
const script = readFileSync(scriptPath, 'utf8')
const gateBranch = script.slice(script.indexOf('if [ "$JCODE_HOOK_EVENT" = pre_tool ]'))
expect(gateBranch).toContain('orca_post_jcode_event >/dev/null 2>&1 &')
// The observer path keeps the plain call, so a slow POST cannot be lost to
// a script that exited first.
expect(script.trimEnd().endsWith('exit 0')).toBe(true)
expect(script).toContain('\norca_post_jcode_event\n')
} finally {
cleanup()
}
})
})
describe.runIf(process.platform !== 'win32')('managed script shape', () => {
it('writes an executable script jcode can exec directly', () => {
const { scriptPath, cleanup } = installManagedScript()
try {
mkdirSync(dirname(scriptPath), { recursive: true })
chmodSync(scriptPath, 0o755)
const script = readFileSync(scriptPath, 'utf8')
// Why: jcode parses the command shell-style but executes it directly, so the
// file itself must carry the interpreter.
expect(script.startsWith('#!/bin/sh\n')).toBe(true)
} finally {
cleanup()
}
})
})
+6 -2
View File
@@ -13,7 +13,11 @@ vi.mock('os', async () => {
})
import { JcodeHookService } from './hook-service'
import { getJcodeConfigPath, getJcodeManagedScriptPath } from './hook-settings'
import {
getJcodeConfigPath,
getJcodeManagedScriptPath,
JCODE_HOOK_EVENTS
} from './hook-settings'
import { tomlQuoteString } from './hook-config'
describe('JcodeHookService', () => {
@@ -50,7 +54,7 @@ describe('JcodeHookService', () => {
expect(status.managedHooksPresent).toBe(true)
const config = readFileSync(getJcodeConfigPath(), 'utf8')
for (const event of ['turn_end', 'session_start', 'session_end', 'post_tool']) {
for (const event of JCODE_HOOK_EVENTS) {
// Why: tomlQuoteString doubles backslashes, so the serialized value (not
// the raw path) is what appears in the config.
expect(config).toContain(`${event} = ${tomlQuoteString(getJcodeManagedScriptPath())}`)
+44 -15
View File
@@ -6,6 +6,7 @@ import {
buildWindowsAgentHookPostCommand,
writeManagedScript
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readTextFileRemote,
writeManagedScriptRemote,
@@ -13,7 +14,9 @@ import {
} from '../agent-hooks/installer-utils-remote'
import {
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue
buildWindowsHookStdinDrainEpilogue,
POSIX_HOOK_STDIN_DRAIN_COMMAND,
WINDOWS_HOOK_STDIN_DRAIN_COMMAND
} from '../agent-hooks/hook-stdin-contract'
import {
applyJcodeManagedHooks,
@@ -37,6 +40,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
// 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',
...buildWindowsHookEnvironmentGuardLines(),
// Why: pre_tool is jcode's gate — it writes the tool input to our stdin and
// waits for us. Drain it first so a tool input larger than the pipe buffer
// can never stall the agent mid-write.
`if "%JCODE_HOOK_EVENT%"=="pre_tool" ${WINDOWS_HOOK_STDIN_DRAIN_COMMAND}`,
buildWindowsAgentHookPostCommand('jcode'),
'exit /b 0',
...buildWindowsHookStdinDrainEpilogue(),
@@ -51,6 +58,12 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
// Why: pre_tool is jcode's gate. It writes the tool input to our stdin and
// waits for us, so drain stdin before any exit — a tool input larger than
// the pipe buffer would otherwise stall the agent mid-write.
'if [ "$JCODE_HOOK_EVENT" = pre_tool ]; then',
` ${POSIX_HOOK_STDIN_DRAIN_COMMAND}`,
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' exit 0',
'fi',
@@ -58,20 +71,30 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
// at 16 KB), so Orca forwards it verbatim instead of hand-building JSON in
// shell (unsafe for arbitrary text). The event name is also posted as a
// top-level form field for old payloads that omit it.
'printf \'%s\' "$JCODE_HOOK_PAYLOAD" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/jcode" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "hook_event_name=${JCODE_HOOK_EVENT}" \\',
' --data-urlencode "session_id=${JCODE_HOOK_SESSION_ID}" \\',
' --data-urlencode "cwd=${JCODE_HOOK_CWD}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'orca_post_jcode_event() {',
' printf \'%s\' "$JCODE_HOOK_PAYLOAD" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/jcode" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "hook_event_name=${JCODE_HOOK_EVENT}" \\',
' --data-urlencode "session_id=${JCODE_HOOK_SESSION_ID}" \\',
' --data-urlencode "cwd=${JCODE_HOOK_CWD}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'}',
// Why: jcode reads this gate's stderr to EOF before releasing the tool call, so
// the POST runs detached with both pipes closed. Orca observes the tool live and
// adds no latency; the gate always allows (Orca never blocks a jcode tool).
'if [ "$JCODE_HOOK_EVENT" = pre_tool ]; then',
' orca_post_jcode_event >/dev/null 2>&1 &',
' exit 0',
'fi',
'orca_post_jcode_event',
'exit 0',
''
].join('\n')
@@ -154,6 +177,12 @@ export class JcodeHookService {
return this.getStatus()
}
// Why: jcode invokes the script path recorded in its own config.toml, so an Orca
// upgrade that changes the script body must rewrite the file the user already has.
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getJcodeManagedScriptPath(), getManagedScript())
}
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
// Why: remote-Windows is out of scope for v1 (same as Devin); assume POSIX.
const remoteConfigPath = getJcodeRemoteConfigPath(remoteHome)
+19 -3
View File
@@ -6,11 +6,27 @@ import { join } from 'node:path'
const JCODE_SCRIPT_BASE = 'jcode-hook'
// Why: only observer hooks. pre_tool is a synchronous gate that would add
// startup latency to every tool call without gating anything for Orca.
export const JCODE_HOOK_EVENTS = ['turn_end', 'session_start', 'session_end', 'post_tool'] as const
// The lifecycle points Orca subscribes to, in the order jcode fires them.
// `pre_tool` is jcode's synchronous gate, but the managed script backgrounds its
// POST and exits 0 immediately, so Orca observes the tool without ever holding
// up a tool call. Without it a long `bash` would show no tool at all until it
// finished, and `request_permission` (the only jcode tool a human answers)
// would only be seen after the answer.
export const JCODE_HOOK_EVENTS = [
'session_start',
'turn_start',
'pre_tool',
'post_tool',
'turn_end',
'session_end'
] as const
export type JcodeHookEvent = (typeof JCODE_HOOK_EVENTS)[number]
/** jcode waits for this one; the managed script must never block on it. */
export function isJcodeGateHookEvent(event: JcodeHookEvent): boolean {
return event === 'pre_tool'
}
export function getJcodeConfigPath(env: NodeJS.ProcessEnv = process.env): string {
const explicit = env.JCODE_HOME?.trim()
return explicit ? join(explicit, 'config.toml') : join(homedir(), '.jcode', 'config.toml')