Shell output is truncated to 2000 lines or 50KB (whichever is hit first), keeping the tail. When truncated, full output is saved to a temp file and the response includes a notice:
[Showing 1847 of 3000 lines (45.2KB of 78.1KB). Full output: /tmp/jinn-shell-xxxxx.log]
For very large output (>1MB capture), the subprocess buffer caps at 1MB but the full output is always spilled to a temp file.
Before executing a command, jinn classifies it:
| Level | Meaning | Response |
|---|---|---|
safe |
Read-only, no side effects | Executed normally |
caution |
Modifies state but recoverable | Executed normally |
dangerous |
Destructive or irreversible | Blocked unless force: true |
{"tool": "run_shell", "args": {"command": "rm -rf /tmp/scratch", "force": true}}The risk field is always present in the response envelope for run_shell calls, even when the command succeeds:
{"ok": true, "result": "...", "risk": "safe", "classification": "success"}Without force: true, a dangerous command returns:
{"ok": false, "error": "blocked by risk classifier: dangerous — ...", "suggestion": "pass force:true in args to override, or use a less-destructive command", "risk": "dangerous"}Every run_shell response includes a classification annotation after the output:
[exit: N]
<stdout/stderr>
[classification: <class> — <reason>]
| Class | Meaning | Action |
|---|---|---|
success |
Exit 0 — command completed normally | Proceed |
expected_nonzero |
Non-zero exit that is a semantic signal, not a failure | Do NOT retry — read the output for the actual result |
error |
Unexpected non-zero exit indicating failure | Diagnose from output, then retry or escalate |
timeout |
Command exceeded its time limit | Retry with a higher timeout value or a faster command |
signal |
Process was terminated by a signal (exit >128 or negative) | Diagnose signal cause; usually an OOM kill or external termination |
| Command | Exit 1 means |
|---|---|
grep, rg, ag |
Pattern not found — this is success |
diff, cmp |
Files differ — this is the result, not an error |
test, [ |
Condition is false — this is the result |
curl exit 22 |
HTTP 4xx/5xx — server responded, check the body |
find returns 0 even when zero files match (no results ≠ failure for find), so a nonzero find exit is always classified error, consistent with exitTable.
Rule: Always inspect classification before deciding whether to retry on a non-zero exit. Retrying an expected_nonzero result wastes turns and context.
When read_file fails, the response includes a suggestion field with an imperative one-sentence next step:
{"ok": false, "error": "file not found: foo.txt", "suggestion": "verify the path exists with list_dir on the parent, or check for typos"}Follow the suggestion before retrying. Common suggestions map to these error paths:
| Error | Suggestion action |
|---|---|
| file not found | list_dir on parent directory |
| not a regular file | Use list_dir to enumerate; target a file not a directory |
| file too large | Use start_line/end_line windowing or search_files |
| binary file detected | Use stat_file for metadata |
| window past end | Reduce start_line to within file length |
| blocked (sensitive path) | Request the specific value from the user instead |
| outside working directory | Supply a path inside the workdir |
When a windowed read exceeds the line limit, the truncate arg controls what is kept:
| Value | Keeps | Use for |
|---|---|---|
head (default) |
First N lines | Paginating top-down with start_line |
tail |
Last N lines | Logs and command output — the end matters |
middle |
Both ends, center elided | Spotting a file's overall shape |
none |
Everything (byte cap still applies) | Small files you must see whole |
smart |
Cuts at block boundaries | Source files (.go/.rs/.ts/.js/.java/.c/.cpp/.h/.hpp) |
Truncated reads append a hint with the remainder file path so you can continue from where the window ended.
Read multiple files in a single call. Returns JSON with files (path→content), errors (path→error detail), and truncation (path→metadata) maps. Partial success: individual failures go to errors without failing the entire call. Use when you need 2+ files at once.
echo '{"tool":"multi_read","args":{"files":[{"path":"a.go"},{"path":"b.go"}]}}' | jinn
Per-file windowing via start_line/end_line/tail on each entry. Max 20 files per call.
Both tools enforce a default cap of 500 entries/matches to prevent context window overflow.
When results are truncated, the response includes:
"truncated": true"total_count": N— the actual number of available entries- A hint string:
[TRUNCATED: 500 of 12847 entries. Use 'max_entries' or 'pattern' to narrow.]
| Tool | Parameter | Default | Cap |
|---|---|---|---|
list_dir |
max_entries |
500 | 10000 |
search_files |
max_matches |
500 | — |
When you receive truncated: true, narrow the request using pattern (for list_dir) or a more specific regex (for search_files), or increase the cap explicitly.
find_files locates files by name pattern. Uses fd when available (respects .gitignore, fast), falls back to POSIX find. Returns matching paths relative to workdir.
| Parameter | Required | Default | Description |
|---|---|---|---|
pattern |
yes | — | Glob pattern: *.go, **/*.json, src/**/*_test.go |
path |
no | . |
Directory to search in |
limit |
no | 1000 | Max results before truncation |
{
"files": ["src/main.go", "src/util.go"],
"truncated": false,
"total_count": 2,
"limit_used": 1000,
"backend": "fd"
}When truncated, a hint is appended: [TRUNCATED: 1000 of 5421 files. Use a more specific pattern or increase limit.]
Both backends automatically exclude: .git, node_modules, vendor, __pycache__, .cache, dist, build.
- Simple patterns (
*.go) match basenames. - Patterns with
/(src/**/*.test.ts) match against full paths. - Use
find_filesfor "which files match this name?" andsearch_filesfor "which files contain this text?"
memory persists values across jinn invocations. The store is a SQLite database at ~/.config/jinn/memory.db (override base dir: JINN_CONFIG_DIR). Keys are scoped per project (auto-detected from the nearest .git ancestor; falls back to the working dir). Writes use WAL journaling with a 5s busy_timeout. A legacy memory.json is migrated once into the "global" scope then renamed memory.json.migrated.
| Action | Required args | Returns |
|---|---|---|
save |
key, value |
"saved: <key>" |
recall |
key |
stored value string |
list |
— | {"keys": [...], "count": N} |
forget |
key |
"forgotten: <key>" (idempotent — not-found is success) |
All actions accept an optional scope arg: omit for the current project, "global" for the cross-project bucket, or an absolute path for a specific project.
| Limit | Value |
|---|---|
| Key charset | [a-zA-Z0-9_.-] |
| Key max length | 128 characters |
| Value max size | 16 KiB |
When a key is not found, recall returns ok: false with suggestion: "use action=\"list\" to see available keys".
echo '{"tool":"memory","args":{"action":"save","key":"last_branch","value":"feat/auth"}}' | jinn
echo '{"tool":"memory","args":{"action":"recall","key":"last_branch"}}' | jinn
echo '{"tool":"memory","args":{"action":"list"}}' | jinn
echo '{"tool":"memory","args":{"action":"forget","key":"last_branch"}}' | jinnlsp_query auto-selects a language server from the file extension and runs one semantic query. The server is started, used, and torn down in a single call (10s timeout).
| Extension | Server binary |
|---|---|
.go |
gopls |
.rs |
rust-analyzer |
.py |
pylsp |
.ts, .tsx, .js, .jsx |
typescript-language-server |
.c, .h, .cpp, .cc, .cxx, .hpp |
clangd |
.java |
jdtls |
.lua |
lua-language-server |
.zig |
zls |
If the binary is not on PATH, the response includes a suggestion with the install command.
| Action | Required args | Returns |
|---|---|---|
definition |
path, line, character |
file:line:col of the definition |
references |
path, line, character |
One file:line:col per reference (capped at 100) |
hover |
path, line, character |
Documentation / type info string |
symbols |
path |
Kind Name (line:col) table |
diagnostics |
path |
Pull diagnostics for the file as file:line:col severity source/code: message |
rename |
path, line, character, new_name |
Preview of rename edits across files — does not write |
line and character are 1-based. symbols and diagnostics do not require a position. Pass symbol (the identifier name) instead of line/character to let jinn resolve the declaration position automatically (with a line but no character it resolves just the column); jinn errors if the name is missing or matches more than one declaration.
echo '{"tool":"lsp_query","args":{"action":"hover","path":"main.go","line":12,"character":5}}' | jinn
echo '{"tool":"lsp_query","args":{"action":"symbols","path":"internal/jinn/engine.go"}}' | jinn
echo '{"tool":"lsp_query","args":{"action":"diagnostics","path":"main.go"}}' | jinnEvery jinn response may include these fields beyond ok, result, and error:
| Field | Set by | Values |
|---|---|---|
suggestion |
Any tool on error | Free-form one-sentence next-step hint |
classification |
run_shell always |
success, expected_nonzero, error, timeout, signal |
risk |
run_shell always |
safe, caution, dangerous |
suggestion appears on structured errors to tell the agent exactly what to try next. Always read it before retrying.
When old_text matches multiple locations, the error includes line numbers of all matches (up to 10):
old_text matches 3 locations (lines: 12, 47, 89) — must be unique. Add surrounding context to disambiguate
To fix: extend old_text to include a few surrounding lines that are unique to the target location. No separate search_files call is needed — the line numbers tell you where the matches are.
write_file, edit_file, and undo restore accept an optional if_checksum: the SHA-256 hex digest from a previous read_file response (meta.sha256, requested with include_checksum: true). multi_edit accepts the same field on each edit entry. apply_patch and search_replace accept an if_checksums object keyed by target path. A mismatch rejects the mutation with error_code: "stale_file". Each jinn call is a separate process, so use these guards whenever mutation content was derived from an earlier read. Batch tools also recheck their phase-1 bytes immediately before each write.
echo '{"tool":"read_file","args":{"path":"demo.txt","include_checksum":true}}' | jinn
echo '{"tool":"write_file","args":{"path":"demo.txt","content":"...","if_checksum":"<meta.sha256>"}}' | jinnA stale write returns:
{"ok":false,"error":"stale write rejected: demo.txt changed since read (checksum 7ea0d98262e9… != expected a948904f2f0f…)","suggestion":"re-read the file, then retry with the new checksum","error_code":"stale_file"}On stale_file: re-read the file, reconcile your change with the current content, retry with the fresh checksum.
search_replace applies one regex substitution across explicit files, globs, or directories in a single call. Use it for repo-wide renames instead of looping edit_file.
| Parameter | Required | Description |
|---|---|---|
pattern |
yes | Regex to match |
replacement |
yes | Replacement text; $1, $2, … expand capture groups. Empty string deletes matches. |
files |
yes | Path, glob, directory, or array of them — resolves to at most 50 files |
include |
no | Glob filter applied after files expansion (e.g. "*.go") |
case_insensitive |
no | Case-insensitive matching (default false) |
multiline |
no | ^/$ match line boundaries (default true) |
dry_run |
no | Preview per-file diffs and match counts without writing |
Every file is validated before any write; writes are per-file atomic. A mid-batch write failure enumerates already-written files with undo ids (same shape as apply_patch below). Binary files are skipped with structured per-file errors. Always run with dry_run: true first on a wide pattern to confirm the match count before committing.
{"tool": "search_replace", "args": {"pattern": "oldName", "replacement": "newName", "files": "**/*.go", "dry_run": true}}Applies a multi-file patch in Codex format (*** Begin Patch ... *** End Patch). Supports three operations:
| Operation | Syntax | Effect |
|---|---|---|
| Add file | *** Add File: path |
Create new file. Lines prefixed with +. |
| Delete file | *** Delete File: path |
Delete existing file. |
| Update file | *** Update File: path |
In-place edits via hunks. |
Each hunk starts with an optional @@ context marker (a line that must be found in the file for positioning), followed by lines prefixed with (context), - (remove), or + (add):
*** Begin Patch
*** Update File: main.go
@@ func main() {
func main() {
- fmt.Println("old")
+ fmt.Println("new")
}
*** End Patch
When exact line matching fails, apply_patch applies progressive fuzzy matching (rstrip → trim → Unicode-normalized) to locate hunks. This handles whitespace differences and smart quotes automatically.
All operations are validated in a preflight pass before any file is mutated. If any operation fails validation (e.g., context not found, file doesn't exist), the entire patch is rejected. Undo snapshots are recorded for each mutated file. Validation failures reject the whole patch before any write. If a WRITE fails mid-batch (e.g. permissions, disk), already-written files stay written and the error enumerates them with their undo ids so you can undo action="restore" each or fix and retry the remainder:
{"ok":false,"error":"apply_patch: partial apply — 1 of 3 files already written: a/f.txt (undo id=88ab3f0fbc8e1de6); update b/f.txt: open /tmp/demo/b/.blob-1351876685: permission denied","suggestion":"restore already-applied files with undo action=\"restore\" id=\u003cid\u003e, then fix the error and retry","error_code":"conflict"}The same partial-apply error shape applies to multi_edit and search_replace.
| Parameter | Required | Description |
|---|---|---|
patch |
yes | Codex-style patch payload |
dry_run |
no | Preview without writing (default: false) |
- Multi-file changes that should be validated together before per-file atomic writes (create + update + delete in one call)
- Hunk-based edits where context lines are more natural than old_text/new_text pairs
- Interoperability with tools that emit Codex-style patches
run_plan executes a plan tree: a PlanTree of PlanNodes connected by first-match-wins conditional PlanEdges. The engine walks the tree deterministically in-process, starting at root and stopping at a leaf node, a max-depth limit, a no-matching-edge dead end, a blocked mutation, context cancellation, or an internal error.
| Field | Required | Default | Description |
|---|---|---|---|
root |
yes | — | ID of the starting node |
nodes |
yes | — | Array of PlanNode objects |
cwd |
no | working dir | Working directory for command execution |
max_depth |
no | 8 | Maximum execution depth before the run stops with stopped_reason: "max_depth", including nested run_plan calls |
force |
no | false |
Plan-level gate: required (with node-level force) for dangerous mutations to execute |
Each PlanNode has:
| Field | Required | Default | Description |
|---|---|---|---|
id |
yes | — | Unique node identifier |
commands |
yes | — | Array of PlanOp to execute |
parallel |
no | false |
Run this node's commands concurrently |
mutates |
no | false |
Enable Phase 2 mutation gating (see below) |
force |
no | false |
Node-level gate: required (with plan-level force) for dangerous mutations to execute |
edges |
no | — | Array of PlanEdge — evaluated after all commands complete, against the last op's result |
Each PlanOp sets exactly one of shell (a shell command string) or tool + args (a tool name and its arguments map).
One shared budget permits at most 256 operations across the outer plan and all
nested run_plan calls. Exhausting it stops the walk with
stopped_reason: "resource_limit".
Nodes without mutates: true (Phase 1) are read-only: only safe-risk shell commands and read-only tools are permitted. The fixed file/search allowlist is (read_file, multi_read, list_dir, search_files, find_files, stat_file, lsp_query), with memory actions recall/list and undo actions list/preview also allowed. Any non-safe shell or mutating action is blocked.
Nodes with mutates: true (Phase 2) allow mutations under a risk gate:
caution-risk operations execute normally.dangerous-risk operations require bothplan.force: trueandnode.force: true. If either is missing, the op is blocked and the run stops withstopped_reason: "mutation_blocked". Nested plans inherit the parent's remaining depth and dangerous-mutation authority, so a childforcefield cannot elevate an unforced parent.
web_fetch and web_search are public-network tools and are rejected from all run_plan phases, including mutates:true and nested plans.
Use Jinn's network MCP profile for read-only public-web requests. Such requests leave the machine and may consume provider quota.
Edges are evaluated in order against the last op result; the first matching edge wins. Each PlanEdge has a when (Condition) and a to (target node id).
| Field | Required | Description |
|---|---|---|
kind |
yes | Condition kind: exitCode, fileExists, jsonPath, numeric, match, always |
op |
no | Comparison operator: eq, ne, lt, lte, gt, gte |
value |
no | Expected value to compare against |
path |
no | File path (fileExists) or dot-separated JSON path (jsonPath) |
extract |
no | Regex with capture group to extract a numeric value (numeric kind) |
regex |
no | Regex to match against the op result (match kind) |
stream |
no | For match only: stdout or stderr (both test against the combined result string) |
negate |
no | Invert the condition result |
The match kind is low-confidence and cannot gate an edge targeting a mutates: true node — such edges are rejected at validation time.
The response carries the plan run result in meta.plan_run:
| Field | Description |
|---|---|
transcript |
Array of PlanNodeResult — per-node results: node_id, depth, ops (each with ok, result, error, classification, risk, exit_code) |
path_taken |
Ordered list of node IDs visited |
depth_reached |
Depth at which the run stopped |
stopped_reason |
One of: leaf, no_edge_match, max_depth, mutation_blocked, aborted, resource_limit, error |
edges_evaluated |
Total number of edge conditions tested |
edges_matched |
Total number of conditions that matched |
Each completed run_plan call fire-and-forget appends a stats row to ~/.config/jinn/stats/run_plan.jsonl (respecting JINN_CONFIG_DIR). The row includes a requests_saved estimate, stop reason, node/op/edge counts, and a timestamp. Stats logging is best-effort — errors are swallowed and never affect the tool's result.
A two-node plan: the root checks for go.mod, and on success (exit code 0) routes to a build node.
echo '{"tool":"run_plan","args":{"plan":{"root":"check","nodes":[{"id":"check","commands":[{"shell":"test -f go.mod && echo found"}],"edges":[{"when":{"kind":"exitCode","op":"eq","value":0},"to":"build"}]},{"id":"build","commands":[{"shell":"go build ./..."}],"mutates":true}]}}}' | jinn