English | 日本語
jind-ai
A CLI tool for running and managing multiple agent sessions simultaneously
(Claude Code is the first-class citizen; other agents plug in via
internal/agent/<kind>/).
https://github.com/user-attachments/assets/62e9d64a-aa7d-42f8-8edf-03f724fe0ee4
Supported agents
| Kind |
CLI |
Notes |
claude (default) |
Claude Code 2.x |
First-class support. Uses --session-id / --resume and Claude Code's native hook system for state tracking. |
codex |
OpenAI Codex CLI 0.144+ |
Hooks are injected per-invocation via -c hooks.X=[...]; grant trust once through the /hooks dialog on first launch (see docs/gotchas.md). Session name / resume UUID are learned from Codex's SessionStart hook payload (no --session-id equivalent upstream yet). |
Select a non-default adapter per session:
jin session new --agent codex --workdir ~/repos/myrepo
Or set a persistent default via default_agent: codex in ~/.config/jind-ai/config.yaml.
The TUI create form includes an agent picker step whenever more than one adapter is registered — pick the kind per session with ↑↓/j/k + Enter. Initial selection is resolved as --agent > default_agent > "claude". Use jin ui --agent codex to preselect Codex for this TUI run only (config is left untouched):
jin ui --agent codex # transient default; ends when TUI exits
Features
- Multi-session management: Run multiple Claude Code sessions in the background simultaneously
- tmux-native: Each session runs in its own tmux pane, so your existing
~/.tmux.conf, custom keybindings, status bar, and copy-mode setup work as-is
- Decoupled UI / logic architecture: All session management, state transitions, and hook handling live in the daemon. The TUI is a thin client that talks to the daemon over a Unix socket and holds no session-management logic. In principle any alternate UI (web, editor extension, ...) can drive the same IPC (see docs/architecture.md / docs/ipc-protocol.md)
- TUI: Interactive terminal UI for listing, monitoring, and operating sessions
- Attach/Detach: Quickly switch between sessions (
Ctrl+] to detach)
- Real-time status tracking: Live display of working directory, branch, and latest message
- Session filter & Paging:
/ opens a fuzzy-search popup over session name, directory, branch, fleet, and agent kind
- Plugins: Run your own shell-executable plugins on session status changes or on demand — for example, desktop notifications via
jin plugin install jind-ai-notifier (registry name; git URLs and local --link paths are also supported)
Installation
Download from GitHub Releases
Download the binary for your OS/architecture from the Releases page.
# Example: Linux amd64
curl -Lo jind-ai.tar.gz https://github.com/takaaki-s/jind-ai/releases/latest/download/jind-ai_0.7.0_linux_amd64.tar.gz
tar xzf jind-ai.tar.gz
sudo mv jin /usr/local/bin/
Go install
go install github.com/takaaki-s/jind-ai/cmd/jin@latest
Build from source
git clone https://github.com/takaaki-s/jind-ai.git
cd jind-ai
make build # Build to bin/jin
make install # Install to $GOPATH/bin
Quick Start
1. Start the daemon
jin daemon start
2. Launch the TUI
jin ui
3. Create and attach to a session
Press n in the TUI to create a session, then Enter to attach.
Press Ctrl+] to detach and return to the TUI.
Session Status
Session states are detected via Claude Code hooks in an event-driven manner.
| Status |
Icon |
Detection |
Description |
thinking |
⚡ |
UserPromptSubmit hook |
Processing |
permission |
? |
Notification hook |
Awaiting permission |
running |
▶ |
Internal |
Running |
creating |
+ |
Internal |
Creating (CC starting up) |
idle |
○ |
Stop hook |
Waiting for input |
stopped |
■ |
Process death detection |
Stopped |
CLI Commands
Daemon management
jin daemon start # Start daemon
jin daemon stop # Stop daemon
jin daemon status # Check status
Session management
# Create session (interactive via TUI - recommended)
jin session new
# Create session (specify working directory)
jin session new --workdir ~/repos/myrepo
# List sessions
jin session list
# List sessions in JSON format (for scripting / LLM integration)
jin session list --json
# Attach to a session
jin session attach <session-name>
# Get session details
jin session info <session-name>
# Send a prompt to a session
jin session send <session-name> "your prompt here"
# Wait for a session to become idle (default timeout: 300s)
jin session wait <session-name>
jin session wait <session-name> --timeout 60
# Get the last assistant message
jin session output <session-name>
# Get the last N conversation pairs
jin session output <session-name> --last 3
# Kill a session
jin session kill <session-name>
# Delete a session
jin session delete <session-name>
# Bulk delete stopped sessions
jin cleanup stopped
jin cleanup stopped --dry-run # Preview what will be deleted
Aliases: session can be shortened to sess (e.g., jin sess list). list to ls, delete to rm.
LLM API (scripting / automation)
The following commands support --json for structured output, enabling integration with scripts and other LLM agents.
# All session commands support --json
jin session list --json
jin session new --workdir ~/repos/myrepo --json
jin session info <session-name> --json
jin session kill <session-name> --json
# Send a prompt and wait for completion
jin session send <session-name> "fix the failing test" --json
jin session wait <session-name> --timeout 120 --json
jin session output <session-name> --json
# Pipeline example: send a prompt, wait, get output
jin session send my-session "refactor main.go"
jin session wait my-session --timeout 300
jin session output my-session --last 1
Orchestration: parent Claude driving child sessions
For richer orchestration (e.g. a parent Claude that needs to inspect what a
child actually did, not just the assistant's text), use session result.
It returns structured tool_use / tool_result / thinking blocks parsed
directly from the Claude Code transcript JSONL — no tmux pane scraping, no
truncation by scrollback buffer.
# Send a prompt, wait until the child stops (idle OR waiting on permission),
# then fetch what it actually did.
jin session prompt my-session "run go test ./... and report failures"
jin session wait my-session --until idle,permission --timeout 600
jin session result my-session --json | jq '.entries[].blocks[] | select(.kind=="tool_result")'
# Incremental fetch: only entries after a checkpoint.
# --since is strictly exclusive: pass the last entry's timestamp to receive
# only entries that came after it (no duplicates). Claude Code emits timestamps
# with millisecond precision (e.g. "2026-04-09T13:23:10.456Z").
T1=$(jin session result my-session --json | jq -r '.entries[-1].timestamp')
jin session prompt my-session "now also run go vet"
jin session wait my-session --until idle,permission
jin session result my-session --since "$T1" --json
# Filter to a specific tool, or to errors only
jin session result my-session --tool Bash --json
jin session result my-session --errors-only --json
prompt is an alias for send.
Exit codes
| Code |
Meaning |
| 0 |
Success |
| 1 |
General error |
| 2 |
Session not found |
| 3 |
Daemon not running |
| 4 |
Timeout (session wait) |
Utilities
jin session workdir <session-name> # Print session's working directory path
jin session edit <session-name> # Open session's working directory in EDITOR
The following shell functions are useful:
# cd to a session's working directory
cc-cd() { cd "$(jin session workdir "$1")"; }
# Select a session with fzf and cd to its working directory
cc-cdf() {
local session
session=$(jin session list | tail -n +2 | fzf --height 40% --reverse | awk '{print $1}')
[[ -n "$session" ]] && cd "$(jin session workdir "$session")"
}
# Select a session with fzf and attach
cc-attach() {
local session
session=$(jin session list | tail -n +2 | fzf --height 40% --reverse | awk '{print $1}')
[[ -n "$session" ]] && jin session attach "$session"
}
Shell Completion
# bash
source <(jin completion bash)
# zsh
source <(jin completion zsh)
# fish
jin completion fish | source
Configuration
jind-ai follows the XDG Base Directory Specification. Files are split across config / state / runtime directories:
$XDG_CONFIG_HOME/jind-ai/ (default: ~/.config/jind-ai)
└── config.yaml # Configuration file
$XDG_STATE_HOME/jind-ai/ (default: ~/.local/state/jind-ai)
├── state.yaml # State file (last used repository, etc.)
├── sessions/ # Session data
├── hooks-settings.json # Generated hooks settings (auto-managed)
├── plugins.lock.yaml # Installed-plugin ledger (see Plugins below)
├── plugin-logs/ # Per-plugin dispatch/run and build output
├── daemon-debug.log # Daemon debug log (when JIN_DEBUG=1)
├── hook-debug.log # Hook debug log (when JIN_DEBUG=1)
└── plugin-debug.log # Plugin dispatcher debug log (when JIN_DEBUG=1)
$XDG_DATA_HOME/jind-ai/ (default: ~/.local/share/jind-ai)
└── plugins/ # Installed plugins (see Plugins below)
$XDG_RUNTIME_DIR/jind-ai/ (fallback: $TMPDIR/jind-ai-<uid>)
└── daemon.sock # Daemon socket
Example configuration (~/.config/jind-ai/config.yaml)
# Customize keybindings (defaults are used when omitted)
keybindings:
# Session list view
up: ["up", "k"]
down: ["down", "j"]
attach: ["enter"]
new: ["n"]
kill: ["x"]
delete: ["d"]
refresh: ["r"]
search: ["M-f"] # opens the session filter popup (fuzzy search).
# Default M-f (Alt+f). Must be modifier-prefixed —
# a bare letter is consumed by the display pane and
# never reaches the outer tmux binding.
# Use ["/"] to restore the pre-M-f bare-slash key
# (breaks agent slash-commands in the display pane).
vscode: ["v"]
quit: ["q", "ctrl+c"]
help: ["?"]
# Session creation form
next_field: ["tab"]
prev_field: ["shift+tab"]
submit: ["enter"]
cancel_form: ["esc"]
# While attached
detach: ["ctrl+]"] # Default: ctrl+]
# Supported keys: ctrl+^, ctrl+], ctrl+\, ctrl+g
# Outer tmux (jin-mgr) — action palette trigger, both panes
action_panel: ["M-p"] # Default: M-p
# action_panel: [] to disable
# action_panel: ["M-x"] to rebind
# Outer tmux (jin-mgr) — per-plugin action triggers (both panes)
# No default — user opts in per plugin. Fires `jin plugin run <name>`
# via tmux `run-shell -b` (background, no output to the active pane), so
# the plugin itself is responsible for opening any popup (matches the
# `jin pane popup --here` model). Uninstalled plugins are silently
# skipped with one log line. Key collisions with core outer-tmux
# bindings are warned only; tmux last-write-wins.
# Outer-tmux keys accept both tmux notation (`M-n`, `C-f`) and the "+"
# style (`alt+n`, `ctrl+f`); they are normalized to tmux form at load.
plugins:
# notifier: { keys: ["M-n"] } # 1 打鍵で通知一覧
# worktree-cleanup: { keys: ["M-w", "M-c"] } # 複数キー可
# journal: { keys: ["ctrl+f"] } # "+" style も可
# Optional: popup sizes (percent, int 1-100). Every entry is optional;
# omitted popups keep their default (create/session_filter/action = 70-80).
# See docs/tui-guide.md#popup-sizes for the full table and delivery paths.
popups:
create: { width: 80, height: 80 }
session_filter: { width: 70, height: 70 }
help: { width: 60, height: 60 }
action: { width: 70, height: 70 }
plugin_default: { width: 70, height: 70 }
plugins: # per-plugin overrides
# my-notifier: { width: 40, height: 20 }
Worktree placement
By default, jin session new --worktree creates worktrees under $XDG_STATE_HOME/jind-ai/worktrees/{name} (typically ~/.local/state/jind-ai/worktrees/). Override this with worktree.base_dir in config.yaml:
worktree:
# Group worktrees per repository under a stable location
base_dir: "${HOME}/ghq/worktrees/{repo}/{name}"
Other common layouts:
# Sibling directory next to each repo checkout
worktree:
base_dir: "${HOME}/dev/worktrees/{name}"
# Under a fixed root, ignoring repo name
worktree:
base_dir: "/mnt/fast/worktrees/{name}"
Template variables:
| Placeholder |
Expands to |
{name} |
Worktree name (e.g. jin-abcd1234, or the --name you passed) |
{repo} |
Basename of the original repository |
${VAR} |
Environment variable (os.ExpandEnv semantics) |
The expanded path must be absolute. Unknown {xxx} placeholders are rejected at session creation.
Worktree branch naming
Every worktree gets a companion branch. Two worktree: settings control how it is named:
worktree:
branch_prefix: "topic/" # Default: "jin/". Use "" to drop the prefix.
default_branch: "main" # Fallback base branch. Default: "" (no fallback).
branch_prefix — prepended to the auto-derived worktree name to form the branch name. The leading jin- on the worktree name is stripped first, so under the default jin-abcd1234 becomes jin/abcd1234 (not jin/jin-abcd1234). Ignored when you pass --worktree-branch <name> to jin session new, since that overrides the branch outright.
default_branch — used only when jind-ai cannot auto-detect the repository's default branch. Detection reads refs/remotes/origin/HEAD; local clones that never had it set (some tarballs, git clone --no-checkout, older clones) will hit the fallback. If detection fails and default_branch is empty, session creation errors with cannot detect default branch.
Worktree creation itself is offline — the new branch is cut from your local origin/<base> with no network round-trip, so heavy repos aren't taxed on every session. If you want the worktree to start from the freshest remote tip, git fetch origin <base> in the source repo before running jin session new --worktree, or wire the fetch into the post-create hook below.
TUI Keybindings
Session list view
| Key |
Action |
↑/k |
Move up |
↓/j |
Move down |
←/h |
Previous page |
→/l |
Next page |
M-f |
Open session filter (fuzzy popup) — see Outer tmux — session filter |
Enter |
Attach to session |
n |
Create new session |
x |
Kill session |
d |
Delete session |
r |
Refresh list |
v |
Open in VS Code |
? |
Show help |
q |
Quit |
| Key |
Action |
Tab |
Move to next field |
Shift+Tab |
Move to previous field |
Enter |
Create session |
Esc |
Cancel |
While attached, press Ctrl+] (default) to detach and return to the TUI.
You can change this in config.yaml under keybindings.detach.
Supported detach keys:
| Key |
Description |
ctrl+] |
Default |
ctrl+^ |
Ctrl+Shift+6 |
ctrl+\ |
Ctrl+Backslash |
ctrl+g |
Ctrl+G |
Outer tmux — action palette
M-p (Alt+p, default) opens the action palette, a searchable popup listing
every built-in TUI action plus installed plugin actions. It's bound at the
outer tmux (jin-mgr) root key table, so it fires the same way whether the
session list (left) or an attached agent (right) has focus.
Override or disable it in config.yaml (see keybindings.action_panel
above):
keybindings:
action_panel: ["M-x"] # rebind to Alt+x
# action_panel: [] # disable entirely (no bind-key issued)
Keys must include a modifier (M-/C-) — a bare letter would be consumed as
normal input by the agent in the right pane instead of reaching the outer
tmux binding.
Outer tmux — session filter
M-f (Alt+f, default) opens the session filter, a fuzzy-search popup for
jumping straight to a session: type a few characters and press Enter to
attach immediately. It's bound the same way as the action palette above — at
the outer tmux (jin-mgr) root key table, so it fires from either pane.
Matched fields are session description, working directory, current working
directory, git branch, fleet, and agent kind (subsequence matching via
sahilm/fuzzy, smart-case, ranked by
score). Esc closes the popup without changing anything; ↑/↓ or
Ctrl+P/Ctrl+N move the cursor.
The default changed from / to M-f because a bare-letter binding at the
outer tmux root also swallows / typed in the display pane, breaking agent
slash-commands (Claude Code /help, less/vim /search, etc.). The action
palette entry ("session filter") also invokes this popup, so you can reach
it without a shortcut at all.
Override or disable the trigger the same way as action_panel:
keybindings:
search: ["ctrl+p"] # rebind to Ctrl+p
# search: ["/"] # restore pre-M-f bare-slash (breaks display-pane `/`)
# search: [] # disable entirely (no bind-key issued)
Claude Code Hooks
jind-ai uses Claude Code hooks to detect session state changes. Hooks are configured automatically — no manual setup required.
When a session starts, jind-ai generates $XDG_STATE_HOME/jind-ai/hooks-settings.json (default ~/.local/state/jind-ai/hooks-settings.json) and passes it to Claude Code via claude --settings. This file wires up the following hooks:
| Hook Event |
Role |
UserPromptSubmit |
User submits a prompt → set session to thinking |
PostToolUse |
Tool execution ends → set session to thinking (recovers from permission state) |
Stop |
Claude's turn ends → set session to idle (dispatches a task-complete JIN_NOTIFY_KIND to plugins) |
Notification |
Permission request, etc. → set session to permission (dispatches a permission JIN_NOTIFY_KIND to plugins) |
Worktree Post-Create Hook
When you create a session with jin session new --worktree, jind-ai can run a setup script right after the worktree is created — installing dependencies, copying .env, initializing submodules — so every new worktree lands ready to use without any manual steps.
Script location
Place a shell script at .jin/worktree-post-create.sh in the original repository (not the worktree). It always runs via bash, so chmod +x is not required. If the file doesn't exist, the hook is silently skipped.
#!/usr/bin/env bash
set -euo pipefail
# Copy .env from parent repository (git-ignored)
cp "$JIN_REPO_ROOT/.env" "$JIN_WORKTREE_PATH/.env" 2>/dev/null || true
# Install dependencies
pnpm install
Environment variables
| Variable |
Description |
JIN_WORKTREE_PATH |
Absolute path of the newly created worktree |
JIN_WORKTREE_BRANCH |
Branch checked out in the worktree |
JIN_WORKTREE_BASE |
Base branch the worktree was created from |
JIN_SESSION_ID |
UUID of the session being created |
JIN_SESSION_NAME |
Session name, if one was given via --name (empty otherwise — the auto-derived name isn't assigned until after the hook runs) |
JIN_REPO_ROOT |
Absolute path of the original repository |
Security: allowlist
Since the script is checked into a repository, jind-ai never runs it unless the repository has been explicitly trusted (a direnv-style allow model). Trust is tracked by the script's SHA256 — editing the script requires trusting it again.
jin worktree allow # Trust the current repository (shows the script, asks for confirmation)
jin worktree revoke # Revoke trust
jin worktree status # Show the allow status of the current repository
jin worktree list # List all trusted repositories
If the script exists but isn't trusted (or changed since it was trusted), the hook is skipped with a warning — the worktree is still created and Claude still starts normally. When creating from the TUI, the popup surfaces a three-way prompt (a: Allow, s: Skip and create anyway, c: Cancel) so you can decide without dropping to a shell.
Skipping the hook
jin session new --worktree --no-hook — skip the hook for this session only
worktree.hook_enabled: false in ~/.config/jind-ai/config.yaml — disable the hook for all repositories
worktree.hook_timeout: <seconds> — change the timeout (default: 300). On expiry the hook's process group is sent SIGTERM, given a 5-second grace period, then SIGKILL if still alive.
On failure
If the hook exits non-zero or times out, the worktree and its branch are rolled back and jin session new fails with a non-zero exit code. The hook's stdout/stderr are kept at ~/.local/state/jind-ai/hook-logs/<session-id>.log for troubleshooting, even after a rollback.
Plugins
jind-ai can run your own shell-executable plugins in reaction to session
status changes, or on demand. A plugin is a directory with a manifest and an
entry-point script; jind-ai never inspects what the script does, only when
it runs and what environment it gets.
Community plugins are discoverable through the plugin registry:
jin plugin ls-remote lists them, jin plugin install <name> installs one
by registry name with a commit-pinned consent screen.
Two ways a plugin runs
- Event listener — a manifest action subscribes to
status_changed
via its on: matcher. Good for notifications, logging, CI triggers —
anything non-interactive. Note: an event fires only when the status
actually changes; a notification without a status transition (e.g. a
repeated stop while already idle) does not dispatch. If a plugin
declares multiple actions, each is matched and debounced independently,
so the same event can fan out to several actions on the same plugin.
- Action — launched explicitly with
jin plugin run <name> [action] [--session <selector>]. Good for interactive workflows (e.g. a
popup-based diff review UI). Omit [action] to run the plugin's default
action (actions[0]); pass an action ID to select a specific one. Set
an action's on: [] to make it action-only. Without --session the run
is a global action: all session-derived env vars are empty. On every
action run — global or session-scoped — JIN_CALLER_TMUX_SOCKET /
JIN_CALLER_TMUX_PANE identify where the invoking CLI was launched
from, when it sat inside a tmux client.
Both entry points execute the same action entrypoint with the same
environment; only the trigger differs.
Manifest (jind-ai-plugin.yaml)
Place this file at the root of the plugin directory. The same manifest is
read at runtime (dispatcher) and at publish time (registry crawler); one
file, one source of truth.
schema_version: 2
name: notifier
version: 0.2.0
description: Desktop notifications for jin sessions
license: MIT
homepage: https://github.com/foo/notifier
jin: ">=0.8.0"
install:
source:
build:
- go build -o bin/notifier ./cmd/notifier
entrypoint: ./bin/notifier
actions:
- id: default # actions[0] is the implicit default
entrypoint: ./bin/notifier notify
on: ["status_changed:idle", "status_changed:permission"]
label: "Desktop notification"
timeout: 30s
- id: send-dm # `jin plugin run notifier send-dm`
entrypoint: ./bin/notifier send-dm
on: [] # action-only (no event subscription)
label: "Send DM to teammate"
popup: { width: 60, height: 30 }
Existing v1 manifests (schema_version: 1 with top-level entrypoint /
on / timeout / popup) keep working: they are normalised at parse
time into a single-action shape, so no author-side migration is required.
Write new manifests as v2.
| Field |
Required |
Description |
schema_version |
Yes |
Manifest generation. 1 or 2. v1 is auto-normalised to a single-action v2 shape at parse time |
name |
Yes |
[a-z][a-z0-9-]{1,63}; unique in the registry; must match the directory name jind-ai installs it under |
version |
Yes |
Plugin's own semver (X.Y.Z); pre-release/build metadata allowed |
description |
Yes |
One-liner shown in jin plugin ls-remote search results |
license / homepage |
No |
Optional metadata carried into the registry entry |
jin |
Yes |
Semver constraint on the jin binary (">=0.8.0", "^0.8", ">=0.8 <0.10"). Checked at install and at every dispatch |
install.source.build |
No |
Ordered list of build commands (each element runs in its own bash -c, no piping across elements) — see Language-specific guidance. Omit for plugins that ship a directly-executable entrypoint (shell scripts, prebuilt binaries in the repo) |
install.source.entrypoint |
Conditional |
Default entrypoint the dispatcher executes if a specific action does not declare its own. Required when install.source is used |
install.release_asset.pattern |
Conditional |
Alternative to install.source. Downloads a prebuilt asset from the latest GitHub Release. Placeholders: {os}, {arch} |
actions[] |
v2 only |
List of actions the plugin exposes. actions[0] is the implicit default. Each element carries id / entrypoint / on / label / timeout / popup (fields below) |
actions[].id |
Yes |
[a-z][a-z0-9-]{0,63}; unique within the plugin. Explicit IDs are strongly recommended — palette, keybindings, and jin plugin run all reference actions by ID |
actions[].entrypoint |
Conditional |
Path (relative to the plugin dir) executed for this action. May be omitted when install.source.entrypoint covers it |
actions[].on |
No |
Per-action status_changed matchers, same syntax as v1's top-level on. Empty or omitted = action-only. Matching / debouncing is independent per action |
actions[].label |
No |
Human-readable label shown in the palette and help popup. When empty, the palette displays <plugin>:<action-id> (or just <plugin> when the default action's ID is default) |
actions[].timeout |
No |
Per-action override for timeout; default 30s |
actions[].popup.width / .height |
No |
Per-action manifest hint for jin pane popup --here size (1–100, percent of terminal) |
actions[].listener |
No |
Marks the action as an event-only endpoint. It still fires on matching on: events, but is hidden from every user-facing surface (palette, help popup, shell completion). Direct invocation via jin plugin run <plugin> <action> remains available for debugging. Requires a non-empty on: — a listener with no events has no runtime purpose |
on / timeout / popup (top-level) |
v1 only |
Legacy v1 fields; forbidden in v2 (validation error). Put them under actions[] instead |
install.source and install.release_asset are mutually exclusive.
config.yaml only enables/disables plugins and tunes dispatch timing (below) — it never duplicates manifest fields.
Listener actions are the common pattern for a plugin that both reacts to
events and exposes a UI. Split the two concerns so only the UI part appears
in the palette:
actions:
- id: list # user-facing: appears in the palette
entrypoint: ./notifier.sh list
label: "Show pending sessions"
- id: listen # event listener: hidden from the palette
entrypoint: ./notifier.sh listen
on: ["status_changed"]
listener: true # requires a non-empty on:
What a plugin receives
Environment variables:
| Variable |
Description |
JIN_EVENT |
status_changed or action |
JIN_ACTION_ID |
ID of the manifest action that fired this run (default for v1 manifests / v2 default actions synthesised as default). A shared entrypoint script can dispatch on this instead of parsing argv |
JIN_SESSION_ID |
Session ID |
JIN_STATUS |
Current status |
JIN_PREV_STATUS |
Previous status (empty for an action run) |
JIN_AGENT_KIND |
Adapter kind (claude, ...) |
JIN_WORKDIR |
Session's working directory |
JIN_TMUX_PANE_ID |
tmux pane ID, if known |
JIN_NOTIFY_KIND |
Notification kind for this transition: task-complete, error, permission, or empty when the transition triggers no notification |
JIN_PLUGIN_DEPTH |
Chain depth — see Constraints |
JIN_SOCKET |
Daemon socket path; the jin CLI a plugin invokes picks this up automatically |
JIN_BIN |
Absolute path of the daemon's own jin binary. Prefer "${JIN_BIN:-jin}" over a bare jin — a jin found on PATH may be an older install that lacks newer subcommands |
JIN_CALLER_TMUX_SOCKET |
Action runs only: socket path of the tmux server the invoking CLI ran inside (from its $TMUX). Unset — not empty — when the caller was outside tmux |
JIN_CALLER_TMUX_PANE |
Action runs only: the invoking CLI's pane ID (from its $TMUX_PANE). Unset when unknown |
The same data is also written to stdin as JSON (same fields, snake_case;
caller tmux context is env-only).
For anything beyond this thin payload, call back into jind-ai:
jin session info "$JIN_SESSION_ID" --json # full session details
jin session send "$JIN_SESSION_ID" "..." # send a prompt
jin session result "$JIN_SESSION_ID" --json # structured transcript entries
jin session focus "$JIN_SESSION_ID" # make the running TUI display this session
jin pane popup "$JIN_SESSION_ID" -- <cmd> # tmux popup over the session's pane
jin pane popup --here -- <cmd> # tmux popup over the caller's own pane (uses $TMUX, falling back to JIN_CALLER_TMUX_SOCKET)
jin pane split "$JIN_SESSION_ID" -- <cmd>
jin pane capture "$JIN_SESSION_ID"
jin pane send-keys "$JIN_SESSION_ID" <keys>
Compatibility contract: treat any environment variable, JSON field, or CLI
flag you don't recognize as something to ignore, not an error. jind-ai only
adds to this surface within a schema_version; breaking removals happen
across a schema_version bump (or, pre-1.0, across a minor jin release).
Install / update / remove / list
# From the plugin registry (see docs/plugin-registry.md)
jin plugin ls-remote # list plugins in the registry
jin plugin install jind-ai-notifier # latest release; `plugin update` follows the plugin
jin plugin install jind-ai-notifier -v 0.2.0 # pin a specific version; `plugin update` will not move it
jin plugin install jind-ai-notifier --force # override an unsatisfied jin compat range
# From a git source (github.com/, gitlab.com/, self-hosted, ssh URLs, ...)
jin plugin install github.com/owner/repo # default branch; `plugin update` follows highest semver tag
jin plugin install github.com/owner/repo@v1.2.0 # pinned to a tag/branch/SHA; `plugin update` will not move it
# From a local directory, symlinked in place (development)
jin plugin install --link ./my-plugin
jin plugin update <name>
jin plugin remove <name>
jin plugin list # NAME / VERSION / STATE / SOURCE; --json for scripting
# Validate a manifest — same checks the registry crawler runs
jin plugin validate # defaults to .
jin plugin validate --github-actions # emit ::error / ::warning annotations
A git install/update shows the manifest (name, version, on,
entrypoint, build) and the commit SHA it resolved to, and asks for
confirmation (--yes to skip) before
touching anything; the approved commit SHA is recorded in
plugins.lock.yaml, so a later install/update never silently lands on a
different commit than the one you saw. A --linked plugin skips this —
linking a local path is itself the trust decision, and jind-ai never runs
build: for a linked plugin.
jin plugin update <name> resolves the plugin's latest release for
an unpinned install: registry names resolve to the registry's declared
latest_version, and raw git-URL installs pick the highest semver tag
from git ls-remote --tags (falling back to the locked ref when the
remote advertises no semver tags). A pinned install — one that used
-v <ver> on the registry path or @<ref> on the git-URL path — is a
no-op with a message pointing at reinstall as the way to move it. This
mirrors install: the "give me latest" and "hold this exact ref" intents
are decided once at install time and honoured by every later update.
Language-specific guidance
- Shell / single file — commit the script, point
entrypoint at it,
and omit install.source.build; add a chmod +x step only if the
script is generated or the exec bit isn't preserved in git.
- Node.js / TypeScript — bundle to
dist/ (esbuild etc.) as one build
step; resolving dependencies at runtime (bun/deno) works too, but that
first-dispatch network fetch can fail silently since dispatch is fail-open
— a pre-built bundle is more predictable.
- Go / Rust / other compiled languages — declare a build sequence under
install.source.build; each element runs as its own process (no shell
piping between elements) so the binary matches the user's platform/arch
(and go.sum / Cargo.lock give reproducibility). Builds run once per
install/update; jind-ai does not resolve dependencies or detect a
toolchain for you — document what's required in your plugin's own README.
A non-zero exit fails the install/update atomically (nothing is left
half-installed), with output kept at
~/.local/state/jind-ai/plugin-logs/<name>-build.log. jind-ai injects
npm_config_ignore_scripts=true into the build environment by default (a
supply-chain guard you can override inside your own build step); the
build itself runs with your own user privileges — it is not sandboxed.
Constraints
- No persistent processes. jind-ai runs a plugin per event/action and
tears it down; don't build a long-running daemon into
entrypoint. If you
need one, run it yourself (manually, or as a systemd user unit) and keep
the plugin a thin per-event client to it (e.g. curl).
- Popups don't inherit
JIN_* env vars. jin pane popup / jin pane split run their command in a process tmux spawns fresh — pass any data
the popup needs as arguments on its command line (or as env-assignment
prefixes in the command string, e.g.
jin pane popup "$JIN_SESSION_ID" -- "JIN_BIN=$JIN_BIN inner.sh --id $JIN_SESSION_ID"),
not as inherited env vars.
- Fail-open. A plugin that errors, times out, or hangs never blocks a
session's status pipeline — it's logged and the pipeline moves on. Timeout
defaults to 30s (
timeout: in the manifest).
- Loop residual risk. jind-ai debounces repeated dispatch of the same
(plugin, session, event) within a short window (default 3s,
plugins.debounce below) and rejects a plugin chaining another plugin run
beyond one hop (JIN_PLUGIN_DEPTH). Neither catches a slow ping-pong
(e.g. a plugin that sends a prompt whose eventual response re-triggers the
same plugin a few seconds later) — avoiding that is on the plugin author.
Config (~/.config/jind-ai/config.yaml)
plugins:
enabled: true # default true; false disables all plugin dispatch
disabled: ["notifier"] # disable individual plugins by name
build_timeout: 300 # seconds, install/update build step (default 300)
debounce: 3 # seconds, dispatch debounce window (default 3)
Compatibility
Plugins declare a semver constraint on the jin binary via jin: (e.g.
">=0.7.0", "^0.7"). Checked at install/update (fail-closed — a plugin
outside the range is rejected before anything is written) and again at
every dispatch (fail-open — an incompatible installed plugin is skipped,
logged once, and shown as incompatible in jin plugin list, with jin plugin run pointing you at jin plugin update <name>). Development builds
(jin --version reporting dev or an unstamped value) satisfy every
constraint so local plugin work is unblocked.
The schema_version field is orthogonal to jin: it identifies the
manifest generation. jind-ai supports schema versions in a window
[min, current], currently both 1, and older schemas will be honoured up
to two generations back once we start bumping.
Debugging a plugin
export JIN_DEBUG=1
tail -f ~/.local/state/jind-ai/plugin-debug.log # dispatcher decisions
tail -f ~/.local/state/jind-ai/plugin-logs/<name>.log # a plugin's own stdout/stderr
Debugging
# Enable debug logging
export JIN_DEBUG=1
# Start daemon
jin daemon start
# View logs
tail -f ~/.local/state/jind-ai/daemon-debug.log
Requirements
- Go 1.24.5+
- tmux 3.3+
- Claude Code CLI installed
License
MIT