Sign in Join

Live chat for Free Pascal, Lazarus, Delphi and every Pascal programmer.

Catalog · How the catalog works
Anything · Lazarus or Delphi
Random projectRandom project

PasClaude

A terminal coding agent for Windows, written in Free Pascal. No dependencies beyond the FPC RTL.

View on GitHub

Member rating

No ratings yet.

Sign in to rate this project.

README

pasclaude

A terminal coding agent for Windows, written in Free Pascal. It talks to the Anthropic messages API, streams the reply as it arrives, and lets the model read, search, edit and run things in your project - asking before anything is changed.

No dependencies beyond the FPC RTL: HTTPS is WinHTTP bound at runtime, JSON is a small DOM in uJson, and the console is driven through the Win32 API.

pascal
    /    /  \
   <    /    >     pasclaude v0.1
    \  /    /      a coding agent in Free Pascal

  claude-sonnet-4-5
  E:\Projects\pascal\pasclaude
  /help for commands, /exit to quit, Esc stops a reply

> what does uHttp do, and is the timeout long enough for streaming?
  * read src\uHttp.pas
    ...
uHttp is a thin WinHTTP wrapper: one POST, streamed to a callback...

Build

pascal
build            :: release (optimised, stripped) -> bin\pasclaude.exe
build debug      :: with line-info for backtraces
build clean

Requires Free Pascal 3.2.x for x86_64-win64. The script finds fpc.exe on PATH, or under C:\lazarus\fpc\* / C:\FPC\*.

Run

pascal
set ANTHROPIC_API_KEY=sk-ant-...
set ANTHROPIC_MODEL=claude-sonnet-4-5          :: optional
bin\pasclaude.exe [directory] [--resume | --continue] [--web] [--add-dir <dir>]
                  [--permission-mode ask|plan|accept-edits]
                  [--dangerously-skip-permissions] [--sandbox off|limits|low]
                  [--output-style <name>] [--append-system-prompt <text>]
                  [--no-project-context]
                  [-p "prompt"] [--session-file <path>]
                  [--output-format text|json|stream-json] [--input-format text|stream-json]
                  [--status] [--doctor [--online]]
                  [--ci prepare|report --ci-in <path> --ci-out <path>
                   [--ci-pr <path>] [--ci-trigger <phrase>]
                   [--ci-allow collaborator|member|owner]]

Without a key, a Claude subscription works instead: if Claude Code has been signed in on the machine, its OAuth token (%USERPROFILE%\.claude\.credentials.json) is picked up automatically and the banner says (subscription). The token is read, never written - refreshing it is Claude Code's job, and this program does not touch another program's state. There are six sources in all and /login chooses among them; see Credentials below for the order and the boundary. An explicit ANTHROPIC_API_KEY always wins, because setting a variable is a deliberate act and borrowing a token is not. Under OAuth the request authenticates with a Bearer header and its beta flag, and the system prompt opens with Claude Code's identity line, which the API requires verbatim; ours follows unchanged, cache breakpoint included.

The directory argument (default: the current one) becomes the session root. It cannot wander into C:\Windows because it misread a relative path: every path a tool is handed is resolved against the session root and refused unless the result lands inside it, or inside a directory you named yourself.

--add-dir <dir> adds a directory the file tools may also work in, repeatable and also spelled --add-dir=<dir>; /add-dir does the same mid-session, /cwd lists the set numbered and /remove-dir <n|path> takes one away. The guard keeps one resolution base: a bare relative path always means the session root, so adding a directory can only make an absolute path that used to be refused succeed - it can never quietly re-point src\main.pas at another tree. A file in an added directory is named by its full absolute path, which is exactly how list_dir and search print it back, and the model is told so by a system-prompt block emitted only when there is more than one root.

What an added directory does not get is the interesting half. It grants file access and nothing else: its .pasclaude\hooks.json does not run, its skills, commands and agents are not offered, its .mcp.json spawns nothing and its CLAUDE.md is not read. Otherwise --add-dir would be a way to make any directory execute what it ships, which is a much larger grant than the words suggest. The session, the history, the approvals file, the rewind snapshots and bash's working directory all stay bound to the session root; .pasclaude is refused at the top of every root, added ones included, because a walker and a guard that disagreed about that name would be the inconsistency.

Directories come from the command line or from a typed /add-dir, and from nowhere else - no file names one, and nothing is persisted, so each session starts at one root and you say it again. A write into an added directory still raises the same approval prompt with the same rendered diff, and a deny rule still refuses inside it. --add-dir widens reach, not permission. At most eight extras: the root list is scanned by the hottest guard in the program.

It is honest about its one soft edge: --add-dir C:\Users is accepted and grants a great deal. Only a bare drive letter or share root is refused, and that is a typo guard rather than a boundary. The boundary is that only you can type it, and that it lasts exactly one session.

If the project contains AGENTS.md, CLAUDE.md or .pasclaude.md, the contents are appended to the system prompt as binding instructions. Not under --ci prepare or --ci report, where the working directory is a checkout of the pull request under review: those two runs load none of them and name the files they skipped - see Running unattended. --no-project-context says the same thing for any run, which is what you want when the directory is a checkout somebody else wrote; it stops the three files and every @import inside them, and nothing else. Your own %USERPROFILE%\.pasclaude\CLAUDE.md loads either way, because the question the flag asks is which tree wrote the prompt, and skills are untouched - their descriptions ride in the skill tool's own description and were never in the system prompt at all.

Commands

Command Effect
/help list the commands
/clear forget the conversation, here and on disk
/compact drop the oldest turns, keep the recent ones
/compact full replace the transcript with a model-written summary
/diff list the files this session has changed
/ide the editor around this terminal; /ide diff [<path>], /ide open <path>[:line]
/review [--staged|<ref>] have the model review a local diff; no network, no token
/pr-comments [<n>] [--show] the comments on one GitHub pull request, printed then sent
/hooks the commands that run automatically, yours and this project's; /hooks off stops both
/jobs background commands still running
/mcp MCP servers, yours and this project's: status, /mcp restart <name>, /mcp refresh
/memory show the project memory (CLAUDE.md)
/init have the model write a CLAUDE.md for this project
/rewind undo turns: conversation and edited files
/sessions list saved sessions and resume one
/skills the skills this project offers; also rescans for new ones
/plugins installed plugins; /plugins enable|disable <name>
/output-style [name] how replies are written; bare, it lists them
/paste attach the clipboard image to your next message; drop cancels
/vim [on|off|save] modal line editing; save keeps it for future sessions
/keys the editing keys in force, and where to rebind them
/think [n] extended thinking: on, off, or a token budget
/web [on|off] let the model search the web (off by default)
/resume reload the saved conversation
/save [name] write the conversation now; a name makes a keepable copy
/cwd the session root and any added directories, numbered
/add-dir <dir> also work in that directory (file access only)
/remove-dir <n> stop working in it; the session root cannot go
/model [name] pick a model from a live list, or set one by name
/deny the rules nothing overrides; /deny add <rule>, /deny remove <n>
/mode [name] ask, plan or accept-edits; bare, it shows the whole state
/plan shorthand for /mode plan
/yolo approve every tool for the rest of the session (bypass mode)
/sandbox [level] off, limits or low: how confined child processes are
/cost turns and tokens used
/context what is filling the context window, by kind
/config settings and the tier each came from; get, set [--local], unset, reload
/telemetry metrics state; preview shows the exact JSON, send flushes now
/login [key] which credential answers; key stores one of pasclaude's own
/logout remove that stored credential, and only that one
/status what is true right now: model, credential, mode, roots, MCP, hooks, style, tokens
/doctor [--online] what is wrong or might be, with a remedy each; offline unless --online
/bug [--transcript] [--paths] [--json] write a redacted report to a file; nothing is uploaded
/exit quit (Ctrl+C also works)

A prompt starting with # is a note for the project memory rather than a question: # prefer edit_file here appends to CLAUDE.md (or AGENTS.md / .pasclaude.md, whichever the project already has) under a Notes heading, and the next session starts knowing it. /memory shows the file; /init has the model explore the project and write a starter CLAUDE.md, through the ordinary write approval.

Memory has a user level too: %USERPROFILE%\.pasclaude\CLAUDE.md follows you across projects and loads before the project's own files, so a project can override it - nearer wins. Inside any instruction file, a line that is @import <path> (or a bare @path alone on its line) is replaced by that file's contents, one level deep: enough for a shared conventions file, no room for a cycle. The imported path faces the same session-root guard as everything else; a refused path stays as literal text.

/rewind undoes turns. Before each of your prompts runs, the transcript length is checkpointed, and before any write_file/edit_file touches a file its prior state is snapshotted in memory (up to 400 KB per file). Rewinding lists your turns, and picking one puts both the conversation and the files back to the moment before it - including deleting files that turn created. What cannot come back is named rather than glossed: shell commands are not undone, and oversized files are not snapshotted. Compaction, /clear, and resuming a session drop the checkpoints, because their message positions no longer describe the transcript.

/save <name> keeps a named copy (<name>.session.json) the autosave never overwrites, and /sessions lists everything saved in the directory - the live session, the safety copy, and the named ones, with dates and sizes - and resumes whichever you pick. --continue (-c) takes the most recently written of exactly that set and asks nothing, naming the file it took. One rule decides what counts as a saved conversation, in uAgent.IsSessionFile, and both the picker and --continue go through it - the approvals file lives in the same directory, and a "newest .json" rule would load it as a transcript.

Two differences from Claude Code, said rather than glossed. There --resume is the picker; here it keeps the meaning it has always had - this directory's session.json, loaded silently - because a new flag does not get to change what an existing one loads, and /sessions was already the picker. And --continue is interactive only: under -p it is a startup error, with --session-file <path> as the answer, because a script that continued "whichever conversation was saved last" would continue a different one depending on what you did in the REPL an hour ago. Giving both --resume and --continue is a startup error too - they name different files, and either precedence would be a guess.

Escape pressed twice on a prompt line that was already empty opens /rewind. It is the only new meaning Escape has: the first press still clears the line, a press on a line with text in it still just clears, and with /vim on Escape leaves insert mode both times and never rewinds - a vim user's fingers press it twice to be sure, and stealing that would cost more than the shortcut is worth. It fires at the REPL prompt only, never in the permission question or the /model, /sessions and /rewind pickers. uTerm sits below uTools and cannot reach a slash command, so the word is pushed down from pasclaude.lpr into uTerm.EscEscCommand - empty by default, so a wiring mistake is a shortcut that does nothing - and submitted as though you had typed it, through the one dispatcher every other command goes through.

A slash command nobody built in is looked up in .pasclaude\commands\: /shout loud words reads commands\shout.md and sends its contents as the prompt with every $ARGUMENTS replaced by loud words. The file is the prompt, nothing more - no frontmatter, no scripting - because a prompt in a file already covers the real use: the same request typed often, made one word. Built-ins cannot be shadowed; the lookup runs only after they decline.

pasclaude -p "question" is print mode: one prompt in, one answer out, exit code 0 on success. Piped stdin becomes context under the prompt (type build.log | pasclaude -p "why did this fail?"), or is the prompt itself when -p has no argument. No banner, and no session save unless --session-file <path> asks for one by name - a script's throwaway question should not disturb the directory's saved conversation, and guessing which file it meant is exactly what -p declines to do, so --resume there is a startup error without it. No permission prompts either: stdin may be a pipe, so asking would hang. Read-only tools work; a run that needed an edit approved says so in its output instead of stalling. Deny rules do load, before the halt that stops the approvals file being read, so a scripted run inherits every refusal and no grant - strictly stricter than the interactive session that started with the same flags, which is the rule. --dangerously-skip-permissions is the one way past that, and it prints a warning to stderr when it is used. It also loads no hooks, and spawns no MCP server the project declared: a scripted run cannot be the thing that first executes a project's code. The hooks half is a flag, uHooks.HooksAllowed, false unless the run is the REPL - see Hooks for which four modes that took it away from and why a finally position was not enough.

The MCP half used to be the same sentence and is now two. Your own %USERPROFILE%\.pasclaude\mcp.json is read under -p and its servers do start; the project's .mcp.json is not read at all. The distinction is the one the spawn prompt already makes: that prompt exists so somebody can decide whether to trust a program the project chose, and a scripted run has nobody to answer it - but a program you chose is approved without any prompt in the REPL today, and keeping it out of -p was the same grant withheld from the one caller that could not object. McpApproveAll is handed a nil Ask there and never reaches a question, because only project servers are counted into the prompt loop.

What that buys depends on who is driving. A bare -p has a nil Ask, so it declares your MCP tools and then refuses every call to them exactly as it refuses every other gated tool, and says so in its output. The callers that gain are the two that can answer: a --input-format stream-json driver answering permission_request lines, and --dangerously-skip-permissions. Connection notices are suppressed on this path, because under --output-format json and stream-json stdout carries the protocol - a server that fails to start is visible in its stderr spool and in the /doctor ledger rather than on screen.

Pressing Esc while a reply is streaming stops it, and so does Ctrl+C: outside the prompt the default Ctrl+C handler would kill the process mid-turn, skipping every finally block - console restoration included - so it is caught and treated as the cancel the user meant. Whatever arrived is kept, any tool the model was about to call is not run, and the conversation stays usable for the next question. Ctrl+Break keeps its default meaning: a user reaching for it wants the process gone, not the reply stopped.

A bare /model fetches the models this key can actually use from GET /v1/models and offers them as a numbered list, current one marked, Enter to keep it. Typing an id from memory is guesswork about a namespace that changes under you - the retired-default 404 was exactly that - so the list comes from the API, which cannot be stale. /model <name> still sets one directly, deliberately unvalidated: a model newer than the list, or one behind a beta flag, should not be un-pickable.

Transient failures (429, 529, 5xx) are retried up to three times with a widening delay, and the wait is interruptible. When the response carries a Retry-After header the server's own wait is used instead of the guess - guessing shorter re-hits the limit, guessing longer wastes the user's time. The value is clamped to a minute; a server asking for an hour is answered by giving up, not by an unkillable hour-long sleep.

That sentence was aspirational until recently and is now true, which is worth saying rather than quietly fixing. WINHTTP_QUERY_RETRY_AFTER was declared as 35, which is WINHTTP_QUERY_REFERER, so the query asked for the wrong header, found nothing, and fell through to the guess every single time. The same mistake was larger next door: WINHTTP_QUERY_CUSTOM was 65, which is WINHTTP_QUERY_VARY, so every by-name header read returned the value of Vary regardless of the name asked for - which is how /pr-comments came to follow a Link header that was really Accept,Accept-Encoding, find no rel="next" in it, and report the list as possibly incomplete. Neither failed loudly, because a wrong info level does not fail the call: it answers with a different header. Both were found the first time tests\net.lpr asked a real server for those headers, and both now have assertions there against servers nobody here wrote.

At the prompt, the arrow keys move the caret and walk the command history, Home/End (or Ctrl+A/Ctrl+E) jump to either end, and Ctrl+U clears the line. Ctrl+W, Ctrl+K, Alt+B, Alt+F and Ctrl+Z are the readline verbs it was missing; /vim turns on a modal editor and %USERPROFILE%\.pasclaude\keys.json rebinds any of it. See Editing the prompt below, which also records why nothing in that file can reach a permission prompt.

History persists in .pasclaude\history.txt, so Up-arrow reaches last week's build command in a fresh window. One command per line, multi-line prompts stored with backslash escapes (\n, \\), capped at 200 entries, written after every accepted line for the same reason the session is - the run worth remembering is the one that ended in a closed window. A missing or unreadable file is simply an empty history, never an error.

/think enables extended thinking. The request carries a thinking: {type: enabled, budget_tokens} block, and max_tokens grows by the budget so a long think does not starve the visible reply that follows it. Budgets under the API's floor of 1024 are rounded up rather than rejected; a bare /think uses 8192. The reasoning streams in grey as it always has - the decoder handled thinking blocks and signatures from the start, there was just no way to ask for them.

/diff answers "what changed?" in aggregate, since approvals happen one edit at a time: the files this session's write_file/edit_file actually touched (denied and failed calls are not recorded), followed by git diff --stat HEAD when the directory is a repository - which also sees hand edits the session list cannot.

pasclaude notices when it is running inside an editor's terminal, and only then. VS Code sets TERM_PROGRAM and VSCODE_INJECTION in its integrated terminal; JetBrains sets TERMINAL_EMULATOR. Both are tested for exact equality, so a terminal that merely has vscode somewhere in its name is not an editor. Outside one, /ide says so and does nothing, and every other surface reads "not detected" rather than inventing a host.

/ide diff opens what this session changed: the file as it stood before pasclaude first touched it, in a real diff tab, against the file as it is now. That is not the diff you approved - it is the accumulated one, from the same snapshots /rewind restores from, which is exactly the thing that is hard to see at the end of a long session. /ide open <path>:42 opens a file at a line. Nothing goes the other way: the editor is never read from, no selection, no cursor, and nothing an editor prints is captured. There is no extension, there is no selection support, and there is no plan for either - a VS Code integrated terminal exports nothing naming a file, a line, a column or a selection, so there is no channel to read one over and none was invented.

