Three fixes, all on paths this PR could not exercise locally. The managed hook command was stored as a bare path. jcode tokenizes that string shell-style before exec'ing it directly (parse_hook_command, crates/jcode-terminal-launch/src/lib.rs): unquoted whitespace splits, and every unquoted backslash is consumed as an escape. So on Windows `C:\Users\me\.orca\agent-hooks\jcode-hook.cmd` reached exec as `C:Usersme.orcaagent-hooksjcode-hook.cmd` and no hook fired at all, and a POSIX home with a space split into two arguments. Store the path single-quoted (verbatim, backslashes included), falling back to double quotes for a path containing a single quote. Existing bare entries are already repointed by the stale-key path, and getStatus accepts both forms so the repair is not reported as a user-owned hook. The quoting helper was previously dead code that only tests called; the three production sites now use it. isJcodeManagedCommand also normalizes separators, since a `/`-only needle never matched a Windows entry. Commit-message generation feeds a staged patch to `jcode run` as the prompt — attacker-influenced text — while jcode's default profile exposes shell, read, write, and MCP. Pass `--tool-profile none`, which resolves to an empty allowed-tool set in jcode's config (base_allowed_tools), matching the read-only posture claude (plan) and codex (read-only) already take. docs/reference/jcode-hook-events.md was never actually in this PR: the repo ignores docs/** and tracks reference docs by allow-list only, so the captured-payload evidence four source comments point at was silently dropped. Allow-list it. Co-authored-by: czzczz <chanzrz_zbf@foxmail.com>
8.7 KiB
jcode hook events
What jcode actually emits, and why Orca's status mapping is shaped the way it is.
Everything below was captured from jcode v0.87.1 (944f747e9) by pointing every
[hooks] entry in config.toml at a script that appends $JCODE_HOOK_PAYLOAD to
a log, then running real turns. Re-capture before changing the mapping; do not
edit it from memory.
The six events
jcode's [hooks] table (crates/jcode-base/src/hooks.rs) has six lifecycle
points. Five are observers — detached, fire-and-forget, they can never slow
the agent. One, pre_tool, is a gate: jcode spawns it, writes the tool input
to its stdin, and waits for it to exit before the tool runs.
| Event | When | Orca state | Notable payload fields |
|---|---|---|---|
session_start |
TUI open, attach, or --resume |
none | source = create/attach/resume, model |
turn_start |
prompt submitted, before the model generates | working |
source, model |
pre_tool |
before each tool call (gate) | working |
tool_name, tool_input (argument JSON as a string) |
post_tool |
after each tool call | working |
tool_name, status, duration_ms, output_bytes/error |
turn_end |
turn finished | done |
status, duration_ms, model, last_assistant_text, error |
session_end |
session closed | done |
source = close |
session_start is identity-only. jcode fires it on an idle TUI open, so mapping
it to working would spin before the user has typed anything (same reason Devin
does not map its SessionStart).
Captured payloads
A jcode run turn that read one file and wrote another:
{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-haiku-4-5","session_id":"session_pawprint_1790149117838_071c2a106812396c","source":"create"}
{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"file_path\":\"sample.txt\",\"intent\":\"Read sample.txt to get its contents\"}","tool_name":"read"}
{"cwd":"/private/tmp/jcode-work","duration_ms":"0","event":"post_tool","output_bytes":"12","session_id":"session_pawprint_…","status":"ok","tool_name":"read"}
{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"content\":\"HELLO\",\"file_path\":\"out.txt\",\"intent\":\"Write uppercased contents of sample.txt to out.txt\"}","tool_name":"write"}
{"cwd":"/private/tmp/jcode-work","duration_ms":"9","event":"post_tool","output_bytes":"137","session_id":"session_pawprint_…","status":"ok","tool_name":"write"}
A TUI turn that failed upstream (note turn_start, which the run path does not emit):
{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-opus-5","session_id":"session_snail_…","source":"create"}
{"cwd":"/private/tmp/jcode-work","event":"turn_start","model":"claude-opus-5","session_id":"session_snail_…","source":"chat"}
{"cwd":"/private/tmp/jcode-work","duration_ms":"6868","error":"Anthropic API error (503 Service Unavailable): …","event":"turn_end","model":"claude-opus-5","session_id":"session_snail_…","status":"error"}
Three consequences the mapping depends on:
turn_startonly fires on the streaming turn path (TUI, desktop, swarm workers, headless sessions), notjcode run. It is what fills the otherwise blank window between a submitted prompt and the first tool call.- Only
pre_toolcarriestool_input.post_toolreports the name and the outcome, so the tool preview has to be held from the matchingpre_tool. - Every jcode tool schema has an
intentstring the model fills in. It is the preview fallback when no tool-specific key (file_path,command, …) matches.
Why Orca subscribes to the gate
pre_tool is the only event that can report a tool while it runs. Without it a
three-minute bash shows no tool at all until it finishes. Two rules keep the
gate from ever costing the agent anything:
- The POST is detached. jcode calls
child.wait_with_output(), which waits for the process and reads its stderr to EOF — a backgrounded child that inherited stderr would hold the gate open for as long as it ran. The managed script runs the POST asorca_post_jcode_event >/dev/null 2>&1 &, so the inherited pipes are closed and the script exits immediately. - stdin is drained first. jcode
write_alls the full tool input to the hook's stdin. A tool input larger than the pipe buffer (a bigwrite) would block that write until the gate timed out if nobody read it, so the script drains stdin before any exit path.
Orca never blocks a jcode tool call: the script always exits 0.
Questions and permissions
jcode has no interactive per-tool approval prompt. Its safety model
(crates/jcode-app-core/src/tool/bash_destructive_gate.rs) either denies a
command outright or asks the model to justify it — both inside the tool, with no
human in the loop. There is therefore no hook, and no terminal-title state, for
"jcode is waiting on you" during ordinary tool use.
The one tool a human answers is ambient mode's request_permission
(crates/jcode-app-core/src/tool/ambient.rs), resolved out of band with
jcode permissions. Orca maps a pre_tool for it to waiting and publishes the
tool input as the question card. post_tool for the same tool is not mapped —
by then the human has already answered.
Matching is by exact tool name. jcode's live tool set is agentgrep, apply_patch, bash, batch, bg, browser, compile_remote, conversation_search, edit, gmail, integration_tools, ls, macos_computer_use, maintainer_feedback, mcp, memory, multiedit, open, panel, patch, read, schedule, session_search, side_panel, skill_manage, swarm, todo, webfetch, websearch, write plus the ambient tools;
a substring rule over that set would be matching on coincidence.
Terminal titles
jcode paints OSC 0 titles roughly once a second. Captured sequence from one TUI session:
jcode → 🐍 jcode Snake → 🐍 jcode/creek Snake → 🌐 jcode Snake · work ~0s → … → 🌐 jcode Snake · last ~6s
The format is <emoji> jcode <session-name>[ · +N -M][ · work|last ~<duration>]
(crates/jcode-tui/src/tui/app/terminal_title.rs). Orca uses it for tab-bar
identity only — status comes from hooks, never from a parsed title. Note there is
no "needs input" title state; that is the same gap as above, not an omission in
the parser.
Per-pane daemons
jcode runs one server/client daemon per runtime dir, and lifecycle hooks fire
inside the daemon. Every TUI client connects the daemon the first pane started,
so without isolation a second jcode pane's events carry the first pane's
ORCA_PANE_KEY and its status lands on the wrong tab.
jcode does forward a client's terminal identity to hooks
(CLIENT_TERMINAL_ENV_VARS in crates/jcode-terminal-launch/src/lib.rs), but
that allowlist covers tmux/zellij/herdr and the terminal emulators — not
ORCA_PANE_KEY. Until it does, Orca stamps a per-pane JCODE_RUNTIME_DIR so
each pane gets its own daemon, socket, and lock. The value is a 16-hex hash of
the pane key because the socket path is capped at SUN_LEN (104 bytes) and a
full pane key never fits.
Config shape
[hooks] values accept a string or an array of strings (HookCommands in
crates/jcode-config-types/src/lib.rs), and jcode re-reads the config on reload,
so hooks can be added without restarting. jcode parses a hook command line
shell-style but executes it directly, not through a shell — the managed value
must be the script path, never an if [ -f … ] wrapper.
That shell-style parse is parse_hook_command
(crates/jcode-terminal-launch/src/lib.rs), and it is why Orca stores the path
shell-quoted. The tokenizer splits on unquoted whitespace and consumes every
unquoted backslash as an escape, so a bare Windows path reaches exec as
C:Usersme.orcaagent-hooksjcode-hook.cmd and no hook fires at all; a POSIX home
with a space splits into two arguments. Single quotes pass a path through
verbatim — backslashes are literal inside them — so Orca single-quotes by
default and falls back to double quotes (escaping \ and ") only for a path
that itself contains a single quote. The value is then TOML-quoted on the way
into the file, so neither the raw path nor the shell-quoted string appears
alone.