Files
wehub-resource-sync a789495a98
CI / Quality Guardrails (push) Waiting to run
CI / Build & Test (macos-latest) (push) Waiting to run
CI / Build & Test (ubuntu-latest) (push) Waiting to run
CI / Build & Test (windows-latest) (push) Waiting to run
CI / Format (push) Waiting to run
CI / PowerShell Syntax (push) Waiting to run
CI / Windows Cross-Target Check (Linux) (push) Waiting to run
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

6.3 KiB

Spawn Hook: External Control of Headed Session Spawns

jcode opens new terminal windows in several flows: swarm agent spawning (swarm spawn with spawn_mode=visible), resume-in-new-terminal, self-dev sessions, restart restores, and jade relay launches. By default jcode detects an installed terminal emulator (kitty, wezterm, alacritty, gnome-terminal, ...) and opens a new OS window.

The spawn hook lets an external program take over this spawn so it can decide where and how the session appears: a tmux pane, a kitty tab, a zellij pane, a tab in a wrapper app like herd, a specific monitor/workspace, etc.

Configuration

# ~/.jcode/config.toml
[terminal]
spawn_hook = "tmux new-window"

Or per-environment:

export JCODE_SPAWN_HOOK="tmux new-window"
# An empty value disables a config-file hook:
export JCODE_SPAWN_HOOK=

Env always wins over the config file.

Contract

When a headed spawn happens and a hook is configured, jcode runs:

<spawn_hook> <jcode-binary> <args...>
  • The hook command is parsed shell-style (quotes and backslash escapes work), but it is executed directly, not through a shell.
  • The jcode binary and its full argument list are appended as extra argv entries (the familiar $TERMINAL -e <cmd> convention).
  • The hook's working directory is the session working directory.
  • The hook process is detached; jcode does not wait for it.
  • If the hook fails to start (binary missing, parse error), jcode logs a warning and falls back to its built-in terminal detection.

Metadata environment

The hook (and any terminal spawned by the built-in fallback) receives:

Variable Meaning
JCODE_SPAWN_KIND Why the spawn happened: swarm-agent, resume, selfdev, restart, jade-relay
JCODE_SPAWN_SESSION_ID The jcode session the window will run
JCODE_SPAWN_TITLE Suggested window/tab title (includes session icon + name)
JCODE_SPAWN_CWD Session working directory
JCODE_SPAWN_PROGRAM Path of the jcode binary to execute
JCODE_SPAWN_COMMAND Full command line, shell-escaped, for hooks that take one shell string
JCODE_SPAWN_SWARM_ID (swarm spawns) The swarm the agent joins
JCODE_SPAWN_COORDINATOR_SESSION_ID (swarm spawns) The coordinator session that requested the spawn
JCODE_FRESH_SPAWN 1 when the spawn is a fresh window handoff

Client terminal environment (multi-terminal routing)

The jcode server process is long-lived: it captures terminal-identifying env vars (ZELLIJ_SESSION_NAME, TMUX, DISPLAY, KITTY_WINDOW_ID, ...) once at startup. When you later open a new terminal/tmux/zellij session and connect a client to the same server, the server's copies are stale, so a spawn hook run by the server would otherwise target the old terminal.

To fix this (see issue #405), each connecting client snapshots its own terminal-identifying env and sends it to the server. When a spawn hook runs, the server re-exports the requesting client's values so the hook follows the terminal the user is actually attached to:

  • The native variable (e.g. ZELLIJ_SESSION_NAME) is overridden with the client's value, so hooks that read it directly target the right session.
  • A JCODE_CLIENT_<NAME> alias (e.g. JCODE_CLIENT_ZELLIJ_SESSION_NAME, JCODE_CLIENT_TMUX, JCODE_CLIENT_DISPLAY) is also exported so a hook can explicitly distinguish the client's terminal from the server's.

Covered keys include the terminal multiplexers (zellij, tmux, screen), terminal emulators (kitty, wezterm, ghostty, alacritty, iTerm, Windows Terminal, handterm), and the display server (DISPLAY, WAYLAND_DISPLAY). Only vars that the client actually has set are forwarded.

Examples

tmux: one window per agent

[terminal]
spawn_hook = "tmux new-window"

tmux new-window <jcode> --resume ses_x runs the command in a new window of the current tmux server. For panes instead:

[terminal]
spawn_hook = "tmux split-window -h"

kitty: one tab per agent (remote control)

[terminal]
spawn_hook = "kitty @ --to unix:/tmp/kitty.sock launch --type=tab --"

Custom router script

For full control (placement, titles, swarm vs resume routing), point the hook at a script:

[terminal]
spawn_hook = "~/bin/jcode-spawn-router"
#!/usr/bin/env bash
# ~/bin/jcode-spawn-router
# argv: the jcode command to run ("$@"). Env: JCODE_SPAWN_* metadata.

case "$JCODE_SPAWN_KIND" in
  swarm-agent)
    # Swarm workers as tmux panes in a window named after the swarm.
    tmux new-window -n "swarm:${JCODE_SPAWN_SWARM_ID:0:8}" "$@" 2>/dev/null \
      || tmux split-window "$@"
    ;;
  *)
    # Everything else as a normal terminal window.
    kitty --title "$JCODE_SPAWN_TITLE" -e "$@" &
    ;;
esac

A hook that exits non-zero after launching nothing will NOT trigger the built-in fallback (jcode only falls back when the hook process cannot be started), so a router script should handle its own fallback like the example above.

Single-shell-string consumers

Some launchers want one shell command string instead of argv. Use $JCODE_SPAWN_COMMAND:

#!/usr/bin/env bash
zellij action new-pane -- bash -lc "$JCODE_SPAWN_COMMAND"

Programmatic discovery

Programs that wrap jcode (e.g. herd-style session managers) can set JCODE_SPAWN_HOOK in the environment of the jcode server process they launch. Every headed spawn the server performs, including swarm agents requested by coordinators over the socket protocol, will then route through the wrapper's hook.

Focus hook

When jcode wants to bring an existing session window to the foreground (e.g. after launching a self-dev window), it normally does a best-effort wmctrl/xdotool title search on X11. That doesn't work under Wayland or inside multiplexers, and a wrapper that owns placement should also own focus:

[terminal]
spawn_hook = "tmux new-window"
focus_hook = "~/bin/jcode-focus"   # env: JCODE_FOCUS_SESSION_ID, JCODE_FOCUS_TITLE
#!/usr/bin/env bash
# ~/bin/jcode-focus
tmux select-window -t "$(tmux list-windows -F '#{window_id} #{window_name}' \
  | grep -F "$JCODE_FOCUS_TITLE" | head -1 | cut -d' ' -f1)"

Env override: JCODE_FOCUS_HOOK (empty value disables a config-file hook). If the hook fails to start, jcode falls back to the built-in focus path.