mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
* perf(claude): run the Windows Claude hook as a native orca-hook.exe
Each Claude hook event on Windows started cmd.exe (plus the user's cmd
AutoRun) and then curl.exe on top of the agent's own shell. A small signed
Rust binary now does the same synchronous POST in one process, under Git
Bash, PowerShell and direct CreateProcess alike.
- native/windows-agent-hook: std-only crate, same contract as the .cmd hook
(prints {}, env guards before stdin, endpoint re-read, 0.5s/1.5s caps,
waits for the listener reply, always exit 0). Its installed file name
selects the agent profile, so the registration stays a bare path.
- Packaged as resources/bin/orca-hook.exe (SignPath signs it with the other
inner PEs); afterPack fails on a missing or wrong-machine binary.
- Claude install copies it verbatim to ~/.orca/agent-hooks/claude-hook.exe
(temp + rename, running copy moved aside) and registers it; it falls back
to the .cmd hook when the bundle is missing, unsigned or cannot be copied.
Only Claude opts in, via windowsNativeHookExecutable.
- The managed-command matcher sweeps .exe entries alongside .cmd/.ps1/.sh.
- The CLI launcher and hook builds share windows-rust-executable-build.mjs,
which now also checks the PE machine.
Refs #20913
* fix(build): publish native Windows binaries atomically so parallel builds cannot collide
The Windows CI lane runs every orca-hook.exe suite in one vitest
invocation, each building through build-windows-agent-hook.mjs. The build
copied straight onto native/windows-agent-hook/.build/orca-hook.exe, and
CopyFile opens its destination unshared, so a second build (or a scanner
still holding the fresh image) failed with EBUSY.
- windows-rust-executable-build.mjs skips the publish when the output
already has the built bytes, otherwise stages a per-process temp file and
renames it over the output, retrying briefly while another handle holds it.
- The test fixture hands each suite a private copy of the build output, so
no suite reads or starts the shared file while another build replaces it.
Refs #20913
* refactor(claude): simplify the native Windows hook to one Claude binary
Review follow-up on the native hook (#20913): the same behaviour with less
machinery.
- The binary is built, shipped and installed as claude-hook.exe, so the
installed name matches its PE OriginalFilename. It is Claude-only, so the
file-name profile table and the per-agent opt-in flag are gone; the
registration is gated by the existing Claude-only usesWindowsEntry check.
- Install and refresh reuse writeManagedScript / refreshManagedScriptIfPresent
(now accepting bytes): temp file, rename, skip when identical. A copy whose
hook is still running cannot be replaced; install then registers the .cmd
hook and refresh keeps the old build until a later start, instead of the
rename-aside and *.retired sweep.
- The install-time Authenticode-presence check is dropped. Release CI now
requires resources\bin\claude-hook.exe to be signed (release-cut and the
signing rehearsal), next to resources\bin\orca.exe.
- getStatus counts either Windows form through findManagedEventHookForms in
hook-settings.ts, replacing managed-event-hook-status.ts.
- Rust: plain Claude constants, one endpoint-file lookup (last assignment
wins, as under `call`), one request write and a deadline-bounded drain.
Guard order and form encoding are unchanged.
- Build: PR CI builds the hook once before the Windows tests, so the suites
only read it; the launcher publish goes back to copyFileSync and the retry
loop and its test are gone. The PE-machine check lives only in afterPack.
Cargo.lock joins both crates' build fingerprints, and the launcher's alias
exports are removed.
Refs #20913
* test(ci): run the managed hook stdin-lifecycle suite in the Windows lane
Its claude-hook.exe case (and the other Windows-only cases) are gated per
test, so the lane-registration guard did not require the file and it ran
only on Linux, where those cases skip. Register it in both Windows lists.
Refs #20913
* feat(agent-hooks): ship the native hook as orca-agent-hook.exe <agent>
The native Windows hook (#20913) becomes one general binary that stays
Claude-only in behaviour.
- Named orca-agent-hook.exe everywhere: build output, PE OriginalFilename
and InternalName, resources\bin, the afterPack check, the installed copy
in ~/.orca/agent-hooks, and the release signed-target lists.
- It takes the agent as its first argument and Claude registers
`<path> claude`. The CLAUDE_JOB_DIR exit and the Grok/Devin drain belong
to that agent; an unknown or missing agent answers {} without reading
stdin. The managed-command matcher owns `orca-agent-hook.exe claude` and
still owns the old claude-hook.exe entry, so installs migrate; install
deletes a leftover claude-hook.exe when nothing runs it.
- A copy still running from the previous build no longer forces the .cmd
fallback. Identical bytes are not rewritten; otherwise the temp file is
renamed over the target, and when Windows refuses because the image is
mapped, the running copy is renamed to orca-agent-hook.exe.old first.
Install deletes a stale .old; only when that copy is also still running
does the install fall back to .cmd. writeManagedScript and
refreshManagedScriptIfPresent take the aside path, sharing one helper.
Refs #20913
* fix(agent-hooks): roll back a failed aside rename and keep an installed hook exe
Design-review follow-up on the native hook (#20913).
- If the running copy was moved to orca-agent-hook.exe.old but the new
file then could not take its name, the copy is renamed back before the
error is rethrown, so settings.json never points at a missing exe. The
rename helper now owns the `.old` name, next to the comment that
explains it; a mocked-fs unit test covers both the sync and async paths.
- A failed write, or a build with no bundled exe, keeps registering a copy
that is already installed (every build knows `claude`), so a dev checkout
and an installed build no longer flip settings.json between forms.
- The claude-hook.exe migration is gone: no release ever shipped that
name, so the legacy sweep, the `.exe` matcher needle and their test are
removed.
Refs #20913
* fix(agent-hooks): derive the native hook's agent with win32 path rules
getWindowsNativeHookAgent took the host's basename of the managed script
path, so on a POSIX host (Linux CI) a Windows path was not split and the
registered command became `orca-agent-hook.exe C:\...\claude`. It now takes
the path and applies path.win32.basename itself. The install destination
and the `.old` aside stay on host paths: they name files the host opens.
Refs #20913
---------
Co-authored-by: Orca Worker <orca-worker@localhost>
211 lines
5.8 KiB
JavaScript
Executable File
211 lines
5.8 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import { spawn, spawnSync } from 'node:child_process'
|
|
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
|
|
|
|
if (process.platform === 'win32') {
|
|
runNodeScript('config/scripts/build-windows-cli-launcher.mjs')
|
|
runNodeScript('config/scripts/build-windows-agent-hook.mjs')
|
|
process.exit(0)
|
|
}
|
|
|
|
if (process.platform === 'linux') {
|
|
runNodeScript('config/scripts/build-wayland-clipboard.mjs')
|
|
process.exit(0)
|
|
}
|
|
|
|
if (process.platform !== 'darwin') {
|
|
console.log(`[native-build] no macOS native computer build required on ${process.platform}`)
|
|
process.exit(0)
|
|
}
|
|
|
|
// Each compiler tree needs its own group so cancellation reaches Swift descendants.
|
|
const children = new Map()
|
|
let externalSignal = null
|
|
let stopping = false
|
|
let outputFailed = false
|
|
// Status of the child whose failure started cancellation; siblings we stop are not failures.
|
|
let firstFailure = null
|
|
let forceTimer
|
|
const signalHandlers = new Map()
|
|
|
|
process.on('SIGINT', handlerFor('SIGINT'))
|
|
process.on('SIGTERM', handlerFor('SIGTERM'))
|
|
// Own sessions do not see a terminal hangup; forward it so compilers do not outlive the shell.
|
|
process.on('SIGHUP', handlerFor('SIGHUP'))
|
|
for (const target of [process.stdout, process.stderr]) {
|
|
target.on('error', () => {
|
|
outputFailed = true
|
|
process.exitCode = 1
|
|
stopBuilds()
|
|
})
|
|
}
|
|
|
|
const exitCodes = await Promise.all(
|
|
['build:computer-macos', 'build:keyboard-layout-macos', 'build:notification-status-macos'].map(
|
|
(scriptName) => runPnpmScript(scriptName)
|
|
)
|
|
)
|
|
clearTimeout(forceTimer)
|
|
for (const [signal, handler] of signalHandlers) {
|
|
process.removeListener(signal, handler)
|
|
}
|
|
if (externalSignal) {
|
|
process.kill(process.pid, externalSignal)
|
|
} else if (firstFailure?.signal) {
|
|
// Node ignores some signals (SIGPIPE); the build still failed if the re-raise is a no-op.
|
|
process.exitCode = 1
|
|
process.kill(process.pid, firstFailure.signal)
|
|
} else {
|
|
process.exitCode = firstFailure?.code ?? Math.max(outputFailed ? 1 : 0, ...exitCodes)
|
|
}
|
|
|
|
function handlerFor(signal) {
|
|
if (!signalHandlers.has(signal)) {
|
|
signalHandlers.set(signal, () => {
|
|
externalSignal ??= signal
|
|
stopBuilds(signal)
|
|
})
|
|
}
|
|
return signalHandlers.get(signal)
|
|
}
|
|
|
|
function stopBuilds(signal = 'SIGTERM') {
|
|
if (stopping) {
|
|
return
|
|
}
|
|
stopping = true
|
|
terminateAll(signal)
|
|
if (children.size > 0) {
|
|
forceTimer ??= setTimeout(() => terminateAll('SIGKILL'), 2_000)
|
|
}
|
|
}
|
|
|
|
function terminateAll(signal) {
|
|
for (const [child, label] of children) {
|
|
if (!child.pid) {
|
|
continue
|
|
}
|
|
console.log(`[native-build] stopping ${label} (${signal})`)
|
|
try {
|
|
process.kill(-child.pid, signal)
|
|
} catch {
|
|
// group already gone
|
|
try {
|
|
child.kill(signal)
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
function runPnpmScript(scriptName) {
|
|
if (stopping) {
|
|
return Promise.resolve(1)
|
|
}
|
|
const label = scriptName.replace(/^build:|-macos$/g, '')
|
|
const { command, prefixArgs, shell } = resolvePnpmCliInvocation()
|
|
const child = spawn(command, [...prefixArgs, 'run', scriptName], {
|
|
detached: true,
|
|
shell,
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
})
|
|
children.set(child, scriptName)
|
|
pipePrefixed(child.stdout, label, process.stdout)
|
|
pipePrefixed(child.stderr, label, process.stderr)
|
|
|
|
return new Promise((resolve) => {
|
|
let failed = false
|
|
child.on('error', (error) => {
|
|
failed = true
|
|
console.error(`[${label}] ${error.message}`)
|
|
if (!stopping) {
|
|
firstFailure = { code: 1, signal: null }
|
|
}
|
|
stopBuilds()
|
|
})
|
|
let exited = false
|
|
let closeTimer
|
|
// A descendant that inherited the pipes must not hold the launcher open forever.
|
|
const armReap = () => {
|
|
clearTimeout(closeTimer)
|
|
// A backpressure pause also delays 'close'; only count time spent actually draining.
|
|
if (child.stdout.isPaused() || child.stderr.isPaused()) {
|
|
return
|
|
}
|
|
closeTimer = setTimeout(() => {
|
|
console.error(`[native-build] ${label} left descendants holding its output; reaping them`)
|
|
try {
|
|
process.kill(-child.pid, 'SIGKILL')
|
|
} catch {}
|
|
child.stdout.destroy()
|
|
child.stderr.destroy()
|
|
}, 2_000)
|
|
}
|
|
for (const stream of [child.stdout, child.stderr]) {
|
|
stream.on('pause', () => clearTimeout(closeTimer))
|
|
stream.on('resume', () => {
|
|
if (exited) {
|
|
armReap()
|
|
}
|
|
})
|
|
}
|
|
child.on('exit', (code, signal) => {
|
|
if (code !== 0 || signal) {
|
|
if (!stopping) {
|
|
firstFailure = { code: code ?? 1, signal }
|
|
}
|
|
stopBuilds()
|
|
}
|
|
exited = true
|
|
armReap()
|
|
})
|
|
// Re-raise the parent's signal only after every child and its output pipes close.
|
|
child.on('close', (code, signal) => {
|
|
clearTimeout(closeTimer)
|
|
children.delete(child)
|
|
resolve(failed || signal ? 1 : (code ?? 1))
|
|
})
|
|
})
|
|
}
|
|
|
|
function pipePrefixed(stream, label, target) {
|
|
stream.setEncoding('utf8')
|
|
let buffer = ''
|
|
stream.on('data', (chunk) => {
|
|
if (target.destroyed) {
|
|
return
|
|
}
|
|
buffer += chunk
|
|
const lines = buffer.split('\n')
|
|
buffer = lines.pop() ?? ''
|
|
for (const line of lines) {
|
|
target.write(`[${label}] ${line}\n`)
|
|
}
|
|
if (target.writableNeedDrain) {
|
|
stream.pause()
|
|
const resume = () => {
|
|
target.off('drain', resume)
|
|
target.off('close', resume)
|
|
stream.resume()
|
|
}
|
|
target.once('drain', resume)
|
|
target.once('close', resume)
|
|
}
|
|
})
|
|
stream.on('end', () => {
|
|
if (buffer.length > 0 && !target.destroyed) {
|
|
target.write(`[${label}] ${buffer}\n`)
|
|
}
|
|
})
|
|
}
|
|
|
|
function runNodeScript(scriptPath) {
|
|
const result = spawnSync(process.execPath, [scriptPath], { stdio: 'inherit' })
|
|
if (result.signal) {
|
|
process.kill(process.pid, result.signal)
|
|
}
|
|
if (result.status !== 0 || result.error) {
|
|
process.exit(result.status ?? 1)
|
|
}
|
|
}
|