The left-hand pane is a copy of your file, written under %LOCALAPPDATA%\pasclaude\ide and never beside the file it came from, so nothing appears inside the project you are working in. It does not linger: the next launched /ide diff deletes the last one, so only ever one exists, a diff you decline or that fails to start deletes the file it just wrote, and the end of the session deletes whatever is still held. What survives all of that is a session killed outright - Ctrl+Break, the window's X, Task Manager - which runs no shutdown of any kind; that file is collected by a day-old sweep the next time you run /ide diff, and by nothing else, so a session you never run the command in again never runs the sweep. A file that was too large to snapshot has no "before" side at all, and /ide diff says so and names the 400 KB limit rather than opening nothing.

The approval prompt was left alone on purpose. Opening the proposed change in the editor sounds better than a diff in the terminal and is worse in practice: --wait blocks until you close the tab, so the y/a/n prompt cannot be answered, and without --wait the tab shows a change that has not happened and stays open after you decline it. A rejected edit leaving a tab that looks applied is a worse mistake than any amount of terminal diff.

Starting the editor is starting a program. It goes through the same spawn every other child does, with the sandbox level forced off for the duration and restored afterwards - a GUI shim under low integrity cannot reach your own editor process. A slash command you typed never reaches the permission gate, so rather than leave that silent, pasclaude prints the exact command line and asks once per session. a there lasts the session and is never written to disk: standing approvals are for tools, not for licences to run programs.

Tab completes: slash commands at the start of the line, file and directory names anywhere else, resolved against the session root. Several matches extend to their common prefix; directories complete with a trailing \ so another Tab descends. Pasting a multi-line block keeps its line breaks as one prompt instead of firing a request per line, and Ctrl+Enter inserts a break by hand.

@path in a prompt attaches that file to it: the model starts with the contents instead of spending a tool round reading them. Mentions face the same path guard as tool calls - no escaping the root, no reaching the session state

  • and an oversized file is reported rather than attached. A binary file is still reported rather than attached, unless it is one of the four image types the API takes, which is where Images below picks the story up. Tab completes paths after the @ too.

Replies render markdown as they stream: headings and bold brighten, inline code and fenced blocks colour cyan, fence lines are swallowed. The renderer holds back only the current incomplete line, so output appears line by line. Unclosed marks print literally rather than swallowing text.

Requests carry two cache_control breakpoints: one on the system prompt (whose span also covers the tools) and one on the last message, so each turn reuses the cached conversation prefix instead of re-paying full price for it. Cached reads are billed at a tenth of the normal input rate; /cost shows the cache counters when they are nonzero, which is also how a working cache is distinguished from a silently ignored marker.

A long session eventually outgrows the context window, and the failure mode is the API rejecting the whole turn. Before each request the transcript is trimmed back to roughly 100 KB by dropping the oldest exchanges; /compact does it on demand. The cut is walked forward to a real user message, because a transcript that opens with an assistant turn - or with tool results whose call has been removed - is refused.

Bytes are a proxy for the thing that actually fills the window, so a second trigger reads the measured truth: the prompt token count the API reported for the last request (/cost shows it as context:). Past 150k tokens the compaction is the summarizing kind - the measured trigger fires at a size where the old turns are worth one request to keep - and falls back to the plain trim if the summary request fails, because a session that cannot summarize must still not outgrow the window.

/context is what to type before deciding which of those to do. The statusline meter and /cost both answer how full; this answers with what, which is the question somebody actually has when they are choosing between /compact, /clear and evicting images. It prints two groups, and the split is the cache breakpoint rather than a cosmetic one: the system prompt and the tool declarations sit ahead of it and cost a tenth on a hit, while every byte of the conversation is re-sent at full price every turn. The conversation is broken into six fixed rows - your messages, replies, thinking, tool calls, tool results, images - with a block count beside each, so 2 images, 8 KB and 40 images, 8 KB read differently, as they should.

One measured number, and everything else in bytes. The API reports prompt tokens for a request as a whole and never per block, so the token figure is the one the server sent and the table beside it is byte shares. A tokens column per row would mean inventing a bytes-per-token ratio and applying it to prose, base64 and JSON keys alike - the same class of invention as the price table this program refuses to ship, and wrong the same way. What is honest is the session's own measured ratio, printed once at the foot as a single figure you can apply yourself. The rows also sum to less than the transcript, and the gap is named rather than absorbed: the role keys and array punctuation wrapping every message are real bytes that no eviction can reach.

The classification is by block type first and role second, which matters for exactly one row: a tool_result rides in a user message, so asking about the role first would file every tool result under "your messages" - and that is the row somebody would then try, and fail, to shrink by typing less. ux asserts it.

/compact full is the expensive kind: it asks the model to summarize the conversation - what was asked, what was done, exact paths, decisions and their reasons, what is unfinished - and replaces the whole transcript with that summary as one legal exchange. One request buys a transcript that keeps the substance of the dropped turns instead of forgetting them. The old transcript is only replaced after a non-empty summary has fully arrived; a failed request, a cancellation, or an empty answer restores the conversation byte for byte, because a compaction that destroys the conversation on a dropped connection is worse than no compaction.

Sessions

The conversation is written to .pasclaude\session.json after every turn, so the session worth keeping - the one that ended in a crash or a closed window - is already on disk. --resume picks it up at startup and /resume does the same later; --continue takes the most recently written save instead - whichever of the live file, the safety copy or a /save that is - and /save forces a write.

The API key is never stored. It belongs in the environment, and writing it into a file inside the user's project is how secrets end up committed.

A saved file is validated before it replaces anything: it must be an object of a version this build understands, every message must have a known role and at least one typed content block, the transcript must open with a user message, and every tool_use must be answered by a tool_result carrying the matching id. Anything else is refused with a reason, and the conversation already in progress is left exactly as it was. That ordering is the point - a file the user hand-edited should cost them nothing, and a transcript the API would reject is worse than no transcript at all, because it fails on every later request rather than at load time.

The write goes to a temporary file and is renamed over the old one. It runs after every turn, so a crash or a full disk partway through a plain in-place write would take the previous good session with it - fmCreate truncates before it writes. The file on disk is now always one complete session or the other.

A turn that fails - no key, no network, a rejected request - leaves the user's question in the transcript with nothing answering it, and autosave runs after a failed turn too. That question is dropped before saving, so a session never ends on an unanswered user message. A trailing tool_result is deliberately not treated the same way: it answers the assistant turn before it, and removing it would orphan that tool call.

/clear clears the saved copy too. Otherwise it would mean "cleared until you resume", and a conversation the user deliberately discarded would come back on the next run.

.pasclaude is invisible to the tools. It sits inside the directory the model is working in and holds the conversation, so search would otherwise match it and hand the model a copy of its own transcript - which goes into the next request, gets saved again, and grows every turn. edit_file could rewrite the history of the turn currently running. It is skipped by list_dir and search and refused by the path guard, including by a roundabout path. A file merely named similarly, like .pasclaude-notes.md, is unaffected.

Starting in a directory that already holds a session, without --resume, moves that session to session.prev.json before anything can overwrite it. Two windows open on one project is not exotic, and the second one used to destroy the first's conversation on its very first save.

--resume is the one exemption, and it is exempt because it loaded that exact file: the copy would be a copy of what is about to be written back. --continue is not exempt, though it briefly was. It takes whichever save is newest, which may be a /save copy, and loads nothing at all when the newest one will not parse - and in both of those the first turn writes over a session.json nobody read. So the copy is taken whenever the file about to be overwritten is not the file that was loaded, which is the question that actually matters rather than which flag was typed.

Tools

Tool Approval Notes
read_file no line-numbered, capped at 400 KB, hex dump if not text; a .ipynb comes back as numbered notebook cells instead
list_dir no skips .git, node_modules, .pasclaude and .gitignored entries; depth 4 when recursive, or a depth argument up to 12
search no case-insensitive substring or, with regex, a bounded regular expression; * globs, case_sensitive and depth arguments, an optional path narrowing it to one tree, respects each root's .gitignore, capped at 200 hits
write_file yes creates intermediate directories
edit_file yes one hunk or several at once; all must match or none apply
notebook_edit yes replace, insert or delete one cell of a Jupyter notebook; same diff preview and approval as edit_file
bash yes cmd.exe /C inside a job object, output merged, 120 s timeout; run_in_background returns a job id instead of waiting
bash_output no what a background job has printed since the last read, or the list of jobs
kill_bash no stops a background job and the whole process tree under it
fetch yes HTTPS GET, capped at 200 KB, own "always" class
todo_write no the visible task list; display state, touches nothing
task no hands a self-contained question to a read-only subagent
skill no reads a named SKILL.md out of the project; declared only when the project has skills

web_search is declared to the API rather than implemented here: the search runs on Anthropic's servers, /web on is what turns the declaration on, and there is no local code for it to call. An MCP server's tools appear beside these as mcp__<server>__<tool>, contributed at runtime rather than written down here.

At each prompt you can answer y (once), a (always) or n. Read-only tools never ask. For the edit tools and fetch, "always" covers the tool class. For bash it covers the program: answering a to git status approves future git commands, not del /s - quietly extending one approval to every future command is how trust gets spent. The prefix is the first token, lowercased, stripped of path and .exe, so git and C:\Tools\git.exe match. A compound command (&, |, ;, redirects, %var%, ^) has no prefix and is asked about every time, because cmd.exe runs everything after the separator regardless of what the first program was. "Always" for one degrades to this-once. /yolo still approves everything.

"Always" answers persist in %LOCALAPPDATA%\pasclaude\approvals\<project>-<hash>.json - the tool-class grants, the approved bash programs and the trusted fingerprints - so a restart does not re-ask what was already answered. It lives outside the project on purpose: the file records what you let this project do, and a repository that could ship its own copy would be answering its own question - pre-approving its own hooks and its own MCP servers before you saw a prompt. One file per project directory, keyed by its full path. The file only ever widens what is approved, never narrows a live grant, and it is deliberately tiny and hand-editable: deleting a line must do the predictable thing. /yolo is the exception: its blanket flags are "I trust this session", not "and every future one", so a yolo session never writes the file. Print mode neither reads nor writes it - a scripted run must not inherit interactive grants.

That file now carries three keys with three different polarities, which is worth knowing before editing one. The grants (allow_edits, allow_bash, allow_fetch, bash_programs, trusted) only widen: a true key can turn a flag on and an absent one can never turn it off, which is what makes allow_edits: false - written by /mode ask - a durable off switch rather than a silence. deny only narrows, is read by a different function that runs much earlier, and is written back verbatim, unparseable lines included, because a hand-edited rule silently erased at exit is worse than one that never worked. sandbox only raises: "low" loads, "off" is neither written nor read, so nothing on disk can be the reason the sandbox is not running. Working directories and the permission mode are stored nowhere at all.

The model also has a todo_write tool for multi-step work: it maintains a task list that renders in the terminal as it changes - [x] done (green), [~] in progress (yellow), [ ] pending - so a long task shows its plan rather than a wall of tool calls. No approval needed; the list is display state and touches nothing.

fetch reaches the outside world, so it asks - and its "always" answer is its own class, separate from the edit tools, because approving edits forever should not quietly approve network access too. Only https:// URLs are accepted, the response is cut at 200 KB during the transfer rather than after it, and a body that is not valid UTF-8 is scrubbed rather than hex-dumped: a page in another encoding is still mostly readable text, unlike a binary file.

search takes a regular expression when asked for one, and the engine is hand-written (src\uRegex.pas) rather than uses RegExpr. The reason is worth recording, because the obvious choice is the wrong one. FPC does ship TRegExpr and it compiles and links here - that was checked before anything was written. It was rejected on one property: TRegExpr 0.987 is a backtracker with no step limit, no deadline and no abort hook anywhere in its source. Measured on this machine, (a+)+$ against a run of as took 109 ms at 20 characters, 1.7 s at 24 and 17.7 s at 27 - a clean doubling per character. A tool call runs synchronously with nothing above it holding a clock (the 120 s deadline belongs to the shell runner, not the tool layer), so one pathological pattern against one ordinary line of code would freeze the session with no way out short of killing the process. Nothing bolted on from outside fixes that: an input-length cap cannot beat an exponent, a watchdog thread cannot interrupt a compute loop, and screening patterns for nested quantifiers is a heuristic that misses (a|a)+ and a?a?a?....

So uRegex is a Thompson NFA simulation instead. All live threads advance over the line in lockstep, one byte at a time, with a per-position visited set that collapses duplicate program counters; the cost is bounded by line length times program size, and catastrophic backtracking is not screened for - it cannot be expressed. It is small (about 900 lines) only because search reports whole matched lines and never a slice of one, so it needs no capture groups, no leftmost-longest rule and no greedy/lazy distinction. On top sits a step budget spent once per search rather than per line; when it runs out the tool reports the hits it found plus a note that the search was stopped, because "incomplete" is a different answer from "no matches". bin\fuzz.exe asserts that (a+)+$ over 60 characters returns in under two seconds - the assertion that encodes the whole decision.

Regex is opt-in through regex: true rather than detected from the pattern: Result :=, array[0] and foo.bar are exactly what code searches look like, and auto-switching would silently change what an existing prompt means. The syntax is byte-oriented ASCII (\w and \b are [A-Za-z0-9_], bytes at or above $80 match only as literals, no backreferences, no lookaround), which is safe because the output is always a whole line, so no match can cut a UTF-8 sequence in half. Both tree walkers take a depth argument now, 1 to 12, clamped rather than refused; their defaults are unchanged.

pasclaude reads a .ipynb as cells, not as JSON. read_file on a notebook prints == cell 0 (code, execution_count 3) == headers followed by the source verbatim and one summary line per output: image/png (148.2 KB) rather than the 148 KB itself. That is the whole point - a single display_data output is routinely megabytes of base64, and dumping it would spend the context on data the model cannot use. text/plain reprs are shown (truncated), streams and tracebacks are shown with ANSI escapes stripped, and everything past 15 lines or 2000 characters per output, or 8 outputs per cell, is counted rather than printed.

Editing goes through notebook_edit(path, cell, edit_mode, source?, cell_type?), not edit_file. Damage from the wrong read default is unrecoverable - the base64 is already in the context - so reading had to change; editing had to be a separate tool because edit_file's contract is substring replacement on file text, and a notebook's text is JSON. Overload it and old_text means something different depending on the extension, while insert and delete have no expression in its schema at all. notebook_edit asks for approval exactly as edit_file does, and its diff preview is a diff of the cell view - so what the user approves is what the model proposed, in the same words, and base64 cannot reach the prompt.

The file is written back in Jupyter's own layout (one space of indent, sorted keys, trailing newline), byte-identical to what nbformat produces: round-trip fidelity here means "the user's git diff shows one changed line", not "Jupyter still opens it". A notebook someone formatted differently is reformatted once on the first edit, which the permission prompt announces. Non-ASCII is written raw, which is what nbformat does too - its writer passes ensure_ascii=False to json.dumps, against that function's own default - so an accent, a CJK character or an emoji outside the BMP comes back as the bytes it went in as and no line the user did not touch moves. This README used to claim the opposite, that escaping was a deliberate deviation; it was not a deviation and it was not deliberate, it was untested, and the fixture that now checks it was written by nbformat itself. A file that arrived with \uXXXX escapes in it, which some other writer produced, keeps them: the parser remembers the literal a string arrived as whenever writing it back our way would change it, and the writer hands that literal back, so the user's diff is still the one line they asked about. Those two sentences are not in tension - the first is about what this program writes when it is composing a string, the second about what it reproduces when it is rewriting somebody else's file - and the second reverses an earlier preference on purpose: normalising towards Jupyter's spelling is still a change to a line nobody touched. Three limits. Object keys are not covered, because the keys of an object live in a parallel array that four operations shift and a raw-key array shifted out of step would emit one field's name onto another, which is a corrupted notebook rather than a spurious diff. It is the literal that is reproduced, not the layout: indent, key order and the trailing newline are still canonicalised, which is the reformatting the permission prompt announces. And a string that arrived carrying a raw byte below 0x20 - an unescaped tab or newline inside the quotes, which this parser accepts and strict JSON does not - is written back escaped rather than as it arrived, so that one string's other escapes are normalised with it. That is the one place "keeps them" is knowingly not kept, and it is deliberate in the other direction: handing the raw byte back would produce a file nbformat refuses to read, which is worse than any diff. The fidelity is switched on by the notebook path alone - one parser entry point, uJson.JsonParseVerbatim, whose only caller is uNotebook - so a request body is still composed and emitted exactly the way it always was. The line array is cut where Python's str.splitlines(True) cuts it, which is on eleven things and not on \n alone: a form feed page break in a Python source used to make one array element here where Jupyter makes two. A source replace keeps the cell's outputs and execution count - the user approved "change this cell's code", not "throw away the plot it produced" - and the cell view labels them, so a later read shows the model they are now stale.

Only nbformat 4 is supported. A v3 notebook is refused by name rather than upgraded, and that stayed the answer after the upgrade was measured rather than guessed at: nbformat's own converter flattens the worksheets, joins a heading cell's lines into one, and draws every new cell id from a random word corpus, so the same v3 file converted twice gives two different v4 files. A conversion that is not reproducible cannot be the no-op an edit has to be, and it rewrites every line of a document whose one guarantee is that an edit touches the cell it names. The error names the tool that owns the conversion instead of only saying no.

