Files
orca/docs/reference/jcode-hook-events.md
T
Neilandczzczz 4c2cb3cf12 fix(jcode): quote the managed hook path, drop tools from patch prompts
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>
2026-10-03 01:49:40 -07:00

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_start only fires on the streaming turn path (TUI, desktop, swarm workers, headless sessions), not jcode run. It is what fills the otherwise blank window between a submitted prompt and the first tool call.
  • Only pre_tool carries tool_input. post_tool reports the name and the outcome, so the tool preview has to be held from the matching pre_tool.
  • Every jcode tool schema has an intent string 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:

  1. 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 as orca_post_jcode_event >/dev/null 2>&1 &, so the inherited pipes are closed and the script exits immediately.
  2. 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 big write) 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.