Shell commands can outlive the tool call that started them. bash takes run_in_background; setting it returns a job id straight away instead of waiting. bash_output reads whatever the job has produced since the last read, along with whether it is still running and its exit code once it is not; called with no job id it lists every job instead. kill_bash stops a job and everything it started, and hands back the output it had not yet given up. /jobs shows the same list to the user, because a process started on their behalf by a model has to be visible without asking the model about it. Foreground commands still time out after 120 seconds; background ones do not.

Output goes to a spool file under .pasclaude\jobs, not a pipe. An anonymous pipe that nobody drains fills and then blocks the writer forever, and for a background command "nobody is draining it" is not a risk but the definition, so the deadlock would be the normal case rather than the corner. The alternatives were a reader thread per job - in a codebase with no threading anywhere to model one on - or an overlapped I/O state machine. A file cannot fill. The child's stdin is an inherited handle onto NUL, so a command that reads stdin gets an instant EOF instead of waiting forever for a keyboard nobody is at.

Each job also owns a Win32 job object with kill-on-close, so the whole process tree dies with pasclaude, including the case where pasclaude dies badly: Windows reclaims the handle even when no finally block runs. That is the only mechanism on Windows that answers "must not be orphaned" honestly - TerminateProcess on cmd.exe leaves the grandchildren, which is precisely the shape of npm run dev, and taskkill /T does nothing at all if pasclaude itself is killed. FPC 3.2.2 does not declare the job API, so the prototypes are declared once, in uSandbox, which is also where the job is created and the child assigned to it before it is allowed to run.

Polling reads the spool from a per-job byte offset that advances by exactly what was returned: no byte twice, no byte skipped. While the job is running the chunk is trimmed back to the last newline, so the model never sees half a line - and never a multi-byte character split across two reads, which would leave both halves invalid with no way to rejoin them. A single line longer than the read cap is the exception, handed over cut at the last complete character rather than held back forever. A poll is bounded by that read cap rather than by Clip, because Clip would drop bytes the model can never ask for again. OEM console output is converted to UTF-8 exactly as it is for foreground bash.

A background command faces the same permission gate as a foreground one, and the same remembered per-program approval covers both. Backgrounding is a question about who waits, not about what runs: a detached del /s deletes exactly as much as an attached one. The prompt says [background] so the user can see they are approving a run that outlives the answer. bash_output and kill_bash ask nothing - they can only observe or stop a process this session already got permission to start, and refusing a kill is a gate that can only ever do harm. Jobs stop at /clear (the transcript holding their ids is being thrown away, and a job nobody can name is a process the user cannot get rid of) and at exit. They deliberately survive cancellation: Esc cancels the model's reply, not the user's build. At most eight run at once, and a job that writes more than 16 MB is stopped and says so.

Two limits are worth stating rather than hiding. The 16 MB cap is checked on a throttled tick that runs at the top of every executed tool call, on every chunk of a streaming reply, before every prompt is drawn, and now every quarter second while the user sits at a prompt - the console read waits with a timeout rather than blocking forever, and the permission question ticks on the same clock, because that is where a user sits longest. Enforcement no longer depends on the model choosing to start, poll or list a job, nor on anybody touching the keyboard. Every quarter second nobody types, exactly: a wake caused by a key goes straight to the read and runs nothing, because a sweep that has to kill a job waits two seconds for each one to die, and that belongs at an idle prompt and never between two characters of a line being typed. A paste or a held key therefore suspends the sweep until the typing pauses, which is a window bounded by the burst. It is still a bound and not a guarantee: nothing here is in the child's write path, so a child can write between two ticks and the real promise is the cap plus a quarter-second of writing - everywhere, with no exception for the prompt. And a job still running at exit is stopped by design, so a user who wanted a truly detached server will find it gone; that is the deliberate trade against orphaning, which the launch message and /jobs make visible.

A tool call shows up the moment its block opens in the stream - the name first, then the argument JSON echoed as it arrives (capped at 160 characters) - so a long argument stream, a big write_file say, reads as progress rather than as a hang. The full description line follows once the arguments are complete.

A .gitignore at the root filters listings and searches - build output is noise the model pays tokens to look at. The reading of the format is deliberately partial (dir rules, * within a segment, anchored rules, ! negation); anything unmatched is shown, which errs on the side of the model seeing more. When the directory is a git repository, the system prompt names the branch and whether the tree is dirty, saving the model its most common first command.

A write or edit shows its diff before you answer, so the decision is about the change rather than about the file name:

pascal
  permission needed: edit_file
    edit src\uAgent.pas
    1 added, 1 removed
      if not SendWithRetry(Blocks, StopReason, Err, Cancelled) then
    - Exit(False);
    + Exit(RecordFailure(Err));
      RecordAssistant(Blocks);

The diff is built from the file as it is on disk, not from what the model believed was there, and it is only computed when someone is actually going to be asked - under /yolo the work is skipped entirely. A binary file is summarised instead of dumped, and an edit whose snippet is missing or ambiguous is refused before any prompt appears.

The permission predicate

"May this run?" is answered in four functions, and the order they run in is the whole of the argument. It is written down here because a line inserted in the wrong place still compiles, still passes most of the suite, and quietly turns a boundary into a suggestion.

RunTool is the boundary layer. It runs before any gate is consulted:

pascal
R0   IsError := False
R1   clear the pending hook allow    (a hook's yes never crosses calls)
R2   refuse a nil input              (a rule cannot be matched against nothing)
R3   [DENY] a tool rule matching the name
R3b  [DENY] a bash rule matching any cmd.exe segment's program name
R4   plan mode: refuse anything not on the plan allowlist
R5   the subagent read-only list
R6   fire PreToolUse hooks; a block refuses here
R7   the per-tool arm: validate, resolve paths through SafePath, build the
     preview, call the gate
R8   fire PostToolUse hooks

Below that sit the three gates, and each opens with the same lines. Permit (edits, fetch, MCP tools) is [DENY], then BypassMode, then the class allow-alls, then the edits catch-all, then a per-server standing yes, then the nil-Ask refusal, then a hook's allow, then the question itself. PermitBash is [DENY] twice - the tool name and the program name - then bypass, the bash blanket, the stored prefix, nil Ask, a hook's allow, the question. SafePath resolves the path, refuses anything outside every root, refuses the state directory, and only then applies the path rules.

Five orderings carry the safety, and none of them is stylistic.

Deny is first everywhere. Above the class allow-alls, above the bash prefix table, above the nil-Ask check, above a PreToolUse hook's allow, and above BypassMode - so nothing overrides a rule: not /yolo, not an "always" answered weeks ago, not a hook that answers allow. It is also above the hook fire, because a hook is a program a repository ships and handing it the arguments of a call the user forbade is a leak even when the hook cannot allow it. The copies in Permit and PermitBash are belt and braces: a reader sees deny-first without trusting the caller, and a fifth gate added later cannot be talked into a yes. Any fifth gate opens with the same lines - that is a copy-paste rule on purpose, not a memory test.

Plan mode is a boundary, not a gate setting. It lives in RunTool, in a different function from Permit, beside the subagent read-only list. A check inside the gate would have sat below the very short-circuits it needed to beat; here, bypass, a class allow-all, a stored bash prefix, a hook's allow and a nil Ask are all structurally unable to lift it, and none of them had to be taught that plan mode exists.

TakeHookAllow stays below the nil-Ask check. Three properties fall out of that position with no separate guard: print mode cannot be widened by a hook, a subagent cannot, and the flag is reachable only where a human was about to be asked anyway. The position is frozen; a feature that moves it is a defect.

SafePath is the only place a path argument becomes an absolute path. Every path-taking tool, the change preview, @-mentions and @import funnel through it, so ..\ tricks, 8.3 names and junctions are unwound exactly once and a tool added later cannot forget the rule. Deny is applied once, outside the root loop, on the winning candidate: inside the loop it would be order-dependent, and a rule whose effect depends on which directory was named first is exactly the silently-failing rule deny exists to eliminate. The escape comparison exists in one function, WithinRoot, and compares against Root + PathDelim - which is what refuses C:\proj-sibling\leak.txt against C:\proj. Nobody else writes a prefix compare.

The sandbox is not an input to any of this. No branch of any gate reads uSandbox.SandboxLevel, and the level does not appear in an approval prompt's detail line either. A confined command still reads every file you can read and still reaches the network, so "it's sandboxed" buys no approval discount, and a level shown at approval time would only invite "so yes".

What can move any of it is a short list, and the project directory is not on it. No mode, working directory, deny rule or sandbox level is ever read from a file inside a root, from a hook's output, from an MCP server, a skill, a custom command, a plugin manifest or anything the model can emit. A mode comes from a keystroke, from this process's command line, or from the allow_edits key the user's own "always" wrote in %LOCALAPPDATA%. A working directory comes from argv or a typed command. A deny rule comes from %LOCALAPPDATA%\pasclaude\deny.json or from a hand-edited "deny" array in the per-root approvals file. The sandbox level rises from argv, /sandbox, or a key a previous session wrote, and falls only from argv or /sandbox. The built-in slash dispatch runs above custom-command expansion, whose result is prompt text and is never re-dispatched; that ordering is load-bearing.

Under -p every one of these is either identical or strictly stricter. Deny rules load before the print-mode halt and standing approvals load after it, so a scripted run inherits every refusal and no grant, and can neither add nor remove a rule. LoadDenyRules reads only the deny array of each file, and that is the whole reason: it is the one reader of the approvals file that runs early, and if it ever grew to read a grant key, -p would start inheriting approvals.

Deny rules

One string per rule, kind:pattern, in a JSON array:

pascal
tool:bash        tool:fetch      tool:mcp__github__*
bash:rm          bash:del
path:.env        path:**/*.pem   path:/secrets/**

tool: and bash: are read at the top of RunTool, above the PreToolUse hook fire; path: is read inside SafePath, where every path-taking tool, @-mention and @import already funnels. A refusal names the rule and the file it came from, so a rule is never mistaken for a bug. A pattern with no separator in it is a name rule and matches the base name at any depth, so path:.env catches src\.env without anybody thinking about where they are; a pattern with one is anchored, and measured against whichever root contains the file, so path:secrets/** means the same thing in an added working directory as it does in the session root. Path rules also hide the file from list_dir and search - the guard and the walkers agree, rather than the walkers hiding something the guard would still hand over by absolute name. An unparseable rule is kept with its reason, reported at startup and listed as NOT IN FORCE, because a user who wrote read:.env believing it protected something has to be told it does not.

Rules live in %LOCALAPPDATA%\pasclaude\deny.json (global, every project) and in a "deny" array in the per-root approvals file (per project, hand-edited, written back verbatim). Nothing in a repository is ever read for one - a deny rule can only narrow, but strictness in one place buys looseness in another: denying search pushes the model onto bash, where an "always" the user gave weeks ago is waiting. /deny lists rules with their source file; /deny add <rule> and /deny remove <n> write the global file. remove is the only widening operation in the feature and exists only at the console - unreachable from -p, from the SDK protocol, from a hook, from a server, from the model.

Three honest limits, printed by /deny as well as documented. bash: reads the first token of each cmd.exe segment - it catches git status && rm -rf x, where the approval prefix table gives up, but it cannot follow %VAR% expansion, a for loop, a renamed copy, a .cmd wrapper or a caret inside the name (r^m); tool:bash is the airtight form. path: covers pasclaude's file tools and not the shell: type .env run through bash never touches SafePath. Hardlinks evade path: - canonicalisation follows junctions, 8.3 names and case, but a second name for the same bytes is a different path to every API Windows offers.

fetch:<host> is deliberately not offered. WinHTTP follows redirects and uHttp sets no redirect policy, so a host rule would match the URL the model typed and not the host that answered; tool:fetch is the honest version. Blocking an MCP server's spawn is likewise not a deny rule: the spawn decision happens before any tool name exists, so a rule can only block calls (tool:mcp__srv__*) and stopping the program means editing .mcp.json.

Permission modes

The gate now has a word for what it is doing, and the word is on the prompt. ask is the default and means what it says. accept-edits stops the prompts for file writes and nothing else - it is the flag a previous "always" answer already set, finally visible and finally revocable. bypass approves everything for this session and persists nothing. plan is the odd one out and the most useful: the model may read, search, fetch and keep a todo list, and every call that would change anything is refused before the permission gate is consulted at all.

That last clause is the design, and its mechanics are in the predicate section above. What plan mode allows is an allowlist of seven tool names in one line - read_file, list_dir, search, todo_write, skill, task, bash_output - which is how a tool somebody's MCP server contributes next year is refused without anybody deciding to refuse it.

fetch was on that list once, on the argument that investigation is the point of the mode. It came off. Every other name there reads something already on this machine, which is what makes plan mode safe to enter without being asked anything; fetch is the one channel by which what those tools read can leave. A mode whose promise is "look, do not touch" cannot also POST the tree to a URL, and a promise with one exception is not a promise anybody can rely on. Investigation that genuinely needs the network is what leaving plan mode is for.

The model is told the mode twice: once as a paragraph in the system prompt, so it does not spend a turn finding out, and once in the refusal, because prompt text is advice it can ignore mid-turn. The paragraph rides in a trailing system block with no cache marker, so turning plan mode on costs a few dozen tokens instead of re-reading the whole cached prefix - and with everything at its default the request body is byte-identical to what it was before this feature existed.

There is no tool the model can call to leave plan mode. A tool that lets the model leave plan mode is a tool that lets the model grant itself write access. Leaving is a keystroke - /mode ask or /mode accept-edits - and it approves nothing retroactively: a plan is prose, and an approval that claims to cover "the plan" while actually covering everything is a lie with a friendly label. What real approval looks like is the diff ChangePreview already renders, one edit at a time. /mode ask also revokes the bash, fetch and MCP class blankets, which is broader than the name suggests and is printed; the named per-program and per-server grants are left alone, because each of those named the thing it covered.

--dangerously-skip-permissions is /yolo before the first prompt, including under -p, and it is the largest single weakening in the program: a prompt injection in a repository file reaches a fully unattended agent. It keeps its long spelling on purpose - --permission-mode bypass is rejected by the parser with a message naming the real flag, and /mode bypass says the same. What still stands in its way: the session root, the deny rules, the subagent read-only list, and the fact that -p halts before it can load a hook or spawn an MCP server. Two combinations are startup errors rather than silent precedence: --permission-mode accept-edits under -p with no stream-json driver ("stop asking me" presupposes a me), and --permission-mode plan together with --dangerously-skip-permissions, where either guess about which the user meant is dangerous.

No mode is persisted or resumed - a session restarted with --resume comes back in ask - and plan mode stops the model, not the machine: a SessionStart or UserPromptSubmit hook is a command the user configured themselves, and it still runs. /mode says so, and prints the four class flags, the counts of the narrow grants a mode word does not cover, and whether this session will persist anything at all. The prompt string carries the mode on every keystroke (plan>, accept-edits>) with a + when a class grant or a stored bash prefix makes the bare word an understatement; the + is a pointer to /mode, not information, because no suffix can render every combination and one that tried would be read as complete.

Sandboxing

This is defence in depth and not an approval mechanism, and the two must not be described in the same breath as if they were points on one scale. A mode decides what you are asked; the sandbox decides what a child process can do once you have said yes.

Every command pasclaude runs - the bash tool, a background job, a hook, an MCP server - starts inside a job object. The tree is capped at 64 processes, cannot break out of the job, cannot read the clipboard or change your desktop, and dies when the session does. That is the default, it costs nothing, and a command that behaves will never notice it. /sandbox off removes the limits but not the job itself: kill-on-close is what makes kill_bash, a hook's timeout and process exit reap a whole tree rather than terminating cmd.exe and orphaning what it started, and that was never one of the restrictions "off" was meant to switch off.

--sandbox low goes further and runs children at low integrity. They then cannot write your user profile, the registry under HKCU, or any directory nobody has labelled for them - which includes the project you are working in. They get a scratch %TEMP% of their own instead, under %LOCALAPPDATA% and keyed like the approvals file, out of the tree. That is a real cost, which is why it is opt-in: a build that writes into its own tree will fail, npx-based MCP servers and hooks that write %APPDATA% will fail, and the failure says so on the same line as the exit code rather than leaving you to guess. When there is no home directory to put the scratch in, low falls back to limits and says so - never to off, because failing to find somewhere for a temp directory is not a reason to stop confining children.

What low integrity does not do is worth saying plainly, because it is the part people assume. It does not stop a command reading anything you can read, and it does not stop it using the network - a probe on the machine this was developed on ran dir %USERPROFILE%, read .gitconfig and completed an HTTPS request under low integrity, all exiting 0. Scoping a process to a directory needs a filesystem filter driver; denying it the network needs a firewall rule. Neither is something an ordinary program can do to itself, so neither is offered.

That measurement is why the sandbox changes nothing about what you are asked to approve. A confined command can still read every credential on the machine and send it anywhere, so "it's sandboxed" is not a reason to say yes to something you would otherwise refuse. The level is shown in /sandbox, in the banner when it is not the default, and on a failed command's exit line - [exit code 1; sandbox: low], tagged on the exit code rather than on any message marker, because the markers that would let us guess more precisely are English. A hint naming icacls and /sandbox off is added on top when the failure looks sandbox-shaped. It is deliberately not in the approval prompt, and deliberately not in the system prompt either: a model told it is sandboxed routes around the sandbox.

Two designs were probed and rejected. Restricted tokens produce a child that dies at 0xC0000142 without window-station ACL plumbing, and dropping privileges alone buys nothing on an ordinary user token. AppContainer needs a persistent per-profile identity and hand-written ACL grants for confinement that is still mostly filesystem scoping we would have to build anyway. And one asymmetry is documented rather than smoothed over: under low, an added working directory is writable by write_file and not by sandboxed bash. pasclaude never changes an ACL or an integrity label on anything outside its own scratch; the failure message tells you the exact icacls command so you can do it to your own tree deliberately.

Building this closed two older defects. The foreground shell had no job object at all, so a timed-out command killed cmd.exe and orphaned its children; and all three raw spawn sites assigned to the job after the spawn, leaving the grandchild-escape race this README used to list as an accepted limit. SandboxSpawn creates suspended, assigns, and only then resumes - a child that has not run yet cannot have started anything outside the job.

Web search

Anthropic's web search is a server-side tool: pasclaude declares it and the API does the searching. There is no RunTool branch, nothing to execute locally, and - the part that decides the design - no per-call hook to hang a permission prompt on. Since the user can never be asked "may I search for this?", the only honest place to put the consent is the declaration itself, so the declaration is what the user controls. /web on turns it on for the session, /web off turns it back off, and --web does the same for print mode, where Ask is nil and nothing could be approved interactively anyway. It is off by default: with it off the tool is absent from the request body entirely, so the model has no way to reach the network and the session pays no tokens for a declaration it may not use. It is deliberately not persisted, for the same reason /yolo is not - a standing file meaning "and every future session may reach the internet" is a wider grant than the word implied. fetch is unchanged and is still the way to read a known URL.

The decoder repair is the interesting half. It assumed every content block is text, thinking or tool_use; anything else fell into an else branch that turned it into an empty text block, which RecordAssistant then dropped for being blank. A server-side block would therefore have vanished from the transcript - breaking the echo the API requires on the next request - while the input_json_delta carrying its search query accumulated into that text block and would have been sent back as assistant prose. Two new block kinds fix it: bkServerToolUse, which is the local tool_use shape under a different type string, and bkResult, a raw passthrough that captures the block's own JSON whole from content_block_start and replays it verbatim. A block this client never understood is one it cannot correctly rebuild, so it is not rebuilt. Making bkResult the fallback for every unrecognised type fixes redacted_thinking and whatever ships next for free.

Exactly one block type is now understood far enough to be shortened. A search result set is echoed back on every request for the rest of the session and is by a wide margin the largest thing in the transcript - a title and a url are a few hundred bytes, and then there is an opaque blob of page content per result that runs to several kilobytes - so a single verbose search taxes every turn that follows it. A web_search_tool_result larger than 32 KB is therefore clipped as it is captured: whole results are dropped off the end until it fits, the last surviving result's title gains a note saying how many went, and the status line on screen says so at the time. The first result is never dropped, because an empty array would read as a search that found nothing. Nothing inside a result that survives is regenerated, shortened or reordered, so the sentence above about not rebuilding what this client does not understand stays exactly true of every byte that is sent.

Whether a subscription OAuth token may declare this tool, and whether the dated type string web_search_20260209 is still current, are things only a live server can answer - and a rejected declaration would 400 every turn for the rest of the session. So a failure whose message names web_search turns the tool off, prints a notice, and retries the same round without it. The worst case is one wasted request. That converts an unverifiable assumption into a bounded, testable degradation, which is also the only form of it a suite with no network can cover. A long server-side run ends the turn with pause_turn rather than end_turn, which Send treats as "keep going" instead of as a finished answer.

Two costs come with it. Search results enter the transcript verbatim and are echoed to the model on every later turn, which is a prompt-injection surface this codebase did not previously have; default-off limits the exposure but does not remove it. And results are not Clipped - they are not RunTool output - so a verbose result set inflates the transcript and every cached turn after it.

Subagents

The task tool hands a self-contained investigation to a nested agent with its own conversation and returns its final message as the tool result: task(prompt, agent_type?), one at a time, synchronously, inside the parent's tool call. The model uses it for the questions that would otherwise fill the main transcript with intermediate reading - "which unit owns X", "where is Y configured" - and works from the answer instead of from six file dumps.

uTools sits below uAgent, so the tool that spawns an agent cannot know what an agent is. That is resolved exactly as the network already is: uTools declares TSubagentProc and var SubagentRunner: TSubagentProc = nil, and uAgent's initialization assigns it. A nil runner means the tool is not advertised and, if named anyway, returns a plain error - deny by default extended from files to a capability. It is the same shape as uHttp.HttpTransport, so it introduces no new concept.

A subagent gets read_file, list_dir and search. Nothing else. It runs inside a tool call, where the user is being asked nothing and is not even looking at a prompt; threading Ask down would raise a permission dialog on behalf of a conversation the user cannot see, whose question they never wrote, with a detail line that is a plausible-looking write request and no context. And it would be theatre, because Permit short-circuits on AllowAllEdits and PermitBash on a persisted "always" - so under /yolo, or after a single a pressed in some earlier session, a writable subagent would write and shell with no prompt at all. Deny-by-default would be honoured in letter and defeated in spirit, in exactly the sessions where the user had already relaxed their guard. It is enforced twice: ToolsSchema stops after those three tools when the depth is raised, and RunTool refuses everything else at the top. The second is the real one, because the schema is advice to the model and RunTool is the boundary - and it runs before any Permit call, so a standing grant cannot reach past it.

Read-only is also what answers the shared-state question. uTools has six module-level blobs two live agents would share, and the honest answer to "which must be saved and restored around a subagent" is: none of them. The todo list, the changed-file list, the bash prefix table and the rewind snapshots are written only by tools a subagent cannot call, and BeginTurn is host-only, so /rewind checkpoints stay keyed to the parent's turn numbers. RootDir is legitimately shared - same session root, same guard. That is a property a reviewer can verify by reading one three-name allowlist, where the alternative was trusting six save-and-restore pairs, one missed restore being a silently corrupted rewind.

Depth caps at one (MaxSubagentDepth), in a counter whose Enter/Leave is balanced in a finally so an exception cannot leave the parent permanently unable to delegate. Two guards, not one: EnterSubagent refuses at the cap, and task is absent from the schema a subagent is sent. Rounds cap at twelve (SubagentMaxRounds) rather than the parent's twenty-four - it is doing one self-contained job and nobody is watching it spend. MaxToolRounds became the default value of a new per-agent MaxRounds property rather than a literal in the loop. The subagent's four token counters are folded into the parent's whether or not its turn succeeded: a failed subagent still spent tokens, and a /cost that quietly omits them lies in the one direction the user would mind, discovered only on the invoice.

Its OnText, OnThinking, OnToolArg and OnToolUseBegin stay nil - two agents streaming prose to one terminal interleaved is unreadable, and the user did not ask the second one anything. OnToolStart and OnNotice forward to the parent's with -> and subagent: prefixes, so there is one line per step and the wait is visibly alive. The answer arrives through the ordinary tool-result summary; pasclaude.lpr needed no per-tool arm.

Cancellation turned up two real defects rather than polish. CtrlCPressed consumes its flag as it answers, so a subagent polling ShouldCancel would swallow the user's Esc and leave the parent running; a ForceCancel latch in uAgent, tested first inside a new WantsCancel that replaced all three poll sites, fixes it, and only a top-level turn clears it, so a subagent's own Send cannot erase the abort it just caused. Second, a cancel landing inside a tool call lets RunTools finish and push its results, so the transcript ends on a tool_result user message the model will never answer

  • and TrimUnansweredQuestion rightly refuses to drop a tool_result, so the next question would be a second user turn in a row and the API would reject it. UnwindCancelledTail picks the right repair: the full unwind when the last message is a user turn, TrimUnansweredQuestion otherwise, since with a trailing assistant message the unwind loop never runs and a dangling tool_use would survive. Send also checks WantsCancel after RunTools, so a latched abort does not send one more request just to abort it mid-stream.

Named types live in .pasclaude\agents\<name>.md, the whole file being the briefing, mirroring custom slash commands exactly rather than inventing a second pattern. The parent's system prompt is deliberately not inherited: it carries the project memory and the "writes need approval" guidance, neither of which applies to a helper that cannot write and has no user to ask. The name is filtered for \ / : . and control characters, the same rule ExpandCustomCommand uses. That filter carries more weight here - agent definitions live inside the state directory SafePath exists to refuse, so this is the one place in uTools that opens a file without SafePath, the path around the name is constructed, and the filter is the whole of the guard. It must stay that way deliberately. The contents are UTF-8-validated and clipped, because they become a system prompt on a nested request where one bad byte loses the call and surfaces only as a mysterious tool failure.

There is no parallelism, and that is a decision rather than a gap. There is no threading anywhere in this codebase; TProcess, the console hooks, HttpTransport and every uTools blob are process-global, and the depth counter is a plain Integer. Concurrency would be a rewrite of the ownership model for a tool that already returns in seconds. Multiplicity is free and already works: the model can emit several task blocks in one assistant turn and RunTools runs them one after another, each Enter/Leave balanced.

Trust

Until this round, nothing in a project directory could make pasclaude run a program. CLAUDE.md was prose, .pasclaude\commands\ was prose, an agent definition was prose; the only route to CreateProcess was the bash tool, and that route ends at a prompt showing the command. Two of the four features below change that - .mcp.json names programs to spawn, hooks.json names commands to run at fixed points in a turn - so the line those files cross is worth drawing explicitly rather than leaving a reader to infer it.

The organizing distinction is declarative versus executable. Declarative text becomes prompt and nothing else: skill bodies, plugin commands, plugin agent types, CLAUDE.md. That is trusted without a prompt, because it grants a repository author no capability they did not already have through the instruction files this program has always read unprompted and labelled binding. A skill that says to run del /s produces a bash call that lands on the same prompt it always would. Executable configuration - anything that reaches CreateProcess - is asked about, every time the bytes change.

Both questions go through the same [y] once [a] always [n] no prompt the bash gate uses, and both are shown in full before they are answered: every hook command verbatim, every server's expanded command line. "Always" records a fingerprint of exactly what was approved, not a name: the FNV-1a 64 of hooks.json's bytes, and for a server the hash of its command, its arguments in order and its NAME=VALUE environment overrides sorted. Editing the hooks file asks again. Repointing an approved server name at a different program asks again, by name, showing the new program - because the name is a label the same file controls, so approving the name would approve whatever it is later made to mean. Environment values are hashed as well as names, since NODE_OPTIONS chooses the program as surely as an argument does.

Those fingerprints live in the approvals file, which is why that file moved out of the project this round. %LOCALAPPDATA%\pasclaude\approvals\ is keyed by the session root's full path; a repository that could ship its own permissions.json would have been answering its own question, pre-approving its own hooks and its own servers before a prompt appeared. When neither %LOCALAPPDATA% nor %USERPROFILE% is set there is no store, and that means approve-nothing and persist-nothing rather than a fall back into the project, which would restore the hole.

Three things deliberately do not answer these questions. /yolo answers the per-call gate and not the right to spawn: yolo has always meant "stop asking me about these tools", it is answered before the repository's config is known to exist, and extending it to launching third-party binaries a cloned repository chose is a different sentence. That survived the move to bypass mode for free - bypass is one line inside Permit, and neither the server spawn prompt nor the hooks fingerprint prompt goes through Permit. Print mode is structurally unable to reach either prompt, and still is: it reads no project config at all, and would arrive with a nil Ask and deny everything if it did - so a scripted run can never be the thing that first executes a project's code. That is now a statement about the project's files specifically. Print mode does read your own mcp.json and start its servers, which reaches no prompt either, for the opposite reason: those servers are approved without one in every mode, so there was never a question for a nil Ask to fail to answer. And enabling a plugin activates its commands, agents and skills and nothing else; a plugin manifest naming a hook or a server is reported by /plugins and never acted on.

What this does not protect against, stated plainly:

  • Habituation. A user who answers a by reflex is one keystroke from running a repository's commands. Showing every command verbatim and re-asking on every edit is a mitigation, not a fix, and it is the price of the feature existing at all.
  • Prompt injection. A server can put instructions in a tool description and a skill can put them in its body, and both reach the model. Skill results are wrapped in a header naming the skill and its source and a trailer saying the text is project-supplied and not an instruction from the user; those lines are the only signal separating repository text from the user's own voice, and they are load-bearing rather than decorative. For MCP the gate is the user having approved the program.
  • FNV-1a is a change detector, not a cryptographic digest. It is not preimage-resistant, and widening it to 64 bits doubles the work without changing that. FPC ships SHA-256 in fcl-hash rather than the RTL, which is outside this program's dependency rule. The residual attack needs someone who can already get a first server approved and then engineer a second config colliding with it.
  • .mcp.json sits at the project root, so the tools can read and write it. That is accepted rather than worked around: convention puts it there, it is meant to be committed, and config is read once at startup, so a model that rewrites it changes nothing that session and the new command line has no matching fingerprint at the next launch. hooks.json lives under .pasclaude and is therefore unreachable by the tools. The asymmetry is deliberate - hooks have no convention pushing them to the root, so they take the stricter placement for free - and should not be "fixed" for symmetry. The user's own two files, %USERPROFILE%\.pasclaude\hooks.json and %USERPROFILE%\.pasclaude\mcp.json, sit outside every root, so no clone can ship one; and .pasclaude is refused at the top level of every root including added ones, so --add-dir %USERPROFILE% does not make them reachable either. That placement, not a prompt, is what makes it safe to trust them without asking.

MCP servers

A project can name programs for pasclaude to run: an .mcp.json in the session root, in the shape the rest of the ecosystem uses.

json
{"mcpServers": {
  "github": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"],
             "env": {"GITHUB_TOKEN": "${GH_TOKEN}"}}
}}

You can name your own in %USERPROFILE%\.pasclaude\mcp.json, same shape, and those apply to every project you open. That file, and only that file, may also name a URL:

json
{"mcpServers": {
  "remote": {"url": "https://mcp.example.com/mcp",
             "headers": {"Authorization": "Bearer ${MY_TOKEN}"}}
}}

Stateless Streamable HTTP: one POST per JSON-RPC message, and the answer comes back as one application/json document or as a text/event-stream, either of which is framed into exactly the bytes the pipe path already produces. A pretty-printed JSON body is compacted first, because a newline inside an object would otherwise frame one message as several; an SSE payload split across several data: lines is rejoined per the grammar before it is parsed. The URL faces the same rule as every other URL here - https anywhere, plain http only when the host is exactly 127.0.0.1 or localhost - and it is checked when the server is opened rather than at first use, so a refusal names the URL instead of reading as a server that would not talk to us.

A project's .mcp.json may not name a URL, and this is the one place a project entry is refused where a program would merely have prompted. The prompt cannot carry the question. For a program it shows a command line, and "may this repository run this" is answerable by looking at it. For a URL the grant is that every argument the model passes to that tool - file contents, paths, whatever it read on your machine - is posted to a host the repository chose, and no prompt showing a URL asks that in a form anybody can weigh. A stdio server from a project can at least be read before it runs; a remote one cannot be read at all. Not softened to a loopback exception either: 127.0.0.1 in a cloned repository's file is a port on your machine that the repository picked, and "it is only local" is exactly the sentence that makes that sound safe.

What is refused on the transport itself, by name: a server that requires a session. The spec lets one hand back an Mcp-Session-Id on initialize and demand it afterwards; this client carries none, so such a server refuses the first real request and the error says what that most likely means rather than reporting a bare 400. Half-supporting it - accepting the id and not resending it - would produce a client that works for one call and then fails in a way nobody can read. There is also no GET listening stream (a server pushing notifications needs a reader off the caller's thread, and one thread is what this program's concurrency arguments rest on), no Last-Event-Id resumption, no WebSocket, and no deprecated two-endpoint HTTP+SSE. And a POST is atomic from here, so Ctrl+C cannot cut a request already on the wire the way it can on the pipe path; the deadline is what bounds it.

That file is read first, and its servers skip the per-command-line spawn prompt: the prompt exists because a repository you cloned chose a program, and this file is you choosing one. It does not skip the per-call permission - deciding to run a program of your own is not approving every call the model makes to it, and /yolo is still what answers that. If the project's .mcp.json names a server you already have, the project's is refused and /mcp says so by name. That is the opposite of the nearer-wins rule skills, styles, commands and agents use, on purpose: those resolve inert text and this one resolves a program to spawn, so under nearer-wins a repository could call its server github, displace yours, and inherit the model's whole session habit of calling mcp__github__* - and the fresh spawn prompt is no defence, because "may this run" is not the question that went wrong. Your servers' discovery cache is written to %LOCALAPPDATA%\pasclaude\mcp-cache.json rather than the project's, because the project cache is a file the project can write and the cache is read back as tool declarations for a server that is approved by construction.

Their stderr goes out of the project too, to %LOCALAPPDATA%\pasclaude\mcp\<name>-<session key>.err. A server of yours follows you between projects and so should what it says; before this it wrote its diagnostics into whichever repository you happened to have open, one disconnected file per project. Three things make the path unambiguous. Scope picks the root, so a project github and a user github cannot name one file. The name leads, so one listing of that directory shows every project's copy of your server together. The session key trails, because the spool is opened CREATE_ALWAYS: with one shared file the second project would truncate the first mid-write and the two children would then write over each other at independent offsets, which is worse than the placement bug it fixed. Project and not session, despite the name: the key is a pure function of the directory you opened, so two pasclaude windows on the same directory still share one spool and still collide exactly as they did when the file lived in the project. Nothing was lost there and nothing was fixed there either; adding a pid would fix it and would cost the one thing the file is for, which is being findable by somebody who went looking after the server died. %LOCALAPPDATA% and not the %USERPROFILE% the mcp.json itself sits in - that asymmetry is the layout rule below working exactly as intended, since mcp.json is configuration you hand-write and a spool is state this program writes, keyed by session. A PROJECT server's spool does not move: a program the repository chose leaves its diagnostics in the repository, where .pasclaude\mcp\<name>.err already is. And nothing creates a directory it is not about to write, so a session running only your own servers no longer leaves an empty .pasclaude\mcp\ behind in somebody else's tree.

Their tools become ordinary tools, named mcp__<server>__<tool>. stdio transport only; an entry carrying a url, or a type other than stdio, is listed in /mcp as unsupported rather than silently dropped, because a user who wrote it and saw nothing would conclude the feature is broken. ${VAR} and ${VAR:-default} are expanded before the command line is hashed, so what the fingerprint covers is what will actually run.

Startup cost is paid once. The first run after approval connects, discovers and writes .pasclaude\mcp-cache.json; every later run declares from the cache and spawns nothing until the first actual call. That buys a real divergence - we can advertise a tool the server no longer has - and it is the right trade, because the failure is one clean tool error that /mcp refresh fixes, where the alternative adds every server's boot time to every prompt. The tool list is frozen for the session even when a live server says it changed: the tools array renders ahead of the system prompt under one cache_control breakpoint, so swapping it mid-session throws the whole prompt cache away on every turn afterwards. /mcp says tools changed and leaves it.

Everything a server says is treated as data. Tool names are sanitized to the API's [A-Za-z0-9_-] and composed against its 64-character ceiling; when they will not fit the server segment is truncated, and if even that is not enough the tool is skipped. Skipping beats mangling - a name we invented cannot be referenced by the user in an approval and reads to the model as a broken machine. Schemas are checked for type, depth (16) and size (8 KB) and rejected rather than truncated, because a cut schema does not parse and one that did would be a lie the model acts on every turn. Descriptions are cut on a UTF-8 boundary. annotations, title and outputSchema are dropped, per the spec's own instruction that a client must not trust them: "the user approved running this program" is not the same statement as "believe what it says about its own side effects". Every skip is counted and shown in /mcp, because a server contributing three of forty tools while looking correct is worse than one that looks broken. The declarations share a 32 KB budget across all servers, consumed in configuration order, because that text lands in the cached prefix and a per-server cap is unbounded in aggregate.

/mcp lists every configured server with its scope, status, tool and skip counts and expanded command line. It names the exact spool file on a server that is dead or failed to start, which is when the path is the thing you want, and once at the foot of the panel it names the directory your own servers write to - a footer rather than a column because that answers the other question, the one nothing was answering: where a healthy server's log went now that it is not in the project. /mcp restart <name> drops a connection so the next call reconnects; /mcp refresh reconnects everything and rewrites the cache for the next run. Servers do not appear in /jobs, are not killable by kill_bash, and survive /clear: a running server is a capability this session has, not something the conversation said.

src\uMcp.pas is the transport and knows nothing about approval, naming or dispatch. The decision worth recording is the one about blocking. A synchronous read on an empty pipe waits until somebody writes, and an MCP server is a third-party program that can hang, crash on a bad request, or take its time. Background bash met the same hazard from the other side and answered it with a spool file, which works there precisely because nobody is waiting; here somebody always is, since a tool call cannot return until the server answers. So every wire wait goes through one function, McpAwait, whose loop is: drain the complete lines already buffered, check the buffer cap, ask the pipe how many bytes are ready without consuming them, read only that many, check the deadline. There is no path that reads without first peeking and none that waits without a deadline, which makes "a hung server can never hang pasclaude" one function's postcondition rather than a discipline spread over four call sites. A deadline that expires kills the connection rather than abandoning it: a server that ignored one request has no credibility for the next, and one left running still owns its stderr file.

Polling on the caller's thread, rather than a reader thread or overlapped I/O, is a correctness argument and not a style one. Every mutable thing above this unit is an unguarded module global, and the subagent design reasons explicitly from "the tools a subagent may call touch no module state"; a reader thread would invalidate that argument for the whole program to save a five-millisecond sleep. The write side has the same shape for the same reason - a WriteFile to a server that has stopped draining its stdin is the one wait a read deadline cannot cover, so the stdin handle is set PIPE_NOWAIT at spawn and the send loop carries its own deadline and liveness check.

Three bounds keep a misbehaving server from doing damage. A request over 256 KB is refused locally without touching the pipe. A line over 1 MB with no newline kills the connection, because a server streaming an unterminated line is indistinguishable from one that has hung. A tool result is capped at 64 KB with uJson.Utf8Cut and a note saying it was cut. Junk on stdout is discarded rather than fatal - the spec forbids it, but a client that dies on a stray line hands a broken server the power to end the session - and an unsolicited request from the server gets a -32601 reply so it is not left waiting. No shell is interposed on the command line: a shell would change the quoting the caller decided on, swallow the exit code, and sit between us and the pipes so that closing stdin no longer reaches the server.

Extending the tool list

The twelve built-in tools are still twelve straight-line literals. Anything else that wants to contribute tools registers a source: a name prefix matching ^[a-z][a-z0-9_]*__$ and two procedures, one that declares and one that runs. MCP registers mcp__. The double underscore is the whole trick - no built-in name contains one and none ever may, so a source cannot shadow read_file, and the rule does not need revisiting when a thirteenth built-in lands. Overlapping prefixes are refused at registration, so at most one source matches a name and dispatch does not depend on the order things registered in.

Sources are declared last, below the cut that ends a subagent's tool list. That cut is an Exit, so a subagent is never told about a source's tools no matter who adds the next one - and RunTool's read-only boundary refuses the call anyway, since nothing a source contributes is on the three-name allowlist. The dispatcher catches exceptions from a source's handler. That is not defensive habit: the tool loop above does not catch, so an exception escaping there would skip the tool_result the API requires and leave a transcript that cannot be sent.

Hooks

.pasclaude\hooks.json runs commands of your choosing at five points in a turn: before a tool runs, after it runs, when you submit a prompt, when the model finishes, and once when the session starts. A hook is a command line and, for the two tool events, an optional regular expression matched against the tool name.

json
{"hooks":{
  "PreToolUse":[{"matcher":"^(write_file|edit_file)$",
                 "command":"python .pasclaude\\hooks\\fmt.py","timeout_ms":5000}],
  "Stop":[{"command":"build.cmd"}]}}

A hook is handed one UTF-8 JSON object on stdin - always hook_event_name and session_root, plus tool_name/tool_input, tool_result/is_error, prompt or stop_hook_active depending on the event. It answers with its exit code: 0 proceeds, 2 blocks, and anything else means the hook failed, which is never a block. On exit 0 a JSON object on stdout is honoured - {"decision":"allow"|"deny","reason":...,"context":...} - and anything else is treated as plain context appended to the result, the prompt or the system prompt.

That "anything else is a failure" rule is the one worth explaining, because the obvious rule is wrong. A nonexistent program exits 1 through cmd.exe; under "any nonzero blocks", one mistyped hook command would silently deny every tool call in the project with no way to find out why. A timeout is treated identically, for the same reason.

What blocking means, per event: PreToolUse - the tool does not run and the hook's text comes back as the tool's error result; PostToolUse - the tool already ran, so its output is kept, the hook's objection appended and the whole thing marked as an error; UserPromptSubmit - the turn is abandoned before anything reaches the transcript; Stop - one more turn runs with the hook's text as the prompt, exactly once per user turn; SessionStart - pasclaude prints the reason and exits.

Stdin is a file, not a pipe. uTools already records why anonymous pipes were rejected for detached children; a hook makes pasclaude the writer, and with one thread there is nobody to drain. Measured: 200 KB to a child that never reads it completes at once through a file handle and deadlocks at the buffer size through a pipe. The deadlock is made impossible rather than unlikely. A tool_input over 64 KB is replaced wholesale with {"_omitted":"N bytes"} rather than truncated, because half a JSON value is not JSON and a hook that cannot parse its own stdin is worse off than one told plainly.

A block returns the same (text, is_error) pair a permission denial already returns, which is why the conversation loop needed no change at all: "blocking leaves a legal transcript" is true by construction. A hook's allow decision is read once and is checked after the nil-Ask test in Permit, so it can turn a question into a yes and can never turn a refusal into one - print mode and subagents both arrive there with a nil Ask and are unaffected. Matchers are the same bounded NFA the search tool uses, so a pattern from an untrusted file cannot hang the session; one that will not compile disables its hook with a note at startup rather than a surprise mid-tool-call. Unknown event names are named in the notes rather than ignored, so a Claude Code config pasted in tells you which half of itself pasclaude will not fire - including that product's nested {"matcher":...,"hooks":[...]} entry shape, which is detected and reported rather than supported, because two accepted shapes is two parsers and twice the surface to fuzz.

Hooks come from two files and the trust between them is deliberately asymmetric. %USERPROFILE%\.pasclaude\hooks.json is yours: it is loaded without a fingerprint and without a prompt, because prompting somebody about a file only they can write is noise that teaches them to answer yes, and every yes it trains is spent later on the project prompt that matters. The project's .pasclaude\hooks.json keeps its prompt exactly as it was. Your file loads first, so for every event your hooks fire before the project's - and since the first block wins and stops the list, that order is the rule and not a detail: a project file that fired first could pre-empt your own audit hook. A project file can never remove, disable or shadow one of yours; there is no key that removes a hook, and the eight-per-event ceiling counts what is already loaded, so a project file listing nine PreToolUse entries crowds out its own overflow. Running pasclaude in %USERPROFILE% makes the two paths one file: it is loaded once, as yours, with nothing asked about it, and /hooks names it once rather than listing it as loaded and then again as absent. Whether the two are one file is decided against the spelling Windows resolves each directory to, not against the text of the paths: comparing the typed strings meant a junction, a SUBST drive or an 8.3 short name reaching your home directory by a second name loaded that one file twice, fired every hook in it twice, and asked you to trust a file of your own - reproduced with a junction before it was fixed, not a worry about one.

/hooks shows both files, whether hooks are running, the project fingerprint and one line per hook with its scope. /hooks off turns off both for the session, which it says out loud - a switch that left some hooks running would be a lie.

Hooks are an interactive-only feature, and that is a flag rather than an accident of where a branch halts. uHooks.HooksAllowed ships false; the host sets it true only when the run is neither -p nor one of --status, --doctor, --ci prepare and --ci report. LoadHooks exits on it, so an unattended run does not even parse the file, and HooksEnabled re-reads it, so clearing it mid-run stops all six firing sites at once. The reason it had to be a flag: the trust prompt sits above the -p halt but far above RunCi, so both --ci verbs and both diagnostic modes were reaching TrustHooks - where an empty line reads as allow and a non-console stdin gives one. --ci report runs after actions/checkout, in a working directory whose .pasclaude\hooks.json came from the pull request head. The narrowing is real and is stated rather than hidden: --doctor no longer loads or fires hooks, and reports hooks.json is present but not enabled for this session instead. Note also that the CI deny floor cannot cover a hook at all - uHooks sits below uTools, so no hook command is ever matched against a rule - which is why the answer here is structural and not a rule. The user-scope file changes none of this. HooksAllowed is checked first and alone in LoadHooks, ahead of the project's trust answer, so it governs both scopes: an unattended run parses neither file. A user-scope hook is not an exemption from that byte, and making it one would turn the flag back into a condition somebody has to remember.

Skills and plugins

A skill is a directory under .pasclaude\skills\ holding a SKILL.md: a small frontmatter block naming it and describing when it applies, then a body of instructions. Only the name and the description are ever advertised; the body arrives when the model calls the skill tool by name. That is the whole idea - a project can write down a dozen procedures and pay one line per procedure per turn instead of a dozen documents. The catalogue is discovered from disk on each refresh: this project's own skills, then each enabled plugin's alphabetically, then the user's own in %USERPROFILE%\.pasclaude\skills\. Nearer wins, the rule the instruction files already use.

The catalogue lives in the skill tool's own description rather than in the system prompt. The system prompt is fixed when the agent is constructed and has no setter, so a catalogue there could never change during a session; the tool schema is rebuilt fresh on every request, and both sit inside the same single cache_control breakpoint, so the token cost is byte-identical and a skill dropped in after /skills is live on the next turn. That placement is now load-bearing for a security argument as well as a caching one - it is why the --ci gate on the instruction files does not have to cover skills, and a ux check pins it. /skills prints the catalogue and doubles as the rescan, because re-reading up to thirty-two files per request would cost more than a stale list does.

The frontmatter reader takes a stated subset and refuses everything else with a line number: a --- fence, flat key: value lines, optionally quoted scalars with no escape interpretation, # comments, blank lines. Block scalars, sequences, indentation, flow collections, anchors and aliases are errors naming the construct and the line. Unknown flat keys parse and are ignored, so a file carrying allowed-tools or license still loads. This is small on purpose: a half-implemented YAML parser mis-reads a description silently, and a description read wrong is a skill that never triggers with nothing anywhere saying why. A skill whose file fails to parse is listed by /skills with its error rather than vanishing, for the same reason.

The skill tool is not gated. It reads a file the user put in their own project, which is read_file's trust class, and what comes back is text of the same class as CLAUDE.md. It grants no capability. The header and trailer described under Trust replace the gate that would have been theatre.

A plugin is a directory under .pasclaude\plugins\ carrying a plugin.json and any of commands\, agents\, skills\. It contributes into those three namespaces through the same loaders an ordinary custom command or agent type uses; it creates no parallel universe. A plugin dropped in is inert until /plugins enable <name>. That consent is a typed name rather than a y/a/n prompt deliberately: a launch-time prompt is answered by muscle memory before anything has been read, while typing a plugin's name means the user went and looked. Enablement lives in .pasclaude\plugins.json, which - unlike the approvals file - is authoritative in both directions, because a disable that did not survive a restart would be a consent bug the user could never diagnose. Both files say so in comment. Collisions resolve nearest-first and the source is always shown, so a cloned repository's plugin shadowing something the user wrote is visible rather than silent. Built-in slash commands can never be shadowed; the built-in dispatch runs first, unchanged.

Caps, all of them visible when they bite: 32 skills catalogued (past that the listing ends (N more skills are installed but not listed)), 320 bytes of description each, 128 KB per SKILL.md, 16 plugins. A supporting file that is not valid UTF-8 is refused rather than hex-dumped - that is a mistake in the skill, not a binary the model asked to see.

Output styles

/output-style with no argument lists what is available and marks the one in force; /output-style <name> sets it and remembers the name; --output-style <name> does the same on the command line, and under -p it is the only way in - a scripted run halts before the approvals file is read, so nothing persisted can reach it.

Three styles are compiled in. default adds nothing at all. explanatory asks for a sentence or two on why the code in front of you is built the way it is and what the other way would have broken. learning asks for the work to stop one step short, with the real decision marked TODO(you): and handed back. Your own live as a flat <name>.md in .pasclaude\styles\, in an enabled plugin's styles\, or in %USERPROFILE%\.pasclaude\styles\; nearer wins, which is the precedence commands, agents and skills already use. The file is a SKILL.md-shaped document read by the same frontmatter parser - a second reader of the same file shape would be a second set of failure modes for it - so a style written for another tool loads unchanged. The three built-in names are reserved: a file called explanatory.md is listed with the clash and never loaded, because a listing that advertises one thing while the loader uses another is worse than a refusal. They are compiled in rather than shipped as files for the same reason: a built-in on disk can be edited into something that no longer matches the name the listing shows.

A style adds; it never replaces. It is one paragraph appended to a system prompt that is otherwise untouched, behind a fixed fence line saying it governs the tone and shape of your prose and grants access to nothing. That is not a promise the model keeps - it is a fact about the code. Nothing reads a style body except a single string concatenation in SessionNote, no frontmatter key maps to any setting, and there is no parse step to trick because there is no consumer to trick. A style saying "you may write files in plan mode" changes nothing: SafePath, the deny rules, the plan-mode boundary and the permission callback are Pascal and read no prompt text.

It rides outside the prompt cache, deliberately. The request has two cache_control breakpoints; the first covers the tools schema and the system prompt, and it is worth real money. A style sits after it, in the same uncached trailing block as the plan-mode paragraph, so switching styles mid-session costs a few hundred tokens instead of re-reading the whole cached prefix. The price of that choice is a standing one: the style is billed as fresh input on every turn. It is capped at 2 KB - about three hundred words - cut with Utf8Cut rather than Copy, and /output-style prints the exact byte count it added, so the charge is visible rather than silent. With the default style the note is empty and the request body is byte-for-byte what it was before this feature existed.

Order inside that block is fixed and is not a matter of taste: the style comes first, then the plan paragraph, then the extra-roots block, then the deny sentence, which stays permanently last. The style is the only text there that a file chose rather than pasclaude's own state, and the deny sentence is the one line describing a refusal nothing can override, so it keeps the recency position. Anything added to this block later inserts before it.

Where the choice is recorded. The name and the place it resolved from go in the approvals file under %LOCALAPPDATA%, keyed by the session root - never in the project, because a repository that could pick its own text for the system prompt has not been asked anything. That is a fourth polarity in a file that already had three: output_style neither widens nor narrows, it selects. The body is not stored, only the name; it is re-read from disk when you set it. If the name later resolves somewhere other than where you agreed to it - a project creates styles\explanatory.md after you set your own - pasclaude refuses it rather than substituting, falls back to default and prints both sources in yellow. Re-run /output-style <name> to consent to the new source.

Edit a style file and it applies by itself, from the next turn. The body is still cached rather than re-read per request - SessionNote runs while a request body is being built, and putting a file read, a UTF-8 repair and a frontmatter parse there would put the parse FAILURE there too, so a file caught half-written by an editor would decide the style for a request already in flight. Instead StyleNote fingerprints the file it read - the size the directory reports and FNV-1a 64 over its bytes - and re-parses only when that fingerprint moved: on the ordinary turn nothing changed, the cached paragraph goes out byte-identical, and the read that produced the fingerprint is one open of a file capped at 64 KB. Over the bytes inside that cap, precisely: the hash sees the same first 64 KB the parser does, so a body can never depend on a byte the fingerprint missed, and a change past the cap that also moves the length is caught by the size sitting beside the hash. Raising the read cap without raising the hash cap would open a real blind spot, which is why the source says so where the two meet. The read is on the request path and the parse is not, which is the half of the caching that was ever load-bearing. A built-in style has no file and never touches the disk at all.

The check used to compare the DOS timestamp FindFirst reports beside the size, and two-second granularity made an edit that landed inside one tick and left the file exactly the same length invisible. A real FILETIME through GetFileAttributesExW was the obvious replacement and was rejected: NTFS stores 100-nanosecond ticks but nothing writes 100-nanosecond values into them - Windows stamps a write from the cached system time, whose tick is about 15 milliseconds, so two saves inside one tick share a last-write time to the digit. That shrinks the blind window and makes what is left flaky rather than documented. The bytes are the only thing that cannot be identical while the content differs.

One residual, deliberate. A file that has become unreadable, unparseable or deleted does NOT empty the style: what was working stays in force, and a yellow line before the next prompt says which file and why, once, rather than once a turn.

Adding to the system prompt

--append-system-prompt "<text>" puts your own paragraph into the system prompt for this run. It ADDS and can never replace: the guidelines, the session root, this project's CLAUDE.md and everything they @import are all still assembled above it, exactly as they were. It goes LAST, after the project's own instructions, and that order is the argument for the flag - everything above it was written by whoever last committed to the tree, and a standing instruction typed at the keyboard should not lose the recency position to a file out of a clone. The model is told where it came from: the paragraph is introduced as Additional instructions, given on this run's command line:.

Give the flag more than once and the values accumulate in the order they were given, a blank line apart - "append" read literally, because the alternative silently discards text you typed. The total is capped at 4096 bytes and the run STOPS past that rather than sending half of it: a standing rule the model received part of is worse than one it never received. The banner names the byte count, because text in the most trusted position of every request should not be invisible.

There is deliberately no settings.json key and no approvals-file key for this. A project file that could append to the system prompt is a project file that can rewrite the agent's standing instructions from inside a clone - the same risk .mcp.json and hooks.json are gated for, except that those two announce themselves and can be trusted per tree, where a system-prompt line would be invisible in every reply it shaped. Command line only means the person who typed it was at the keyboard. Something long belongs in CLAUDE.md, where /memory can show it back to you.

Images

@shot.png in a prompt attaches the image; /paste takes one off the Windows clipboard, and /paste drop cancels it before you send. Both print what you are about to spend - dimensions, bytes, and the token cost - because an image is expensive and, unlike prose, you cannot skim it back later in the transcript. The figure is the API's own patch formula, ceil(w/28) x ceil(h/28) after the model's downscale, so a 1080p screenshot reports 2691 tokens and a 4K one 4784.

The clipboard needed an encoder. Windows offers CF_DIB - raw pixels - and the API takes png, jpeg, gif and webp but not BMP. FPC's paszlib is a package, not the RTL, so it is off limits here; the way through is that a zlib stream may legally be made of stored deflate blocks, which need only CRC-32 and Adler-32. That was verified with a compiled probe before any of it was designed around: Windows Imaging Component decoded the result and Python's zlib inflated it with every chunk CRC validating. The price is that such a PNG is about the size of its raw pixels, which is why uImage writes a palette when an image has 256 colours or fewer - a terminal or dialog screenshot almost always does, and that is the difference between a paste fitting in the 2 MB budget and not. Past that it halves and retries, at most twice, then refuses and names the size. A quarter-scale screenshot is still readable; a sixteenth is not, and an honest refusal beats an image you cannot check.

Files you mention are never re-encoded - they are already compressed properly - so they go up untouched under a 5 MB cap, and a JPEG cannot be resized without a decoder anyway, which is what makes an oversize file a refusal rather than a shrink. Eight images per message: the API allows more, but past twenty blocks it imposes a stricter per-image dimension rule, and eight keeps a turn clear of it. uImage is a leaf beside uJson - SysUtils only, no Win32, no console, no JSON - so the awkward cases (a bottom-up DIB, a truncated header, a header claiming 40000x40000) are testable without a clipboard.

A copied file goes through ResolveInRoot, which is SafePath, the same resolver @-mentions, read_file and @import use. /paste of a file outside the session root, inside .pasclaude\, or under a deny rule is refused by the same message it would get as a tool argument; copying the image rather than the file is unaffected, and --add-dir widens both alike.

What a message holds now

A message content array can carry an image block, and one shape covers every part of the program:

json
{"type":"image","source":{"type":"base64","media_type":"image/png","data":"..."}}

Base64 sources only. A url source would have the API fetch a host nobody here can vet, and the Files API needs a beta header and an upload round trip. Images appear only in user messages, only where your own @mention, /paste, or a stream-json driver's own message put them; a tool_result may not carry one, and RunTool still returns a string. That is a prohibition rather than a scoping choice - three units would have to move in lockstep, and a tool result has no human in the loop to see what arrived.

TBlockKind gained nothing and the streaming decoder was not touched. The model does not emit image blocks; if the API ever does, the existing bkResult passthrough already captures it whole and replays it verbatim, which is the correct behaviour and must not be "improved" into a typed kind.

Two local keys, width and height, live beside the block in FMessages so a resumed session can print [image 1920x1080 image/png] without decoding a megabyte of base64. They are stripped from the copy that goes on the wire, beside the existing cache_control fixup, because the Messages API validates content blocks strictly and rejects a key it does not know. The transcript keeps our concerns and the transport keeps the API's.

ValidTranscript was not modified and SessionVersion is still 1: its only type-sensitive rule is that a tool_use needs a matching tool_result, and an image block satisfies everything else by being an object with a non-empty type. So image sessions round-trip today, and nobody may add a block-type allowlist - it would make already-saved sessions unloadable, which under -p is a hard exit 2.

What images cost you over time. Base64 is re-sent in full on every turn, so one screenshot puts the transcript permanently over the compaction threshold, and TranscriptBytes counts it honestly because that is the real cost of the next request. The byte trim cannot help by itself, because the image sits in the tail it is trying to keep. So the trigger block first calls EvictImages(2): it walks the transcript oldest-first and replaces all but the two newest image blocks with [image removed to save context: 1920x1080 image/png]. It substitutes rather than deletes - an emptied content array is exactly what the session loader rejects, and a measure meant to save context must not leave you with a session that will not load. Two survive because that is the image you are discussing plus one for a before/after pair. Eviction rewrites memory only; a file already saved keeps the bytes until it is overwritten.

An image can also be consumed by a turn that never happened. AppendUserText drains the pending queue into the message before the request goes out, and both tail repairs - the failed-turn unwind and the unanswered-question trim - drop that message. RequeueImagesFrom reads the base64 blocks back onto the queue at both sites and says so in a notice; silence was the real damage, since you had already been told the image goes with your next message.

What an image can do that text cannot. It can carry instructions painted into its pixels that a person reading the transcript will never see - the transcript shows only [image 1920x1080 image/png]. Nothing here detects that and nothing can. What bounds it is the same instinct as the permission system: an image gets in only by your own @mention or /paste, both root-guarded by the check a tool call goes through, and it lands in a user block, so it carries the authority of text you typed and no more. It cannot answer a permission prompt: those are read from the real keyboard, and the model has no channel to it. Note also that a saved session keeps the image on disk in plaintext under .pasclaude\.

Not done: read_file on an image still returns its hex dump - a tool result has no human in the loop, and uNotebook and uMcp already refuse to sail base64 into context unexamined - tool results cannot carry images at all, and only base64 sources are used. Print mode's plain -p never expands @mentions, so an image cannot be attached that way; a --input-format stream-json driver can send image blocks in its own user message, under the same caps and the same four media types.

The screen

Three pieces of chrome, all drawn by uTerm and all amber.

The banner is a rounded two-column frame. On the left is what this session is - who it greets, the model and how it authenticates, the session root and any added ones. On the right is what to type. The split is not decoration: the left column is the half a user checks and the right is the half they learn once and stop reading, so side by side means the half that goes stale never pushes the half that stays useful off the screen.

What the frame deliberately does not hold is the warnings. A permission mode that is not plain ask, a deny set in force, a sandbox below the default - those go underneath it, unboxed. A box is exactly the shape an eye learns to skip, and those are the three lines that must not be skipped. Under 64 columns the frame is dropped for a single stacked column: two columns in forty columns is two columns of ellipses.

The prompt block is a rule, the text, a rule, and the status lines under it. The text word-wraps and the block grows downward, which means every repaint paints rows below the caret and then walks back up to it - so the block exists only where VT escapes do. A console that refuses VT gets the single-line editor this program has always had, and so does every prompt that is not the REPL: the permission question, the model picker, the session picker and the rewind picker all read through ReadLineEdit, which never frames. A question wrapped in a status bar is a question that gets skimmed.

Each repaint is assembled and written once. Six rows painted run by run is thirty console calls per keystroke, which a user experiences as a prompt that stutters while they type.

The status line says, in order: the model and where you are, the meters, what was loaded, and the permission mode. Every field has a "say nothing" value, and a field that says nothing takes no column - a session with no git, no MCP servers and no memory file gets a shorter line, not a line of zeroes. The mode is last because it is the line a narrow window must keep, and it is omitted entirely for plain ask, on the same reasoning the prompt has always followed: an indicator that is always on is not an indicator.

pascal
????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
??? what shall we build
????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
[claude-opus-5] ??? pasclaude git:(feat/config-diagnostics)
Context ???????????????????????????????????? 12% (18.4k) ??? Session 121.5k in 8.4k out
1 CLAUDE.md ??? 3 MCPs ??? 16 hooks
?????? bypass  (/mode to change)

The context meter fills towards the point this program compacts at, not the model's context window: compaction is what you will actually experience, so it is the number worth watching. A non-zero percentage always lights at least one cell - a meter that reads empty at 1% is a meter that does not appear to work.

The branch comes out of .git\HEAD, not out of git. A subprocess per turn would be affordable; the status is refreshed once per turn and never per keystroke, but reading a file is the same answer for none of the cost, and it handles the three shapes .git comes in (a directory, a worktree's pointer file, and a detached HEAD, which is shown as the short commit).

The palette. Four amber tones - clAmberLt, clAmber, clAmberDim, clAmberDk - go out as 24-bit escapes on a VT console. A sixteen-colour palette has exactly one amber, so on a legacy console the two bright ends collapse onto intense yellow and the two dark ones onto the dim pair that reads as structure rather than text. The banner loses its shading and keeps its shape.

How the rows are built. A row is one string with zero-width colour marks in it (#1 plus a letter), not a run of coloured writes. That is what makes a row measurable: UiWidth counts columns rather than bytes - ─ is three bytes and one column, and a frame padded by Length() is a frame with a ragged edge - UiFit truncates to a width while copying the marks through, and UiPaint colours it. Dropping the marks on a cut would leak the last colour onto everything painted after it, which is how one long path turns the rest of a screen amber. #1 is safe as the sentinel because nothing a user types or a model returns contains it.

uTerm sits at the bottom of the unit ladder and cannot ask uAgent for a token count or uTools for the permission mode, so the status facts are pushed into it as a plain record before each read rather than pulled out of it. That inversion is what keeps the console unit free of the program's state, and it is why the composer is a pure function of a record that the suite drives without a console: StatusLines at every width from 12 to 140, UiBoxTop, UiBoxRow and UiBoxBottom asserted to be the same number of columns for both glyph sets and for content far too long for its column, and PromptRows asserted to lose no character and to leave the caret where the eye is.

Editing the prompt

The line editor has always had arrows, Home/End and Ctrl+A/E/U. It now also has the readline verbs it was missing - Ctrl+W deletes the word to the left, Ctrl+K to the end of the line, Alt+B and Alt+F move by words, Ctrl+Z undoes - and both halves of those are rebindable.

/vim turns on a modal editor. A line always starts in insert mode, so forgetting the setting is on costs nothing; Esc (or Ctrl+[) enters normal mode and the prompt says which one you are in, [I] or [N], in front of whatever the permission mode already puts there. In the framed block described under The screen the permission mode has moved to the status line, so the tag is all that stands in front of the mark - but it is still there, and in both its states. Both tags are four columns wide, so the text does not shift sideways when you press Esc. Normal mode has h l w b e 0 ^ $ for motion, j and k for history - a prompt is one line, so those are worth more as history than as motion - i a I A to insert, x D C S and the d and c compounds (dw db de d0 d$ dd, and the same with c) to edit, and u and Ctrl+R for undo and redo. A whole insert session undoes in one step rather than one character at a time.

What vim mode is not: no visual mode, no registers and no put, no counts, no . repeat, no marks, no macros, no : commands, no search, no text objects, no r/R/s, no o/O, no gg/G, no %. Most of those exist to move around a buffer, and there is no buffer here. Two things do get worse when you turn it on, and /vim on says so: Esc stops clearing the line (Ctrl+U still does), and a paste that arrives while you are in normal mode is read as commands. Esc-Esc for /rewind is off with vim on for the same reason - two presses mean "definitely normal mode" to fingers older than this program. /vim save keeps the setting; /keys prints the current table.

Rebinding. %USERPROFILE%\.pasclaude\keys.json:

json
{"vim": true, "bindings": {"ctrl+w": "delete-word-left",
                           "alt+d": "delete-word-right",
                           "ctrl+k": "none"}}

An action is one of a closed list of editor verbs, none unbinds, and anything the file gets wrong is printed at startup naming the entry - an unknown action or an unparsable key is never silently dropped, and a refused entry leaves the built-in binding standing. A missing or broken file is the built-in bindings and a working session.

Why that file is not in your project directory. Everything else under .pasclaude\ describes the project and arrives with a git clone. Keybindings decide what your keyboard does, and a repository that could ship them would be choosing that for you before you had read a line. So keys.json is read from %USERPROFILE% only, the same place the user-level CLAUDE.md lives - this is configuration you write by hand, not state the program keeps, which is what %LOCALAPPDATA% is for.

The key-dispatch boundary

The approval question is read with the keyboard, so "configuration can reach the line editor and nothing else" has to be a property of the code rather than a convention. It is written down here because it is the invariant a maintainer is most likely to break without noticing: every one of the changes that would break it looks like tidying.

Three separate things stop a rebound key answering a permission prompt, and any one of them would be enough.

The shape. ReadLineCore(Prompt, P: TKeyProfile, Line) is implementation-only and takes the profile as a required parameter; there is no module-var read inside it. Two interface wrappers supply one: ReadPromptLine passes PromptProfile, and ReadLineEdit passes the constant KeysNone. ReadLineEdit kept its exact old signature, so the default action for any prompt added later - calling the function that already exists - is the safe one. The permission prompt, /model, /sessions and /rewind all go through it, and under KeysNone Vim is false, so normal mode cannot exist there and a is always the character a.

The grammar. KeyChordOf cannot construct a chord for an unmodified character. A bindable chord is [ctrl+][alt+][shift+]base where base is a named non-character key, or a letter or digit that must carry ctrl or alt. y, a and n have no spelling at all. enter, tab, escape, ctrl+c, ctrl+enter and alt+enter do parse and are then refused by name as reserved.

The action set. Bindings map onto TEditKey and nothing else. No action submits a line, cancels, answers a question, emits text or runs anything - KeyActionName(ekChar) returns '', so the one verb that produces text cannot even be named in the file. The worst a fully hostile keys.json achieves is annoying editing, recoverable with Ctrl+U.

Matching is on virtual-key code plus modifier flags, never on the synthesised control character, which dissolves the "Ctrl arrives two ways" problem: Ctrl+W is VK $57 with LEFT_CTRL_PRESSED whatever UnicodeChar holds. The literal control-character chain stays as a fixed fallback inside ReadLineCore, outside the binding table.

The audit is one grep, and it is written in uTerm's interface beside the declarations:

pascal
grep -n "ReadPromptLine\|PromptProfile\|KeysNone" src/*.pas src/*.lpr

ReadPromptLine - one declaration, one body, one call, the REPL. PromptProfile - one declaration, one body, one setter, and exactly one read in a reader, ReadPromptLine's argument expression. KeysNone - one declaration, one body, one use in ReadLineEdit. A second ReadPromptLine call site, or a read of PromptProfile inside ReadLineCore, is the defect.

None of this reaches the model. There is no change to the request body, to the system prompt, or to either cache breakpoint: the body a conversation produces is the same string with vim on or off, and a smoke test asserts it. Telling the model you pressed dw would cost tokens on every turn to say something it cannot act on.

Driving pasclaude from another program

-p answers a single prompt, and two flags change what that answer looks like on the wire. --output-format json prints one JSON object and nothing else: the finished result of the turn. --output-format stream-json prints one JSON object per line as the turn happens - the tools it called, the text as it streamed, and finally the result. --input-format stream-json turns stdin into the other half of the conversation, so a driver can ask a second question without starting a second process, and can answer the permission questions the user would otherwise have answered.

Both flags need -p, and --input-format stream-json needs --output-format stream-json, because a driver that can speak has to be able to listen. -p takes the next argument as its prompt only when that argument does not begin with -, so the flags may go on either side of it.

The message types: system (subtype init, emitted once, carrying the session id, cwd, any additional working directories, model, permission mode and the full inventory of tools, agents, commands, MCP servers and skills), user (the prompt echoed back), assistant_delta (delta.type is text or thinking), tool_use (id, name, and the input as a parsed object), tool_result (tool_use_id, name, content, is_error), notice, hook, permission_request, error, and result. Exactly one result per turn is the contract and the process never exits without one, so a driver knows the run is over when it sees the result line and then stdout closes.

hook is {"type":"hook","event":"PreToolUse","tool_name":"write_file", "detail":"...","blocked":true} and is emitted once per fire that actually ran a child - never for the empty fire that happens around every tool call when no matcher matched, or a driver counting hooks would be counting tools. blocked is the field it exists for: a hook that ran and let the call through and a hook that refused it both end up in the same tool_result, one as content and one as an error, so a driver that wanted to log refusals had to infer which from a string. The detail is the hook's own output, repaired to UTF-8 and cut to 16 KB with [hook detail truncated] when the hooks of one event together printed more than that; control bytes are escaped by the encoder, so a hook that prints ten lines still occupies exactly one line of the protocol. Only stream-json gets it - json is one object for the whole run, and the text REPL already shows a hook's words in the tool result and its failures as notices, so it gains nothing.

The honest caveat: pasclaude's own -p never fires a hook, because hooks are interactive-only and every SDK format needs -p, --status or --doctor (see Hooks below). The seam is armed under stream-json and there is nothing in the shipped CLI to report through it; a program that embeds uSdk, drives SdkRun in process and sets uHooks.HooksAllowed itself is the caller that sees the event today.

The permission exchange is the part worth an example. pasclaude emits

json
{"type":"permission_request","id":"perm_1","tool_name":"write_file",
 "title":"write_file","detail":"...diff..."}

and blocks on exactly one line of stdin. The driver replies

json
{"type":"permission_response","id":"perm_1","behavior":"allow"}

where behavior is allow, allow_always or deny. Everything else denies: an unknown verb, a mismatched id, a line that is not JSON, a message that is not a permission_response, and end of stream. That is the same rule the console prompt follows when its own read fails, and there is no default-allow branch anywhere in the parser.

A driver is a permission answerer and nothing more. The protocol has no message that sets a mode, adds a working directory, edits a deny rule or changes the sandbox level; the working directories and the mode name appear in the init event because a driver that cannot see them cannot explain a refusal to whoever reads its log. Whether the channel is armed at all is a separate decision from what is reported - AskViaDriver, set only in the two modes where a question can arise, since plan refuses before the gate and bypass answers itself. That used to be one string compared against 'ask', which was a behaviour switch keyed on a display name and became a latent bug the moment the vocabulary grew past two words.

A run without --input-format stream-json can read files, list directories and search, and nothing else. Every write, edit and shell command is refused in band, as an ordinary tool_result with is_error true naming the refusal - print mode's existing deny-by-default rule, not a new one. An SDK run never reads or writes the approvals file and never writes session.json or history.txt, so a driver's throwaway question neither inherits a human's standing approvals nor manufactures new ones. It never loads hooks - that is the one byte HooksAllowed, which is set only on the interactive path, and it is why the hook event above has nothing to carry under -p - and never spawns an MCP server, for the same structural reason: it halts before that startup work runs.

Exit codes: 0 success, 1 the turn failed, 2 a startup or usage error. A startup failure in json or stream-json mode is emitted as a single {"type":"error","error":"..."} line rather than a sentence, so a driver's parser is never handed prose.

python
p = subprocess.Popen([exe, "-p", "", "--output-format", "stream-json",
                      "--input-format", "stream-json"],
                     stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
p.stdin.write(json.dumps({"type": "user",
                          "message": {"role": "user", "content": "hello"}}) + "\n")
p.stdin.flush()
for line in p.stdout:
    msg = json.loads(line)
    if msg["type"] == "result":
        break

Scripted sessions

-p answers one question and exits, and by default it leaves no trace: the directory's saved conversation is not read, not written and not backed up. A throwaway question should not disturb a conversation somebody is in the middle of, and that has been true since print mode existed.

A multi-turn agent built out of repeated subprocess calls needs the opposite, so it asks for it by name:

pascal
pasclaude -p "start on the parser" --session-file work\agent.json
pasclaude -p "now the error cases" --session-file work\agent.json --resume

The file is the ordinary session file - same format, same version, same validation - so one written by a script opens in the REPL with /resume, and a conversation started at the keyboard can be continued by a script. There is no second serialisation, because two serialisations of the same data are how two serialisations drift apart.

The path is checked with the same guard the tools use: it lives in the session root, or in a directory named with --add-dir. --session-file without -p is a startup error - interactive sessions already have /resume and the session picker, and a second way to name one file is a second thing to keep consistent. --resume under -p without --session-file is a startup error too, and that one is the point of the design: guessing the directory's session file is exactly what -p has always declined to do. --continue under -p is refused outright, with no --session-file escape, because it is the stronger version of the same problem - --resume at least names one fixed file, where --continue takes whichever transcript happens to have been written last.

On turn one there is no file yet, and that is not an error - the driver passes the same arguments every time and the first call simply starts fresh, tested with FileExists rather than by sniffing the loader's reason string. A file that IS there and cannot be read stops the run with exit 2. That differs from interactive --resume, which prints its reason and carries on, and the difference is deliberate: a person reads the warning and decides, a script does not. A run that quietly got a blank conversation would do work on absent context and then save over the file it could not read.

The save happens before the result line, not after. That ordering is a contract: when a driver reads result, the transcript is already on disk, so it can spawn the next process immediately without polling for the file. If the save fails it gets an error line and the process exits 1 - but the result line still says the turn succeeded, with the answer in it, because the work was done and only the writing down failed.

Every system/init line now carries resumed, resumed_messages and session_file, present on every run whether or not anything was resumed. A driver that has to branch on a missing key is a driver that gets it wrong on the run where nothing happened, which is most of them. TSdkOptions is now built only through SdkDefaultOptions, at every construction site including the suites: FPC initialises only the managed fields of a local record, so a Boolean added to that record inherits stack garbage, and Resume reading garbage is a run that resumes a file nobody asked it to.

What comes back is the conversation and nothing else. Permission mode, added roots, standing approvals, the sandbox level, MCP approvals, hook trust and the output style are all re-established from the command line on every run, because none of them is in the file - LoadSession writes the messages, the model and the counters, and there is no path from a session file to any of pasclaude's permission state at all. A resumed run can never come back more permissive than a fresh one.

Two caveats worth knowing before building a loop on this. Nothing is compacted on the scripted path: CompactWithSummary would spend an API request the driver never asked for, and the byte trigger is the interactive host's policy rather than part of the protocol, so a session that outgrows the context window is answered with a new file. And two processes given the same --session-file race - each write is atomic, so a reader never sees half a session, but the loser's turn is lost with no warning.

The same code is reachable from Pascal. src\uSdk.pas is console-free and examples\embed.lpr is about sixty lines using only SysUtils and uSdk - set the root, construct, ask, free - and build.cmd builds it, so it cannot rot into needing uTerm. One session per process, because RootDir, the AllowAll flags, the job table, the bash prefixes, the snapshots and the ignore rules are all module-global in uTools; a subprocess is the isolation boundary. Constructing a session clears the working-directory set before setting the root - they are a grant made to a session, not a property of the machine - and loads the user's deny rules and nothing else out of the approvals store. That is the one class of rule it is safe to hand an embedder unasked, because it is the one class that cannot grant anything: Ask stays nil, so the session inherits the user's refusals and none of their approvals.

That unit is also where the system prompt now lives. It was lifted out of pasclaude.lpr verbatim, which is what makes it testable for the first time - specifically that user memory loads before the project's own instruction files, so nearer still wins. The facade ships with SdkProjectContextAllowed false, and examples\embed.lpr sets it: letting the tree you point a session at write part of the system prompt is a deliberate act, like assigning Ask, and an embedder that clones a stranger's repository into the working directory leaves the line out and gets the guidelines only. --no-project-context is that same decision made from the command line, for the caller who is running the shipped binary against a tree rather than embedding the unit.

Pull requests: /review and /pr-comments

pascal
/review                  :: git diff HEAD - the working tree
/review --staged         :: git diff --cached - the index
/review <ref>            :: git diff <ref>...HEAD - what this branch adds
/pr-comments [<n>]       :: one pull request's comments, printed then sent
/pr-comments <n> --show  :: printed, and nothing sent

/review is local. It runs git, sends the diff as an ordinary user message and touches no network and no token at all, so any edit the model then proposes shows its own diff and asks like every other. The stat is printed first, so you see the size and the file list before a turn is spent on it. /review 123 is refused by name: fetching a pull request's diff would pull an arbitrarily large, arbitrarily hostile change written by whoever opened it into the context of an agent holding thirteen tools, for a result gh pr checkout 123 and then /review main already gives from code you chose to check out.

A <ref> is user text entering a cmd.exe line that answers to nobody - there is no permission gate, no deny check and no sandbox on a slash command you typed - so it is charset-validated before anything is composed: letters, digits, . _ - /, no leading -, no .., 128 bytes. The validator has its own test, and it is one requirement with the resolver below rather than two ideas: both, or neither ships.

/pr-comments fetches the inline review comments, the review bodies and the conversation thread of one pull request in four GETs, or more when a list runs to more than one page, renders them to the console, and sends the model exactly the lines it printed. With no number it infers the pull request from the current branch and asks for one when that is not exactly one match. --show prints and sends nothing. Consent is per invocation and per pull request: there is no github tool, so the model can never fetch one itself, and there is no polling.

Read-only is a property of the client rather than of the caller. uGitHub issues GET and nothing else - there is no POST, PUT, PATCH or DELETE anywhere in the unit - so no comment, review, approval, merge, label or branch can be produced by any amount of injected text, in any mode, by any answer to any prompt. A read-only client cannot be talked into an action, which is a stronger statement than any amount of prompt hygiene and is why the hygiene is the second line rather than the first. The consequences are stated rather than worked around: no replying, no resolving, no approving.

Pagination exists now. uHttp returns the Link response header - one header by name, verbatim and unparsed, dropped whole rather than cut if it is over 4 KB, because a truncated URL is a URL that is nearly right and nearly right is the worst thing an untrusted URL can be.

rel="next" is read as a signal, never as an address. It answers "is there another page"; where that page lives is decided here, by putting &page=N on the URL pasclaude has been requesting all along. No URL a server chose is ever requested, so a cross-host link, a credential-bearing redirect and a poisoned path have nothing to act on - the worst a lying rel="next" buys is one extra request to our own URL, which comes back empty and ends the loop. A configurable API host is the one knob a credential must not have, and a Link header that gets followed is that knob, operated from the wire with no settings file involved.

That replaced a stricter-looking rule that turned out to be the bug. Links used to be vetted before being followed - same scheme, same host, same path, query only - and GitHub answers /repos/OWNER/REPO/... with a next link under /repositories/<id>/...: the same resource, canonically spelled, and a moved path by that rule's own reading. So every page past the first, on every list, was refused. It went unnoticed because the refusal was unreachable until the WINHTTP_QUERY_CUSTOM fix made Link readable at all - the first time this program could see a next link was also the first time it turned one down. The lesson is in the shape rather than the constant: a rule strict enough to be worth having is strict enough to refuse the legitimate case, and the way out was not to loosen it but to stop needing it.

At most three pages of 100 and 300 items a list - the page cap bounds requests carrying the token, the item cap bounds memory, and the two multiply to each other on purpose so neither is a number that could never bite. A list stopped by either cap is still reported rather than silently truncated, and the notice now names our cap, which is a different sentence from the one it used to have to write. The old sentence is still there for the one case that needs it: a list whose last page came back exactly full with no rel="next" in it is reported as possibly incomplete, because a Link that was dropped - over the 4 KB cap, or through a header query that failed - looks from here exactly like a list that ended, and handing you 100 of 250 comments with no sign anything was missing is the failure this notice has always existed to prevent. A short last page cannot be that case and says nothing at all. The honest cost: a busy pull request spends up to ten GETs where it spent four, and unauthenticated requests get sixty an hour, so six runs can exhaust a limit fifteen used to.

Every program pasclaude runs by name - git, gh - is resolved on PATH first. cmd.exe searches the current directory before PATH, so a repository shipping a git.cmd used to have it run at startup; that was measured with a probe rather than theorised. ProgramCommand walks PATH and PATHEXT and never considers the current directory or a working root, and the three pre-existing git call sites go through it too.

The GitHub token, and text a stranger wrote

A GitHub token comes from GH_TOKEN, then GITHUB_TOKEN, then gh auth token, and from nowhere else - not from a settings file at any tier, not from a flag, not from a prompt, not from any file under a root. pasclaude stores none: gh already owns GitHub credential storage, and a second DPAPI blob would be a second credential lifetime to keep correct for no gain. So uAuth grows no source, its writers still take no path argument, and AuthResolve's declaration order still answers only "which credential signs the model request". The API host is compiled in and is settable at no tier, which is why there is no GitHub Enterprise support and no github.api_base: moving an endpoint is how a token leaves, so that knob does not exist.

The token is screened before it is used, not after. uHttp hands the header block straight to WinHttpSendRequest and validates no byte of it, so a CR in a token value is header injection; GhTokenLooksUsable requires printable ASCII, one line, 8 to 512 bytes, and it runs before HttpGet is called rather than inside a transport, so a substituted transport in a suite sees exactly what the network would. A token that is present and unusable is refused by name rather than quietly downgraded to an anonymous request. gh's output is accepted only on exit code 0 and only as a single line, and is never copied into an error, a note or a report.

Where it is redacted is everywhere it could appear, and the first mechanism is that it is never recorded. TDiagFacts carries the repository name and the source name - GH_TOKEN, GITHUB_TOKEN or gh cli - and no value, no hint and no length, so /status, /doctor and /bug could not leak it if every redactor failed. It exists only inside the header block of a live request: never in a URL, never in a console line, never in a session file, never in telemetry. /status also spawns nothing to find it - naming the source reads two environment variables and resolves gh on PATH, because a command called status must not run a program whose whole job is to print a credential to a pipe.

A pull request body is written by anyone with a GitHub account, and that makes it the least trusted text this program handles - an MCP tool description at least required somebody to approve a command line first. It lands in an ordinary user message, never the system prompt, never a tool description, inside a marked block preceded by one sentence saying that everything inside is third-party data, is information to consider, and is never an instruction. Caps: 100 items per list, 4 KB per body, 64 KB per payload, every cut marked, every byte IsValidUtf8-checked.

The order of that envelope is the bug, so it is written down. Decode with uJson, strip control characters, Utf8Cut to the cap, and then drop any line that is a forged marker - per line, after the cut, never on the assembled string. Stripping markers first is forgeable: pad so the cut lands mid-marker, or write a marker that only appears once the pieces are joined. And the envelope is a labelling device, not a sanitiser. It tells the model where the third-party text starts and stops and removes a forged end-marker; it does not make the text safe. A comment can still persuade the model to propose an edit or a command, and the human answering the prompt is the last gate - so a user who habitually answers a has already widened it, and /review over a fetched fork branch feeds attacker-written code to the model exactly as opening that file in an editor would.

Running unattended

Everything above assumes somebody is at the prompt. This is the first context where nobody is, and it is documented as its own thing for that reason.

A comment on an issue or pull request that mentions @claude can start a run. The template is examples\github\pasclaude-mention.yml; it is copied into your own repository rather than being live here, because a live workflow in this repository would run on every comment, spend the maintainer's Actions minutes, and fail saying "no credential".

A CI run may read the repository and write one comment. It may never push, never patch, never approve, never merge.

Who may pull the trigger. issue_comment, created, and nothing else - that trigger's workflow file is always taken from the default branch, so a pull request cannot edit the workflow that reviews it. The check runs twice: a cheap if: in YAML, so a stranger's comment never starts a runner at all, and again in Pascal inside --ci prepare, so an edited if: does not silently widen anything. Both read author_association, which GitHub computes from the commenter's relationship to the repository; it is not a field the commenter fills in, which is the only reason it is trustworthy input while the comment beside it is not. Anything below COLLABORATOR is refused, an association nobody has invented yet reads as the lowest value rather than the widest, and --ci-allow member|owner narrows further. There is no flag that widens.

Fork pull requests are refused, not unimplemented. gh pr view --json isCrossRepository,headRefOid,state is parsed as if hostile: anything that is not the boolean false reads as a fork, and a pull-request comment with no --ci-pr file is refused too, so deleting that step fails closed rather than open. The argument for allowing forks is that with bash denied nothing from the fork is executed, only read - but that rests on the job having no build step, and one added npm ci line destroys it silently.

The token, key by key. contents: read for the checkout, issues: write to post the comment (the issue-comments endpoint serves pull requests too, and this is the narrower of the two keys that satisfy it), pull-requests: read for gh pr view. Naming any key sets every unnamed one to none, so with exactly those three an injected instruction could read this repository, read pull-request metadata and post comments here - and could not push to a branch, create a branch or a tag, approve or submit a review, merge, edit a workflow, publish a release, or reach another repository. It holds no token in any case: GH_TOKEN is set on exactly the two fixed gh steps, and the step that runs the model has ANTHROPIC_API_KEY and no GitHub credential. persist-credentials: false keeps one out of .git\config where read_file could find it.

Why bypass mode is not the CI recipe. --dangerously-skip-permissions appears nowhere in the template and the ux suite fails if it ever does. Under -p there is nobody to ask, so Ask is nil and a gated tool returns an ordinary error result while the turn continues - plan mode over a deny floor is not a degraded agent, it is one that reads and answers. The floor is written out of tree, to %LOCALAPPDATA%\pasclaude\deny.json, so nothing in the checkout can supply or edit it: tool:bash, tool:bash_output, tool:kill_bash, tool:fetch, tool:web_search, tool:write_file, tool:edit_file, tool:notebook_edit, tool:task, path:**/.git/**, path:**/.env*, path:**/*.pem. Nothing overrides a deny rule - not a mode, not a persisted "always", not a hook's allow, not a file in the tree - which is exactly the property worth having where nobody is watching. --ci prepare runs after the rules load and exits 2 naming every missing one. The one thing that floor cannot cover is a hook, because uHooks sits below uTools and no hook command is ever matched against a rule; hooks are therefore switched off structurally instead, as Hooks above describes.

The project's own instruction files are switched off the same way, and for a reason the deny floor also cannot reach. uSdk.SdkProjectContextAllowed ships false; the host sets it true unless the mode is one of the two --ci verbs or --no-project-context was given, and SdkProjectContext re-reads it where the files are opened rather than at the call site, so clearing that byte stops every caller at once - including TSdkSession, which builds a prompt of its own. It matters for the same reason hooks did: --ci report runs after actions/checkout, so AGENTS.md, CLAUDE.md, .pasclaude.md and everything they @import came from the pull request head and went into the system prompt, which is the most trusted position in the request. A path: deny rule is matched against the model's tool calls, and this loader is not one. Be exact about what that buys, because the flattering reading is wrong: neither --ci verb runs a turn, so what the flag stops there is the read and the assembly rather than a delivery to a model, and a --ci run that skipped files says so in a line that also says it asked no model anything - the answer in that build log came from the -p step above it, which loads them unless it too passes --no-project-context, and in the shipped template it does. The user-level memory in %USERPROFILE%\.pasclaude\CLAUDE.md still loads: the question the flag asks is which tree wrote the prompt, and the template builds the agent from a clone in RUNNER_TEMP before checking the head out, so nothing in a pull request can write that path.

The step that actually runs the model is an ordinary -p in the checked-out head, and it passes --no-project-context. A flag on the command line rather than a mode inferred from the prompt, and that distinction is the whole of it: the prompt is the attacker-supplied text, so anything keyed on its contents is a behaviour the attacker chooses, and the fail-open case would be a comment crafted so the marker is not recognised. The flag clears the same byte, so SdkProjectContext needs no second rule; the two terms meet in uSdk.SdkProjectContextDecide, an and of two negatives, so neither can widen the other and there is deliberately no argument that turns the loader back on. -p without the flag loads a project's files exactly as the REPL does, which is what Scripted sessions promises, and ux asserts both directions over real files on disk. What is left of a branch's contribution to that request is its skill catalogue, which is in the skill tool's own description and not in the prompt.

The two modes. --ci prepare --ci-in <event> --ci-out <prompt> [--ci-pr <pr.json>] decides whether to answer, writes the prompt file when it will, writes a fixed refusal comment to <prompt>.md when it will not, and appends proceed/code/reason/number/head_sha to %GITHUB_OUTPUT%. --ci report --ci-in <result.json> --ci-out <comment.md> turns one line of --output-format json into the comment and appends a run summary to %GITHUB_STEP_SUMMARY%. Exit codes are the existing three: 0 for any decision reached, 2 for a usage error, an unreadable or oversized input, an unwritable output, or a deny floor that is not in force.

Comment text never passes through YAML expansion or a command line - pasclaude opens GITHUB_EVENT_PATH itself, which is exactly how the classic Actions injection is avoided - and nothing untrusted reaches GITHUB_OUTPUT, GITHUB_ENV or a run: line. Every value written there is from a fixed vocabulary or validated, and head_sha must be exactly 40 hex characters or it is not emitted, because it chooses the commit actions/checkout writes into the workspace.

How the YAML is checked. build.cmd and test.cmd cannot run YAML and RTL-only forbids adding a parser, so ux reads the template as text - absent is a failure, not a skip - and asserts every deny rule verbatim, persist-credentials: false, the three permission keys and no fourth : write line, fewer than 120 lines, and the absence of the _target trigger, comment-body and title interpolation, and the bypass flag. It is a grep, and it is honest about being one: everything semantically load-bearing lives in src/uCi.pas where the suites can drive it.

Cost. windows-latest, Chocolatey's freepascal, and no release pipeline, so the workflow clones and runs build.cmd: about four minutes wall, eight billed on a private repository, free on a public one.

Settings, and the scope table

pascal
%USERPROFILE%\.pasclaude\settings.json        user
<root>\.pasclaude\settings.json               project
<root>\.pasclaude\settings.local.json         local

Three files, one flat JSON object each, read once at startup. Nearer wins per key: local, then project, then user, then the compiled default - the rule ScanSkillDir already uses for skills, styles, commands and agents. Keys are literal flat strings; model.alias is a key whose name contains a dot, not an object to walk into, so this loader has no traversal of an unknown object at all. The user file is in %USERPROFILE% rather than %LOCALAPPDATA% for the reason keys.json is: %LOCALAPPDATA% is where the program keeps state it writes, and this is hand-authored configuration a person has to be able to find.

The charter is one sentence: settings.json carries display and economy keys only; authority stays where it is. This is the fifth round in a row that principle has been defended - approvals out of the repository, deny rules from %LOCALAPPDATA% only, keys.json user-scope only, --add-dir from argv only - and it now has a name and a table instead of an argument repeated per feature.

key who may set it range default
output_style any tier a style name the built-in
thinking_budget any tier 0, or 1024..32768; project ..8192 0
tool_result_bytes any tier 4096..131072; project ..65536 30720
auto_compact_tokens any tier 20000..180000; project ..150000 150000
model user file only a model id, alias or profile claude-sonnet-4-5
model.alias user file only name -> id four built-ins
model.route.subagent, model.route.compaction user file only a model name sonnet
telemetry.enabled, .endpoint, .headers, .interval_turns, .timeout_ms, .service_name user file only off
ide.enabled user file only let /ide open files and diffs in a detected editor on
ide.command user file only absolute path to the editor's command-line program none

Sixteen keys, four of which a project may set. The three economy numbers are narrow-only from a project or local file, measured against the value you have in force - your own settings file if it names the key, the compiled default if it does not. A project may lower thinking_budget and tool_result_bytes and never raise either, and because the compiled thinking budget is 0, a repository cannot turn extended thinking on at all. auto_compact_tokens runs the other way: each compaction is an extra billed request, so a project may push the point later and never earlier. The comparison is against your position rather than against the key's own maximum, which is the fix for the version that let a clone raise a compiled 0 to 8192 while the table's comment said it could not. A value past the ceiling is refused, not clamped - a clamp teaches the repository that the key half-works and teaches you nothing. A thinking_budget between 1 and 1023 is refused at every tier, because the API's floor is 1024 and it rejects the request, so the number would fail every turn in that checkout.

Both ide keys are user scope for the reason the whole table exists. ide.command names a program a slash command starts, so a cloned repository able to set it would be a cloned repository able to run anything on your machine - the same shape of hole as a project-settable telemetry endpoint, and it gets the same answer. It also has to be absolute, has to end in .cmd, .bat or .exe, has to exist, and is only consulted once an editor has already been detected. None of those four is sufficient on its own, and the shape check is re-applied by SettingsWrite, so /config set cannot write what startup would refuse. It is also the only way a JetBrains user gets a launch, because nothing a JetBrains terminal exports names idea64.exe.

model is user scope because a project that picks the model does not merely spend your money on its own say-so: it can name the weakest available id to review its own code, and nothing in the output would say so. There is deliberately no fingerprint-and-prompt path of the kind hooks.json has. Hook trust is a one-time question about a bounded set of commands you can read in full; model choice is unbounded and recurring.

settings.local.json carries project authority, not user authority. .gitignore is a convention rather than a guard - a repository can simply commit one - so treating the filename as a security property would hand a clone back everything three rounds of work took away from it. Local is nearer than project in precedence and identical to it in authority; the escape hatch from a project value is /config set --local, not an inverted hierarchy.

The one mechanism. TierAllowed(Def, Tier) is a function in src/uSettings.pas with exactly one call site: SettingsStore, which is the only procedure in the unit that writes the value array. A value at a tier its key does not permit is never stored - not stored and later overruled, and there is no second if anywhere for a refactor to drop. Two greps are the review: SettingValues must be written only inside SettingsStore, and TierAllowed must have one caller. Every reader funnels through one SettingLookup, so the reader side is trivially safe: it can only see what the writer admitted. uSettings uses uJson, SysUtils and Classes and nothing else, and SettingsParseTier takes bytes, never a path - the uTerm.KeysParse rule - so the unit can never learn a configuration location and every suite drives it with no filesystem.

Thirty-two more names are in the same table as refusals, honoured at no tier including the user one: permissions, allow_edits, allow_bash, allow_fetch, bash_programs, trusted, deny, sandbox, permission_mode, append_system_prompt, add_dir, additionalDirectories, env, apiKey, apiKeyHelper, auth, credential, login, mcpServers, plugins, vim, bindings, hooks, bare telemetry, bare ide, github, report_dir, redact, doctor, bug, statusLine, outputStyle. They are there on purpose. The realistic failure is not an attack, it is somebody pasting Claude Code's settings.json and believing it took effect, so each one produces a sentence naming the file that really owns it - vim and bindings point at keys.json, hooks at .pasclaude\hooks.json, deny at deny.json, permissions at the approvals file under %LOCALAPPDATA%, bare ide at the two dotted keys, and github at GH_TOKEN, GITHUB_TOKEN and the gh CLI, since no file names the token and none names the host. append_system_prompt points at the command-line flag of that name and is refused at the user tier too, which is stricter than output_style beside it: a style adds a paragraph about prose whose body has one reader, where this would add arbitrary standing instructions to the most trusted position in every request. report_dir and redact are pre-refused so that adding them later is a review conflict rather than a one-line insertion. The last two are the two shapes of silent failure this list exists to catch, and both were missing until an audit went looking. statusLine names a program in Claude Code, so it is ide.command's hole without the slash command in front of it - a clone able to set it would run something of its choosing every time the screen repainted - and here the status line is compiled in and runs nothing at all. outputStyle is the near miss on output_style, which any tier may set, and a near miss is where silence costs most: the user can see the working key in somebody else's file and has no way to tell why theirs does nothing. SettingIndex compares names with = and nothing lowercases an incoming key, so the two spellings are genuinely two entries and neither shadows the other. The counts above are asserted in ux now rather than only written down - the sentence introducing the honoured keys in uSettings had said "fourteen" since before the two ide keys were added, which broke nothing and stopped being true.

A file with any problem in it contributes nothing - bad JSON, not an object, an unknown key, a wrong type, an out-of-range integer, a key at a tier it may not use - and every problem is named in yellow at startup beside the bad deny rules. Partial application is how a typo silently changes half a configuration. It never halts: a project file is attacker-controlled, and a startup refusal would be denial of service via config. Those yellow lines are suppressed under --output-format json and stream-json, where stdout carries the protocol and nothing else, and every one of them is in the ledger /doctor prints regardless. Control characters are stripped and every string is cut to 240 bytes on the way out: a key named ESC[2J in a cloned repository would otherwise erase the security warnings printed above it, and a TAB inside a value would shift the fields of the tab-separated /config row and let the file choose the tier word /status blames.

The load sits immediately after the session root is known and above the print-mode halt, so a -p run honours it. That is legal only because nothing in the table can grant - not a permission, not a root, not a sandbox level, not a mode, not a tool. The authority split is what buys the load position. If a future key can grant, it does not belong in settings.json; if it somehow must, the load splits and the granting half moves below the halt beside LoadPermissions.

/config prints the three absolute paths, then key, effective value and tier, with an overruled: line under anything shadowed. Absolute paths rather than tier words because SafePath refuses everything under .pasclaude in every root: the model's own read_file cannot look at a settings file even when you ask it to, so /config is the whole debugging surface. /config get <k> shows the chain, /config set [--local] <k> <v> writes the user file or settings.local.json, /config unset removes and /config reload re-reads. /config set never writes <root>\.pasclaude\settings.json - pasclaude committing configuration into somebody's repository on their behalf is not a convenience. The writer is read-modify-write over the parsed document, so every other key survives including the refused ones, and an unparseable target file refuses the write rather than being replaced; that is the direct answer to SavePermissions, which rewrites wholesale and destroys any key its loader does not understand.

Only three of the four keys re-apply on /config reload. The system prompt is frozen at TAgent.Create for prompt-cache reasons, so a reloaded output_style needs /output-style and a reloaded model needs /model. keys.json and plugins.json were deliberately not folded in: one is %USERPROFILE%-only by design, and the other is program-written in both directions, where a hand-authored file would recreate SavePermissions' silent-loss hazard in a new place.

Model aliases and routing

opus, sonnet and haiku are short names for the current family ids, and opusplan is a profile rather than an id: opus while plan mode is on, sonnet the rest of the time. Every built-in target is dateless - claude-opus-4-5, not a snapshot - because a dated id in a table this program ships is the retired-default 404 that has already happened here once. The server resolves a family name to whatever is current; a table of snapshots would have to be re-shipped to stay true. The live list is still the authority: a bare /model fetches GET /v1/models, numbers it exactly as before, then prints the aliases below with continued numbering, marking in yellow any whose target the key's own list does not mention. An alias name may not contain a dash or begin with claude, so no real model id can ever be captured by one, and any built-in target can be overridden from the user settings file without a rebuild - which is what makes a stale table an annoyance rather than a breakage.

Two roles run somewhere other than the model you picked: the read-only subagent, which has three tools and nobody watching it spend, and the compaction summary, which is mechanical work on text the model already wrote. Both default to sonnet. On the shipped default model that is a no-op - every request carries exactly the string it carried before routing existed - so the feature costs nothing until you deliberately choose a stronger main model, and when you do the banner says which models the routed roles are on. Your own turns are never routed.

The profile is resolved per request rather than when you type /model opusplan, so /mode plan and /mode ask switch the model mid-session with nothing to keep in step, and Agent.Model keeps the literal word so /resume restores the profile and not a snapshot of whichever half was live at save time. The cost is real and worth stating: changing model mid-session invalidates the cache breakpoint on the system block and re-bills the prefix.

When a turn fails with HTTP 404 and its id came from an alias, the API's own not_found_error message gains a clause naming the alias, the id it produced and /model. There is deliberately no startup preflight against /v1/models: it would add a round trip to every start including -p, and it could only ever answer "this key's list mentions it", which is not the same question for a dateless name.

Precedence, highest first: /model typed this session, then the model a resumed session restores, then ANTHROPIC_MODEL, then user-scope model in settings.json, then the compiled default. /cost keeps its existing lines and adds a by model: block only when more than one model was actually used - tokens from different models are not comparable, and the block is the only thing that says so. Still no prices, which is the same reason the SDK omits total_cost_usd. The SDK's init line keeps model as the literal session model, which may now be an alias or a profile, and gains model_resolved, what the next main request would actually carry; the result line gains an additive models array.

Credentials

pasclaude authenticates with whatever this machine already has, in one order it will tell you on request:

pascal
1  ANTHROPIC_API_KEY
2  ANTHROPIC_AUTH_TOKEN
3  a stored preference, if it names a source that is actually live
4  pasclaude's own store   %LOCALAPPDATA%\pasclaude\credential.json
5  Claude Code             %USERPROFILE%\.claude\.credentials.json
6  Jcode                   %USERPROFILE%\.jcode\auth.json
7  the ant CLI profile     %APPDATA%\Anthropic\credentials\<profile>.json

The two environment variables always win, because exporting a variable is a deliberate act for one invocation and reading another program's token is not. A preference can only choose among sources this machine already has; it can

[...]