Skip to content

gitguard: anchor linked-worktree baselines on core.worktree/core.bare, not the shared config inode (vogt WI-1670) - #1

Merged
thedancingdeveloper merged 363 commits into
mainfrom
fix/gitguard-linked-worktree
Oct 16, 2026
Merged

thedancingdeveloper merged 363 commits into
mainfrom
fix/gitguard-linked-worktree

Conversation

@thedancingdeveloper

Copy link
Copy Markdown
Owner

When one session ran git checkout -b or git worktree add, the shared config was rewritten and its inode changed. That reset every sibling worktree's baseline, so the guard then refused each session's own edits as pre-existing dirt. The baseline is now anchored on core.worktree/core.bare, and the git dir's own config is recorded. The review found no blockers (vogt WI-1670).

🤖 Generated with Claude Code

thedancingdeveloper and others added 30 commits September 29, 2026 10:37
Three commands took a job reference and each handled a missing or wrong
one differently. With no argument and more than one job running, /logs
listed the jobs with its usage, while /restart and /stopjob printed a
bare usage line. A name matching nothing gave "no job X" everywhere,
never saying what the jobs were called.

resolveJobRef is now shared: no argument means the sole running job,
otherwise the jobs are listed with the command's usage; an unknown name
is answered with every job's name and state. The issue also suspected
/stopjob said "no job" for an exited job. Reading JobStore.Kill showed
the opposite: lookup finds exited jobs and Kill is a no-op on them, so
the command reported "stopped build" for something already dead. It now
says the job already exited, with its code, and points at /restart.

Measured: three new tests (ambiguous argument lists jobs for all three
commands and acts on none; unknown name lists names and states; stopping
an exited job reports it) fail on main and pass here. go vet,
CGO_ENABLED=0 go build and go test ./... pass apart from the #110 skill
test that reads the real HOME.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Three small defects in how typed input is handled.

An ambiguous @path Tab printed "candidates: …" with appendLine, which in
an inline renderer means permanently, into the terminal's scrollback;
every completion left a line behind. The matches are now rendered in
the live region under the prompt (the current one bracketed), and only
while the completion cycle is live - the moment the input differs from
what Tab put there, the line goes.

completeAtPath took LastIndex("@"), so Tab inside "mail bob@exa"
completed the email as a path. The @ must now start a word: at the
start of the input or after whitespace.

A follow-up queued during a turn and sent when the turn ends was echoed
and sent but never passed to noteNav, so /outline and /search --mine
skipped it; a normal submit does record it. It is now indexed the same
way, with the expanded text as the body.

Measured: new tests for candidates staying out of the transcript but
appearing in bottomView and disappearing once the input changes, @
inside a word not completing while "@x" after a space still does,
atTokenStart cases, and a queued follow-up appearing in m.nav after
doneMsg and becoming the next prompt. go vet, CGO_ENABLED=0 go build
and go test ./... pass except the #110 skill test that reads the real
HOME.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
…out was

The Bash tool description said nothing about the shell's lifetime. Each
call is a fresh `bash -c` in the project directory, so `cd sub` in one
call and `make` in the next runs make in the wrong place, and exported
variables vanish the same way. The description now says so and suggests
&& or absolute paths.

It also never mentioned run_in_background, though the torture test
showed the job system goes unused unless the model is steered to it at
the moment it decides how to run something. The description now names
it, with BashOutput and KillShell, for commands that do not finish on
their own.

A timeout was reported as "[command timed out, exit code N]" without the
limit, leaving the model to guess whether to raise it. timeoutNote now
states the limit and, below the 10-minute maximum, that a larger timeout
is allowed; at the maximum it points only at run_in_background or
smaller steps. It is appended in Execute, where the effective timeout is
known, so formatBashOutput's signature and callers are unchanged.

Measured: tests for the description's content and for the note at the
default and maximum limits. go vet, CGO_ENABLED=0 go build and go test
./... pass apart from the #110 skill test. Assumed: that the added
description text changes model behaviour as intended - not measured
against a live model.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Grep could show matching lines but nothing around them, so every hit
the model wanted to understand cost a follow-up Read. search.Grep now
takes Before/After and returns context lines marked as such; overlapping
windows emit each line once, and a line that is both context and a match
is a match. The tool exposes them as -A/-B/-C, the names Claude Code and
grep use, and formats context with '-' and "--" group separators.

type=<name> filters by extension for about twenty common languages, so
the model need not write "*.{ts,tsx,mts,cts}"; an unknown name is
refused with the list. head_limit caps the lines shown and says how many
there were, so a capped result can't pass for a complete one.

Multiline mode returned one {Line: 0, Text: ""} entry per matching file,
which content mode printed as "file:" - no match, no location. It now
returns each match with the line it starts on and the matched text.

Grep and Glob passed a relative path straight to the walker, so it was
taken from the process directory rather than the working directory the
other file tools use. Both now go through resolvePath. Today WorkingDir
equals the process cwd, so this was latent rather than visible.

Measured: tool-level tests for context output (including overlap and
the separator), type filtering and the unknown-type error, head_limit,
multiline content, and relative path resolution for Grep and Glob.
go vet, CGO_ENABLED=0 go build and go test ./... pass apart from the
#110 skill test.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Memory was project-scoped only, so facts about the user (not a project)
had to be re-taught in every checkout. Add a user-level memory file read
from $KLAUDIA_CONFIG_DIR/MEMORY.md (default ~/.klaudia/MEMORY.md, the same
base session.ConfigRoot() already uses for user config, jobs and sessions),
and inject it into the system prompt alongside project memory.

The recall section now labels the two blocks — "User memory" (global) then
"Project memory" (this project) — with the project block last so its notes
refine or override the global ones, matching how CLAUDE.md layering already
puts the closest file last. The file is optional: absent = no-op, verified
by test.

Measured: build stays CGO_ENABLED=0-clean; the prompt and memory suites
pass, including new tests for user-memory present, absent (no-op), and the
combined-with-labels case, all driven off KLAUDIA_CONFIG_DIR/t.TempDir()
rather than the real $HOME (per issue #110's caution).

Assumed: this is read-only injection. The Memory tool still writes only to
project memory (.klaudia/MEMORY.md); a user-scoped write path would need a
scope argument on the tool surface and is left as a follow-up.

Closes #161

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Too many tools rode into every request eagerly, spending input tokens
(and cached-prefix room) on capabilities most turns never use. The
deferral machinery already existed for MCP tools — withheld from the
request and revealed on demand via ToolSearch — but the local Browser
and Task tools were always loaded.

Defer the four Browser* tools (BrowserSearch, BrowserFetch,
BrowserNavigate, BrowserSnapshot) and the four Task* tools (TaskCreate,
TaskList, TaskGet, TaskUpdate). Measured: this removes 8 tools from the
standing tool list every turn carries. The Browser tools' own
descriptions already instruct the model to prefer the server-side
web_search/web_fetch on Claude models, so on the common path they were
fallbacks the request paid for unconditionally.

TodoWrite vs Task*: TodoWrite stays eager and Task* is deferred. The
reasoning (assumed, not measured): TodoWrite is the planning checklist
nearly every non-trivial turn reaches for, so paying a ToolSearch
round-trip to reach it would be a net loss. Task* is a separate,
four-tool session task store that most sessions never touch — a lot of
standing surface for a rarely-used capability.

The policy lives in tools.DefaultDeferredTools(); the CLI merges it into
the deferred set (guarded by registry presence) alongside the mcp__
tools. Because these tools are always registered, ToolSearch is now
always registered too, so a session with no MCP servers also trims its
toolset (previously ToolSearch appeared only when MCP tools were
present). The tools stay registered and work the instant ToolSearch
reveals them; the edit/read/bash/search core is untouched.

Tests: tools.TestDefaultDeferredTools asserts the set, that every
deferred tool is still registered, and that the core stays eager;
agent.TestBuildToolParamsDefersBrowserAndTaskTools verifies the real
default registry withholds Browser*/Task* while keeping the core and
ToolSearch, and that a Browser tool is offered once revealed.

Closes #144

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Bring skill frontmatter closer to Claude Code parity and let a skill
reference the files bundled beside it.

Fully wired (measured by tests):
- Positional argument substitution $1, $2, ... $N in the skill body,
  whitespace-split from the invocation arguments, empty when absent, and
  multi-digit indices ($10) handled correctly. $ARGUMENTS keeps its
  meaning; a body that references neither still gets non-empty arguments
  appended, preserving the previous behaviour.
- Base directory: $KLAUDIA_SKILL_DIR (and ${KLAUDIA_SKILL_DIR}) expands
  to the skill's directory in the body, and a one-line preamble names it
  in the rendered result so the model can locate bundled files. The name
  follows the repo's KLAUDIA convention (issue greenthread-ai#83's CLAUDE_SKILL_DIR is
  not on this line, so no existing name to match).
- allowed-tools frontmatter key parsed as the Claude Code spelling, with
  the older tools key kept as an alias (allowed-tools wins).
- argument-hint parsed and surfaced in the Skill tool's description, so
  the model sees the expected argument shape.

Stored and documented, not yet enforced (assumed sufficient for parity
without a behavioural hook that does not exist yet):
- allowed-tools does not restrict the tool set: a skill injects
  instructions into the current turn rather than opening a scoped
  sub-session, so there is no per-skill tool gate to attach it to.
- model is a per-skill hint with no invocation-time model switch; the
  Skill tool result is consumed in the caller's model.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
…lines

The language-server client and its tools covered only diagnostics,
definition, and references. This extends both with the remaining
navigation capabilities the issue asks for and enriches the results.

New client operations, each with a Pool wrapper and a tool:
- Hover (textDocument/hover): flattens the three shapes `contents` can
  take (MarkupContent, a MarkedString, or an array of them) to plain text.
- DocumentSymbols (textDocument/documentSymbol): normalises both server
  reply forms — the hierarchical DocumentSymbol[] (recursing into
  children, using selectionRange as the name span) and the flat
  SymbolInformation[] — into one ordered list.
- Rename (textDocument/rename): returns the WorkspaceEdit grouped by file
  as a PREVIEW and does not apply it. Applying was a deliberate choice:
  the repo already has an Edit tool and the issue says to prefer
  returning edits, so Rename stays read-only and the model applies what
  it wants. Both the `changes` map and `documentChanges` array forms are
  decoded.
- Implementation (textDocument/implementation): reuses the existing
  locations() path.

Definition/References/Implementation/DocumentSymbols/Hover results now
include the source line's text beside each file:line:col, read from disk
via a SourceLine helper (issue #157 asks for this explicitly).

Capability handling is measured, not assumed: Initialize now captures the
server's advertised capabilities from the initialize result, and each
request checks the relevant *Provider flag, returning a new
ErrNotSupported sentinel when absent. The tool layer renders that as
"not supported" rather than an error, so a server missing a capability
neither errors confusingly nor hangs. The existing per-request timeout is
reused unchanged (issue #109).

Tests are hermetic — no real language server. They cover the parsers
(hover shapes, hierarchical vs flat symbols, both workspace-edit forms),
capEnabled, SymbolKindName, SourceLine (including out-of-range and plain
paths), and the tool-level formatting (source-line inclusion, rename
preview with the "not applied" notice). The live gopls test is unchanged.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Klaudia works in the foreground for minutes at a stretch, and the two
moments it hands control back — a turn finishing and a permission or
approval prompt appearing — are exactly when a user who has looked away
needs a nudge. Emit a terminal notification at those moments: the bell
(\a), an OSC 9 desktop notification (iTerm2/kitty/WezTerm) and/or an
OSC 777 one (rxvt/urxvt and others), configured by a new `[tui] notify`
key. The list form ("bell,osc9", "all", "off") is parsed once at startup
via ParseNotify, mirroring the existing EnterInserts helper, so an
unknown token warns rather than silently meaning nothing; unset defaults
to "bell", the most portable mechanism and the least intrusive.

The escapes go out through View like the OSC 52 clipboard copy already
does (a new pendingNotify field kept separate from pendingOSC so a /copy
and a notification in the same frame don't clobber each other), which
keeps them ordered with the frame instead of racing the renderer on a
separate file descriptor.

Focus gating is measured, not assumed: the program now enables focus
reporting (tea.WithReportFocus — one of the few DECSETs that leaves
inline scrollback and click-drag selection intact, unlike alt-screen or
mouse capture), and the notifier stays quiet only while the window is
known-focused. A terminal that never reports focus leaves focusKnown
false and is notified regardless, so the feature can't silently do
nothing on a terminal without focus support.

The notify path fires on doneMsg only when the turn actually returns to
idle — not on a goal-loop iteration or a queued follow-up, which don't
wait on the user. OSC bodies are sanitized (BEL/ESC/control bytes
stripped) so a tool name in a prompt can't terminate the sequence early
or smuggle a second escape.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Add a hooks subsystem modeled on Claude Code. A new [hooks] config section
maps each event to matcher groups of shell commands; the new internal/hooks
package runs the matching commands with a JSON payload on stdin and reads their
exit code and stdout to steer the loop. The agent loop calls the runner at four
points: before a prompt is added (UserPromptSubmit), before and after each tool
dispatch (PreToolUse/PostToolUse), and when it is about to return a tool-less
answer (Stop). A nil runner disables the feature, so every existing caller is
unaffected.

Design decisions:

- Protocol matches Claude Code: exit 0 with {"decision":"block","reason":...}
  blocks; exit 2 blocks with stderr as the reason; any other non-zero is a
  non-blocking error that is logged and ignored. UserPromptSubmit additional
  context comes from JSON (flat or nested hookSpecificOutput) or, as a
  convenience, plain stdout.
- PreToolUse blocks are tagged like host-gate refusals (errResultTagged with
  hostBlocked=true) so a repeatedly-blocking hook does not feed the same-shape
  streak breaker and latch the tool for the rest of the run.
- A Stop block re-injects its reason as a user message and re-enters the loop,
  bounded by stopBlockLimit (8) so an always-blocking hook cannot loop forever;
  a user halt takes precedence over any Stop hook.
- Per-hook timeout (default 60s) runs the command in its own process group and
  kills the group on deadline. Measured: `sh -c "sleep 5"` leaves the child
  holding the stdout/stderr pipes after the shell is killed, so killing only the
  shell let cmd.Run() block until the grandchild exited — the group kill plus a
  WaitDelay backstop is what actually enforces the deadline (covered by a test).

Security: hooks run arbitrary commands, so project-level (.klaudia/config.toml)
hooks are dropped at load time — the same escalation risk the repo gates for
project-supplied executable config. Only user-level (~/.klaudia) hooks are
honored. This is the conservative choice; a project-trust prompt could relax it
later. Documented in CHANGELOG.

Assumed (not measured): the exact stdout schema beyond decision/reason/
additionalContext; unrecognized JSON fields are ignored rather than erroring, to
stay forward-compatible with Claude Code's evolving hook output.

Tests: internal/hooks covers matching, stdin payload shape, decision parsing,
exit-code semantics and timeout; internal/agent covers a PreToolUse block
preventing execution and a Stop block re-entering the loop once; internal/config
covers TOML parsing and project-hook dropping.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Three related MCP enhancements from #159.

tools/list_changed (shipped): the Manager installs a per-session
ToolListChangedHandler on every server it connects, reconnects, or
reloads, dispatching to a host-registered callback. In the CLI that
callback rebuilds both tool registries from the live list, sharing and
serialising against the existing config-reload rebuild. A tool that
appears or disappears mid-session is reflected without a restart. Verified
by an in-memory server that adds a tool post-connect and a test asserting
the handler fires and Tools() then sees both.

Prompts as slash commands (shipped): Manager.Prompts/GetPrompt wrap
prompts/list and prompts/get; the CLI exposes each as a
/mcp__<server>__<prompt> command whose Render fetches and submits the
rendered prompt text. Argument parsing takes key=value tokens and routes
bare text to the first required argument. Measured against an in-memory
prompt server (list, templated get, arg parsing, mixed-role rendering).
Deferred: re-registering prompts added by a later config reload as slash
commands (the prompt stays reachable through the MCP layer meanwhile).

Remote OAuth (client_credentials shipped, authorization_code foundation):
a new oauth block on a server config, secrets named by env var. The
client_credentials grant is end-to-end — token minted and refreshed via
golang.org/x/oauth2/clientcredentials, verified against an httptest token
endpoint asserting the bearer reaches the resource request. The
authorization_code grant ships token storage (0600, per server) and
refresh (persistingTokenSource re-saves on change, tested); the
interactive browser acquisition is assumed to be follow-up, so a server
with no stored token connects unauthenticated and receives the server 401.
Config is validated per server so one bad block does not abort startup.

go mod tidy promoted golang.org/x/oauth2 to a direct dependency.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
After a successful Edit or Write to a source file, fetch the language
server's diagnostics for that file and append a compact "New
diagnostics:" section (path:line:col severity message, capped) to the
tool result, so the model immediately sees errors it just introduced —
as Claude Code does — without a separate Diagnostics call.

Seam: an optional Diagnostics hook on tools.Context
(tools.DiagnosticsFunc, backed by *lsp.Pool.Diagnostics), added to
agent.Options and passed straight through in the tools.Context literal;
the CLI wires the pool's method in at each run site. This keeps the
feature in the tools layer and out of internal/agent/loop.go's dispatch
logic — the loop only forwards the hook, it makes no decision with it —
so it will not collide with other in-flight dispatch work. This was a
deliberate choice: the alternative of branching in loop.go after the
Edit/Write results would have edited the dispatch path directly.

Measured: the existing LSP pool already applies its own timeouts
(diagnosticsWait) and returns a clear error for unsupported, disabled,
or missing servers, so the hook needs no timeout of its own — a nil
hook, an error, or an empty result all append nothing. Assumed (not
verified against a live server here): that gopls et al. push
diagnostics for the just-written on-disk file within that window; the
Diagnostics tool already relies on the same behaviour. Matching the
#109 fix intent, silence is never reported as a false "clean" — only a
non-empty diagnostic set produces text.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
dispatch ran tool.ValidateInput(raw) only after opts.Host.Check and the
permission/approval step, so a tool_use with malformed arguments — a call
that could never run whatever the gate or the user decided — still made the
host gate classify it and made the user approve it before it was rejected as
invalid. Move validation to run immediately after the tool is looked up and
before the host gate and permission checks, so an obviously-invalid call is
refused at once without bothering either.

The reorder does not touch the failure-counter or loop-breaker behaviour. The
validation branch still returns the same errResult(msg) it returned when it
ran late — measured: the failure counter bumps identically (new
TestInvalidInputStillCountsAsAFailure), and the accepted-field list from
schemaFieldList is preserved verbatim. Loop-breakers A and B are unaffected
because they run earlier still (before Lookup), so their relative position to
validation is unchanged. What moved is only that validation now precedes the
host gate and the approver rather than following them — verified by
TestInvalidInputSkipsGateAndApproval, which fails on the old ordering (both
the gate's Observed hook and the approver fire) and passes on the new one.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
/rewind [N] removes the last N exchanges (default 1) from the in-memory
conversation so recent turns can be backed out without /clear wiping
everything.

The load-bearing decision is the exchange boundary. An exchange begins at a
user prompt and runs up to the message before the next prompt: the prompt plus
the assistant turn(s) and tool results it triggered. Rewind cuts the history at
a user-prompt boundary and keeps the prefix untouched, so every dropped
exchange is removed whole. This is what makes it safe: measured against the
message shapes the loop actually stores (assistant tool_use answered by a
tool_result in the immediately following user-role message), a user-prompt
boundary is by construction a user message that carries no tool_result, so
cutting there can never strand a tool_use without its result or leave an orphan
tool_result — the exact corruption sanitizeMessages otherwise has to repair. A
user-role message carrying a tool_result is therefore never treated as a
boundary (it belongs to the preceding assistant turn); only a user message with
none is.

Persistence is truncation, not a marker: in-memory messages map 1:1 onto
transcript message entries (the recorder writes one entry per message, resume
rebuilds one message per entry), so Transcript.DropLastMessages truncates the
JSONL file in place by the same message count dropped from memory. In-place
os.Truncate keeps the inode, so an open append handle stays valid and the
recorder's parent-chain pointer is re-seated on the last surviving entry. A
later --resume then sees the same conversation as the screen.

Guards, as specified: N beyond the exchanges present rewinds all of them and
says so; N<=0 or non-numeric is a no-op with an error. The screenshot golden
regenerated because /help gained a line.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Add two run-and-exit subcommands so the environment and configuration can be
inspected from scripts and CI without starting a session.

`klaudia doctor [--json]` runs the same checks the interactive /doctor renders.
The doctor package gains a Report type ({checks, ok}) with json tags on Check,
and a Critical(checks) predicate. "Critical" is defined narrowly and measured
against one condition: the auth check warning, i.e. no usable credential
resolved — the only state in which Klaudia cannot reach a model at all. Every
other warning (a missing OS-sandbox binary, an unknown context window, no
language servers) still leaves a working session, so those stay advisory. The
command exits non-zero only when Critical is true, making `klaudia doctor` a
usable preflight gate.

`klaudia config show [--origin]` prints the effective merged config as
key = value lines; --origin annotates each with default/home/project/env.

Origin-tracking approach: config.Load merges the two files into one Config and
forgets which layer set what, which is exactly what --origin needs. Rather than
change Load (assumed risky — it is on the startup path for every run), a
parallel config.Origins pass reads the same two files via the existing
readProject, keeps the home and project layers separate, and replays the merge
precedence per field: project wins over home wins over a built-in default;
accumulating fields (permission rules, LSP disabled, write roots) report the
highest layer that contributed. This was verified against the real merge in a
layering test (project overrides home -> origin=project; home-only ->
origin=home; unset-with-default -> origin=default).

Secret redaction: the resolved API key is never placed in output. The apiKey
line reports only that a key is set and its source — inline from a file, or
from the named apiKeyEnv variable, which is the one place an env origin arises.
A test writes a known secret via both apiKey and apiKeyEnv and asserts it
appears in no rendered value; the env case is what exercises origin=env.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Four CLI flags matching Claude Code semantics, all optional and backward
compatible.

System prompt composition (prompt.Compose): --system-prompt replaces the
built default entirely; --append-system-prompt appends (blank-line separated)
to whatever prompt is otherwise used — the default alone, or a --system-prompt
replacement — so given both, the append follows the replacement. Blank/whitespace
values are ignored. Measured by table test; the "\n\n" separator is a chosen
convention (the base prompt already separates its blocks that way), not a
Claude Code requirement.

--mcp-config (repeatable) takes a file path or inline JSON in the .mcp.json
shape (detected by a leading '{'/'[', else read as a path) and merges those
servers over the on-disk config, a CLI value winning a name clash. The trust
choice is deliberate and measured against the existing model: the merged
servers go through the same mcp.Connect path and their tools keep the
mcp__<server>__<tool> names, so mcpPermission gates them identically to project
.mcp.json servers — allowed only under an enforcing trust posture, asked in
interactive modes, refused where there is nobody to ask. A CLI flag buys no
extra trust and bypasses no approval; this was assumed to be the safer default
and confirmed by routing through the unchanged connect/permission path rather
than a new one.

--add-dir (repeatable) adds directories the agent may operate in beyond the
working directory. They are folded into the host gate's project roots
(sessionRoots) in every mode, so an extra dir counts as project work rather
than a host change on the next tool call; interactive sessions seed their
ExtraDirs from the flag so /add-dir extends rather than replaces the set. The
extra-dirs prompt note is baked in once for the fixed non-interactive modes and
re-applied per turn in the TUI, where /add-dir can change it.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
…ROPIC_BASE_URL

Three related improvements to the native Anthropic provider's auth.

Credential resolution (api.ResolveCredential) now runs, first hit wins:
  1. ANTHROPIC_API_KEY      -> x-api-key
  2. ANTHROPIC_AUTH_TOKEN   -> Bearer
  3. klaudia login store    -> x-api-key or Bearer (~/.klaudia/credentials.json)
  4. Claude Code session    -> Bearer (OAuth) or x-api-key

Steps 1-3 are explicit Klaudia configuration; step 4 is a borrowed Claude
Code login and never overrides the former. That step was the macOS Keychain
only; on Linux/other platforms it now also reads ~/.claude/.credentials.json.

Measured: the existing OAuth path already sets Authorization: Bearer via
option.WithAuthToken (client.New) when Credential.IsOAuth(), and the Keychain
payload shape is {"claudeAiOauth":{"accessToken,refreshToken,expiresAt}}. The
Linux file is documented to use the same shape, so the OAuth token is wired
end-to-end exactly as the Keychain token already was: preferred over any
stored apiKey, refreshed and written back (0600, preserving unknown fields)
when expired with a refresh token present.

Assumed: that Claude Code on Linux writes ~/.claude/.credentials.json with
that JSON shape, and that it may also carry a top-level "apiKey"; both are
parsed defensively (missing file/keys/shapes tolerated), and a top-level
apiKey is used as x-api-key when no OAuth token is present. Not verified
against a real Linux Claude Code install here.

klaudia login writes an API key to ~/.klaudia/credentials.json (0600), read at
step 3. It prompts without echo on a terminal, reads a piped line otherwise,
and takes --api-key <k> for scripts. A full interactive OAuth device flow is
left as follow-up; a borrowed Claude Code OAuth session already covers the
subscription case.

ANTHROPIC_BASE_URL is honoured for the Anthropic base URL, via
api.ResolveAnthropicBaseURL: explicit config baseURL wins, then
KLAUDIA_CUSTOM_ENDPOINT (--custom-endpoint), then ANTHROPIC_BASE_URL, then the
production API.

Closes #155

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Composing a long or structured prompt in the input box is awkward without
editor motions. Ctrl+G now writes the current draft to a temp file, opens it
in the user's editor, and reads the edited text back into the box — the same
instinct as `git commit` dropping you into one.

Bubble Tea v1's tea.ExecProcess is what makes this cheap and correct: it
suspends the TUI, hands the child the real terminal, and repaints + restores
the cursor when the child exits, so no manual terminal juggling is needed. The
pager and /open paths already use it (measured), which is why the whole thing
is a single command returned from the key handler plus a done-message handler,
rather than new plumbing.

Editor resolution reuses the convention /open established, verified by reading
editorCommand in pager.go: $VISUAL first, then $EDITOR, keeping any flags the
value carries. Where /open then fails, this falls back to vi/nano on PATH so
Ctrl+G still works on a box with neither variable set — the issue asks for a
sensible default, not a hard failure. The fallback order (vi, nano, nvim, vim)
is assumed to cover the common cases; vi being POSIX-mandated is the load-
bearing one. With nothing usable it prints a brief hint instead of failing.

The expanded prompt value is written (paste chips resolved to payloads) so the
user edits the real text that would be sent. Command resolution and the temp-
file round-trip are factored into pure functions (draftEditorCommand,
writeDraftFile, readDraftFile) and unit-tested; terminal suspension itself is
not unit-testable. An editor that exits with an error leaves the original draft
untouched, and the temp file is always removed. Ctrl+G is gated to states that
show a real editable draft (idle, queuing a follow-up, answering in your own
words), never a y/n or numbered prompt.

docs/screenshot.svg is regenerated because the /help key hints changed, which
its currency test requires.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
When a turn emits several tool_use blocks, a maximal run of consecutive
read-only tools now has its Execute calls fired concurrently (bounded to
8) instead of strictly sequentially, so a turn dominated by I/O-bound
reads costs about its slowest call rather than the sum of all of them.

Read-only is a deliberate allowlist (readOnlyForConcurrency: Read, Grep,
Glob, and the LSP query tools Diagnostics/Definition/References), not the
allowAlways permission decision. Measured against the code: allowAlways
is also returned by tools that mutate state or shared loop maps
(TaskCreate/TaskUpdate, KillShell, RestartJob, Memory, TodoWrite, Skill,
Agent, ToolSearch), so it is not a safe concurrency signal. Bash is
excluded because a command is not read-only in general; Edit/Write/
NotebookEdit are mutating; ToolSearch is excluded because its Execute
calls Reveal(), which writes the loop's shared `revealed` map. The
allowlisted tools were checked not to use tctx.Progress or tctx.Reveal,
so nothing on a worker goroutine calls emit or mutates shared state.

Ordering is guaranteed by index: dispatchToolUses writes each result
into its tool_use slot, so tool_result still pairs with its tool_use, and
a mutating/side-effecting tool ends the current read-only run — the run
before it is fully joined before it executes, so observable side effects
never reorder. A single read-only call (or one not adjacent to another)
takes the unchanged sequential dispatch path.

Shared-state safety: dispatch was split into prepareCall (gating) →
Execute → finalizeCall (result handling). All per-call gating and every
read/write of the shared failures/errStreaks maps stay on the loop
goroutine and in order; only Execute overlaps. This is the design, not an
assumption — verified with `go test -race ./internal/agent/...`, which
passes, including a test that asserts peak concurrent executions > 1, a
test that result order and ids are preserved, and a test that a mutating
tool never overlaps the read-only executions on either side of it.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…odel

Three related changes to how the TUI drives compaction.

/compact now takes optional free text after the command. It is threaded
through Compact -> BuildSummaryRequest and appended to the summary
instruction as an emphasis, so the model keeps detail about what the user
named. A plain /compact leaves the instruction byte-for-byte identical, and
autocompact passes an empty focus, so the no-arg path is unchanged
(asserted by TestBuildSummaryRequestNoFocusUnchanged).

/summary shows the current session's last compaction summary and, with
"edit", opens it in $EDITOR. Measured: showing the in-memory summary (from a
/compact this session), falling back to the persisted summary that seeds
--resume, is covered by tests; the edit round-trip reuses the existing
temp-file + editorCommand path that /open already uses (write to a temp
file, hand it to $EDITOR, read it back on clean exit) and persists via the
same session.WriteSummary the autocompact path writes. Assumed, and stated
in the code and CHANGELOG: /summary edit replaces the persisted summary that
seeds a later --resume; it does not rewrite the summary message already
carried in the live context, and an empty or unchanged edit is a no-op. The
editor exit is driven by tea.ExecProcess, so the interactive round-trip
itself is exercised by hand rather than in a unit test; the pieces around it
(which summary is shown, the unavailable path, persistence wiring) are
tested.

Use the live model: the TUI wired Compact to the model captured when the
session was built, so a /model switch mid-session never reached it while
every turn already resolved sess.Model fresh. The Compact closure now reads
sess.Model live too (assigned after the session literal so the closure can
see it), matching the turn path.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
An @path in the prompt used to be a bare string the model had to go and
Read for itself, and an @image.png was unusable — there was no path for a
user message to carry anything but text. Both are now resolved at submit
time, while the on-screen prompt keeps the short @path (as paste chips do).

Syntax (documented in docs/ux-spec.md):

  @path            whole text file, inlined behind a "===== path =====" delimiter
  @path:START-END  lines START..END, 1-based inclusive (END past EOF is clamped)
  @path:LINE       a single line

The colon form is the one compilers and stack traces already print, so a
pasted "file.go:42" completes cleanly; the range just adds the "-END" half.

An @path pointing at a png/jpeg/gif/webp is attached as a base64 image
content block on the user message (the same block shape a Read of an image
produces — reused via a new agent.userMessageWithImages, fed by a new
agent.Options.PromptImages that RunFunc/startTurn now thread through).
Pasting or typing the path to a saved screenshot is how a pasted image is
attached.

Measured, not assumed:
- Existence is the disambiguator (matching parseFileRef): a token that does
  not resolve is left exactly as typed, so "@handle" and an email survive.
  A missing token that plainly meant a file (a slash or an extension) earns
  a note. Covered by unit tests over temp files.
- Bounds: a whole-file text reference over 256 KiB, an image over 5 MiB, and
  a non-image binary (NUL/invalid-UTF-8 sniff) are each refused with a faint
  terminal note, never sent to the model. Nothing is silently truncated.

Scope / follow-up (assumed sufficient for this issue):
- Expansion runs on the first submit and on a message queued while Klaudia
  is working; a mid-turn steering interjection stays text-only, since the
  steer box carries a string.
- "Paste an image" means paste/type its path. True clipboard-image paste
  (bytes straight off the system clipboard) is not reachable from a v1
  bubbletea bracketed-paste event and is left as a follow-up.

Closes #156

Co-Authored-By: Klaudia <noreply@greenthread.ai>
…x-budget-usd

Klaudia tracked tokens but never turned them into dollars: the headless
total_cost_usd was a hardcoded 0 placeholder, the stream-json result line
carried none, and there was no way to cap spend.

Add internal/api/pricing.go: a per-model USD price table (input, output,
cache-read, cache-write per 1M tokens) plus CostUSD(model, usage), resolving
CLI aliases through ResolveModel like the existing MaxOutputTokens /
ContextWindow tables. A model with no listed rate is reported unknown and
costs 0, so an OpenAI-compatible endpoint (or a model added after this table)
never gets a fabricated figure and can never trip a budget stop.

Price sourcing (measured vs assumed):
- Input/output rates are first-party Anthropic Messages API list prices from
  docs.claude.com, captured as of 2026-06-24. They are list prices and WILL
  drift — update the table on any reprice or new model. This is a point-in-time
  snapshot, not a live feed.
- Cache rates are ASSUMED to follow Anthropic's standard multipliers of the
  input rate (5-minute cache write 1.25x, cache read 0.10x), which is how the
  model pages express them, rather than independently sourced per model. Stored
  explicitly per model for auditability so a future flat-rate exception is a
  one-line edit.

The agent loop computes cumulative CostUSD once from the token totals it
already accumulates (recomputed from running totals, not summed per-delta, so
rounding never drifts) and stores it on Result. That single value feeds the
headless JSON result and the stream-json result line. The TUI shows a compact
running cost in the status bar (lowest-priority segment, dropped first when
narrow) and a cost breakdown under /stats; cache tokens are reconciled onto the
session at each turn end, so the bar is exact at a turn boundary and slightly
under mid-turn.

--max-budget-usd <n> stops the run once cumulative cost reaches the limit. It
is checked at the turn boundary alongside --max-turns (not mid-turn, so no
dangling tool_use), can overshoot by at most the last turn's cost, and sets
stop reason "max_budget" with a new exit code (5, distinct from max_turns's 3
so automation can tell budget from turns from failure) and an explanatory turn
note. The budget is enforced per loop.Run — the main headless/TUI run and each
--loop iteration; folding sub-agent spend into the parent budget is out of
scope (the spawner discards child token totals today).

Co-Authored-By: Klaudia <noreply@greenthread.ai>
…ion for writers

The Agent tool blocked the parent turn until a sub-agent returned, which is
right for a quick fan-out and wrong for a long investigation. This adds a
background mode alongside the existing synchronous one (the default, so nothing
regresses):

- Background launch. `Agent` with `background: true` registers the child, hands
  back a handle, and returns immediately. A BackgroundRegistry (mutex-guarded)
  tracks id/type/label/status/elapsed/last-activity. The child runs on a context
  detached from the turn that started it — it must outlive that turn — so its
  cancellation lives in the registry, not the parent ctx.

- Delivery. A finished child's result is surfaced back on a later turn via
  Options.CollectBackground, polled at the loop's two existing safe injection
  points (the same ones steering uses), so a result never splits a
  tool_use/tool_result pair. TakeFinished marks each result collected so it is
  delivered exactly once.

- /agents status view. The command now lists background instances beneath the
  types (new agentsview.go; tui.go edits kept to the dispatch + one banner).

- Worktree isolation for writers. subagent.Type.MayWrite is derived from the
  granted toolset (wildcard or a mutating tool → writer), mirroring how
  ReadOnlyMCP is derived rather than trusting the prompt. A background writer
  runs in its own detached-HEAD git worktree, created on launch and removed on
  completion; read-only agents share the parent tree. Isolation failure fails
  the writer rather than silently running it in the shared tree.

Measured: full loop tests (scripted provider) confirm prompt launch, once-only
delivery, reader-vs-writer isolation, isolation-failure handling, and the
/agents rendering; the git-backed worktree provider is tested against a real
temp repo (skipped when git is absent) proving a write in the worktree does not
leak into the parent and cleanup removes it. Ran `go test -race` on
subagent/agent/tui/tools — all pass; the registry-concurrency test exercises
concurrent register/finish/List/collect under -race.

Assumed: the mutating-tool set (Write/Edit/MultiEdit/NotebookEdit/Bash) is the
right writer heuristic; Bash is included because a command can write.

Follow-up (documented in CHANGELOG): background delivery is wired for the
interactive TUI path only — a headless single-shot run returns before a child
finishes; per-agent cancellation and post-crash worktree reclamation beyond a
best-effort pre-clean are not yet surfaced.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Sessions were resumable only by remembering a UUID and passing --resume:
nothing listed what was stored, named a conversation, or cleared old ones,
so ~/.klaudia/sessions grew without bound.

Titles: each session derives a one-line title from its first user prompt,
persisted in a <id>.meta.json sidecar beside the transcript. The transcript
stays an append-only log the resume path reads back verbatim, so mutable
metadata lives next to it rather than inside it — the same split the
compaction summary already uses. Titles are backfilled lazily on first List,
so the derivation (which reads the transcript) happens once.

Headless management: `klaudia sessions ls` lists id/age/project/title
(--json for the machine-readable form, reusing SessionInfo's tags), and
`klaudia sessions rm <id>` deletes a session's transcript, summary and
metadata. Both are new cobra subcommands on the root, kept in sessions.go.

TUI: /resume opens the existing numbered picker over recent sessions
(title + age) and switches to the chosen one in place; /rename <title> sets
the current title. In-place resume needed the recorder to follow the swap,
so the interactive path now wraps the transcript in a swapRecorder and
exposes a Resume hook that repoints it (and the summary target) at the
chosen session — new turns then append to the resumed transcript, exactly
as the CLI --resume path does.

Retention: sessions are pruned once at startup, bounded by a new [sessions]
config section — retentionDays (default 30) and retentionMax (default 100),
independent caps, -1 to disable either. Defaults chosen conservatively so a
normal amount of history is never pruned by surprise.

Measured: build stays CGO_ENABLED=0-clean; the session/cli tests below pass;
the docs/screenshot.svg regenerated because /help gained two commands (the
image renders the real slashHelp()). Assumed: retention operates over the
whole sessions store globally rather than per-project — a single ~/.klaudia
store with one cap is the simpler contract, and the 30-day/100 defaults keep
it from biting cross-project.

Safety: the active session is never pruned and never counts toward the cap;
every delete (retention and `sessions rm`) is guarded to only remove files
sitting directly inside root/<project>/, so a caller can't be tricked into
deleting outside the sessions store. An invalid id is rejected, not matched.

Closes #148

Co-Authored-By: Klaudia <noreply@greenthread.ai>
…ery command

"Always allow" for a compound Bash line built the persisted rule from
bashparser.Prefix() — the first command's "program subcommand" short form —
and saved that one rule. Under the old first-command-only rule matching that
silently over-permitted whatever was chained after the first command. Under
the every-command matching in PR greenthread-ai#16 (which this depends on and should merge
after), the single first-command rule no longer covers the rest of the line,
so the identical command is asked about again on the next run.

Bash.PermissionRequest now also returns RuleSpecifiers: one "program
subcommand" short form per distinct command the line runs, carried through
agent.ApprovalRequest to the TUI, which on "always allow" saves one rule per
specifier. `git status && go test ./...` saves Bash(git status) and
Bash(go test), so every command is individually allowed under every-command
matching.

Rule-derivation choice (measured against the parser, not assumed): the set is
built from bashparser.Parse's top-level commands — which already include those
inside command substitutions and subshells — as their short forms, deduped in
source order. A lone command yields exactly Prefix(), so its saved rule is
byte-for-byte unchanged. The derivation returns nil, and the caller falls back
to the single prior specifier, whenever the line cannot be reduced to a clean
set of named commands: a parse error, any parameter expansion or command
substitution (a word whose text is not the whole command — a rule built from it
could both over-permit and miss the substituted program), or an inline
bash -c / eval whose payload commands this pass does not enumerate. Guessing
rules for those risks over-permitting and would not reliably allow the line
anyway, so the conservative single-rule fallback (the prior behaviour) is kept.

Dependency (assumed target semantics, read from origin/fix/bash-rules-every-command,
PR greenthread-ai#16): the matching side that requires every command in a line to be allowed
is not yet on main. This change is correct under both models — under the old
matching the per-command rules still allow the line (the first rule matches),
and under PR greenthread-ai#16 every command is covered — but its value is only realised once
PR greenthread-ai#16 merges. Merge this after greenthread-ai#16.

Measured: internal/tools/bashallow_test.go — the derivation for single, chained
and piped lines and the fallback cases, that a lone command's specifier equals
the request Specifier, and, via permission.MatchAny against each command's short
form, that the saved rules cover every command in `git status && go test ./...`
without allowing unrelated commands. Build CGO_ENABLED=0-clean; the tools,
permission, config, tui and native suites pass.

Closes greenthread-ai#65

Co-Authored-By: Klaudia <noreply@greenthread.ai>
…/model switch

Two transcript-sanitiser gaps that PR greenthread-ai#21 (issue greenthread-ai#62) did not cover — it
handled blank text and orphan tool_result/tool_use. Both 400 the whole
request on send, so a resume or a mid-session model change dies on them.

1. Over-long tool names. The API rejects a tool_use whose `name` exceeds the
   documented ~200-character ceiling (a long namespaced MCP tool name is the
   usual source). capToolNames truncates it to maxToolNameLen. Measured from
   the SDK: BetaToolResultBlockParam carries no name field — the pair is joined
   only by tool_use_id — so truncating the name cannot break pairing, and the
   id is left untouched. There is no exported SDK constant for the limit, so
   200 is fixed here per the documented max; matching the truncated name
   against the live tool set is out of scope, since the sanitiser sees only
   messages.

2. Stale thinking after a /model switch. A thinking / redacted_thinking block
   is bound to the model that produced it (verified against the Anthropic API
   reference: replayed thinking is valid only on the same model — other models
   reject or silently drop it). After a switch the blocks already in history
   are stale. Exact trigger: drop thinking from every assistant turn EXCEPT the
   in-progress one, identified by the existing pendingAssistantIndex (the last
   assistant turn followed only by tool_result blocks; -1 when the trailing
   message is ordinary user text, i.e. right after a switch — then all thinking
   is history and dropped). This is safe because a completed prior turn's
   thinking is never required by the API, while the current turn's may be, so
   it is preserved rather than risk stripping thinking a model needs.

   Limitation (assumed, not measured): the sanitiser sees only the message
   list, not the active model — a /model switch is not observable here (the
   recorded transcript stores BetaMessageParam, which carries no model). So it
   cannot distinguish a still-valid same-model prior-turn block from a stale
   one and drops both; this is never an API error (prior-turn thinking is
   optional) but does forgo some reasoning context in a healthy same-model
   session. And if a switch lands mid-tool-loop, the in-progress turn's
   thinking is kept and could still be stale — an edge accepted over the risk
   of stripping needed thinking.

Both steps are additive: existing sanitiser behaviour is unchanged, and the
fast-path needsSanitize gained detection for both so clean transcripts still
skip allocation. Tests cover truncation on tool_use with the paired
tool_result still matching, a normal name left alone, stale historical thinking
dropped while the current turn's is kept, and redacted_thinking in history.

Closes greenthread-ai#68

Co-Authored-By: Klaudia <noreply@greenthread.ai>
thedancingdeveloper and others added 28 commits October 9, 2026 13:48
…er-mcp

cli: add klaudia mcp browser for Codex
A baseline captured for another work tree was reused for as long as the
directory stayed put, and neither route in #295 moves the directory. After
the run touched a clean tree, swapping its .git for a `gitdir:` file aimed
at the start repository, or writing core.worktree into its config, left the
cached "clean" answer in place, so `git add -A` was allowed and rewrote the
start repository's index.

The probe cache now remembers the identity the answer was taken under: the
.git entry's type and mtime, and the mtime of the config in the absolute git
dir it resolved to. Both are re-read from the filesystem before a remembered
answer is trusted, so the cache still costs no git call while it holds, and
a tree that no longer matches is probed and re-captured. Failing to confirm
the identity drops the baseline rather than serving it.

The two routes are new refuse cases in TestGuardDropsBaselineWhenGitDirChanges.
Both fail on main and pass here; the #289 and #292 readings (a command judged
by the tree -C or cd lands in, a linked worktree of the start repository) are
unchanged, and the per-directory probe cache still asks git once.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
…nd config ctime (#295)

Move the work-tree identity off the probe cache and onto the baseline,
keyed by the tree's top, so a first touch at the top and a command from
a subdirectory are judged by the same identity. Record the .git entry by
type and dev/inode (not mtime, which git's own renames change), a gitdir
file by its content, and the git dir's config, commondir config and
config.worktree by inode and ctime. A remembered baseline whose tree no
longer matches is dropped and the command refused rather than
re-captured.

Committed by oversight on the worker's behalf (gitguard blocks the
worker's git add, msp-klaudia #296).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…op (#295)

A remembered probe answer is reused only while no .git entry has appeared
between the directory and the cached top. A `git init` or a gitdir file
written in a subdirectory makes git there target a different tree while
the cached top's own .git is unchanged, so the identity check at the top
alone missed it. The walk is one Lstat per directory and runs no git.

Committed by oversight on the worker's behalf (gitguard blocks the
worker's git add, msp-klaudia #296).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…w .git

gitDirAppeared now fails closed like the identity stamps do: a directory
the walk cannot see is not one to trust a cached answer about. Review
note on #297.

Committed by oversight on the worker's behalf (gitguard blocks the
worker's git add, msp-klaudia #296).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#296)

Three false refusals, each reproduced as a failing test first:
- git -C add in a linked worktree (a test-ordering fix; the guard was right)
- cd | ... && git: a piped cd runs in a subshell and never moves this shell
- git commit -m "$(cat <<EOF ...)": calls are now recorded in source
  order, and a command substitution keeps the line sequential unless its
  body contains a cd

Pipe sides carry a new Command.Subshell flag; chdir skips them. The #301
routes behave identically to main.

Committed by oversight on the worker's behalf (gitguard blocks the
worker's git add).

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With lastpipe on, bash runs the last command of a pipe in the current
shell, so a piped cd can move it. A line mentioning lastpipe is now not
sequential: no cd on it is followed and every git is judged where the
line started. TestGuardLastpipeIsNotFollowed covers shopt and set -o.

Committed by oversight on the worker's behalf.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A cd on the final side of a pipe is now always followed, which is
conservative whatever lastpipe's state; the raw-text lastpipe check stays
as a second line. Nested pipes mark their non-final sides recursively.
TestGuardQuotedLastpipeStillFollowed covers shopt -s last''pipe.

Committed by oversight on the worker's behalf.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s (#296)

Security review of #302 (B1, B2):
- B1: a cd on any side of a pipe is not followed. lastpipe is off by
  default but its state is not tracked, so a piped line is no longer read
  as sequential and its git is judged against the start repository.
  The Subshell field and its use in chdir are removed.
- B2: bash 5.3 ${ ...; } and ${| ...; } run in the current shell, so
  they break the line like main treated every substitution.

Trade-off: the #296 pipe case (cd X | true && cd .. && cd X && git add)
is refused again; the heredoc -m and git -C cases stay fixed.

Committed by oversight on the worker's behalf.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#296 made every piped line non-sequential, so `cd <worktree> && git add -A
2>&1 | tail -3` was judged against the start repository and refused over its
untracked files. That is the shape agents use to stage in their own linked
worktree, so #289 kept reproducing on builds that carry #290/#292/#294-#296.

lastpipe can only matter when a pipe side could move this shell if it ran
here. A pipe now breaks the chain only when a side runs cd/pushd/popd, a
builtin that runs or reads other code (eval, source, builtin, command, exec),
one that sets shell options or variables, a bare assignment, or a command
whose name is not a literal. Every #296 fail-closed case still refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of the #289 fix: a denylist of pipe-side builtins missed mapfile -C
(its callback runs in this shell) and read/mapfile/printf -v setting CDPATH,
which steers a later relative cd. A pipe side now breaks the chain for any
bash builtin except the inert ones (true, false, :, echo, test, [, pwd, and
printf without -v), a bare assignment, or a non-literal name.

The same review found older gaps in plain chains, closed here too:
- eval, source, ., builtin, command, mapfile, readarray, trap, enable and
  alias run code in this shell or rename what runs, so after one the
  directory is no longer known; nor after a command named by an expansion;
- a line that mentions CDPATH is judged against the start repository;
- with CDPATH inherited from the environment, a relative cd that does not
  start with ./ or ../ is not followed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Second review of the #289 fix: `read CD''PATH` and `printf -v CD''PATH`
reach bash as CDPATH, but the raw-text check could not see them, so a later
relative cd was followed to the wrong tree. Two changes:
- After any builtin that can set a variable (read, printf -v or a printf
  with a non-literal argument, declare, typeset, export, local, readonly,
  let, getopts), a relative cd that does not start with ./ or ../ is not
  followed.
- The GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE / GIT_COMMON_DIR / CDPATH /
  core.worktree check also reads each word as bash passes it (quotes
  removed), so `env GIT''_DIR=…` is caught too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The host-change guard refused `ssh host 'rm -f /x/y/.file'` straight after
the user approved deleting /x/y/.file. A remote command is classified with
no working directory, so every path effect inside it came out uncertain,
and an uncertain effect is never matched against a grant. The approval
could not cover it, and the refusal also said "this machine" for another
host.

- A path on another host spelled out as an absolute literal is marked
  Spelled: still uncertain, so still put to the user without a grant, but
  a live grant's scope may cover it. It is cleaned, not resolved against
  this machine's filesystem. Variables, ~ and relative paths are not
  Spelled. Bulk deletes and opaque scripts stay uncoverable.
- A path the request named, and that was granted exactly because its parent
  is a root, may itself be deleted (approving the delete of
  /tmp/scratch now lets `rm /tmp/scratch` run). A widened scope still may
  not delete its own directory.
- The refusal names the remote host when every effect lands on one.

Known and unchanged: a relative path after a remote cd produces no effect at
all, so it was not gated before and is not gated now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adversarial review of 389bf60 found four ways a grant now covered more than
the user approved. All four were refused on main:
- B1: a non-literal word dropped from the ssh argv (`"$X"`, `$(…)`) still
  let the rest of the remote script count as spelled out. Now nothing in
  such a script is Spelled.
- B2: brace expansion escaped the scope. `/scope/.{.,}/.{.,}/etc/x`
  cleans lexically inside it, but the remote shell expands it outside. A
  path with braces, globs or a backslash is never Spelled.
- B3: a named scope path let `rm -rf <it>` through. Deletes now carry
  Recursive (any short cluster with r/R, or --recursive), and a named
  path may be deleted only non-recursively.
- B4: approving a remote change at the drift card minted a grant that then
  covered this machine. Spelled effects mint nothing again.

Also: `ssh localhost` / 127.x / ::1 is this machine, so it is not Spelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Second review of the #305 fix:
- R1: GNU rm takes any unique prefix of a long option, so `rm --rec` and
  `--recursiv` remove a tree but slipped past the Recursive mark (and the
  bulk check). Any prefix of --recursive from `--r` on now counts.
- R2: `ln -s / /scope/r` counted as a write inside the scope, after which
  /scope/r/etc/shadow looked in scope. A link is never spelled out on
  another host. A symlink that already exists on the remote host is a
  residual risk: nothing there can be resolved from here.
- A remote script that runs xargs, parallel or find -exec has arguments
  appended at run time, so nothing in it is spelled out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Third review of the #305 fix: a write inside a remote scope can plant a
symlink. `ln` was closed, but `cp -s`, `mv`, `rsync` and `tar` also do it,
and a later spelled path "inside" the scope then lands anywhere on that
host. A remote path cannot be resolved from here, so Spelled coverage is now
limited to what #305 needs: a non-recursive delete of a path in an approved
scope. Writes through a remote scope go to the user every time. A delete
through a symlink that already exists on that host remains a residual risk.

Also: xargs/parallel are matched by basename (`/usr/bin/xargs`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as (#307)

Vogt could not start a Klaudia worker between "autonomous" and "bypass":
acceptEdits was a deprecated alias for autonomous, so Vogt refused
accept_edits for the Klaudia template (WI-1272). This makes it the mode
the name says, with Claude Code's meaning and no allow rules:

- Write/Edit/NotebookEdit inside the workspace run without asking. The
  workspace is the run's working directory plus its added directories,
  canonicalised, so a symlink out of the project is outside. The loop
  fills it per run (permFor), so a sub-agent in its own worktree is
  trusted with that worktree. Sub-agents now also carry ExtraDirs.
- Edits outside the workspace ask, including the rest of $HOME and the
  temp dir, which the host gate counts as project work for autonomous.
  Exec-granting files ask as before.
- Bash, RestartJob, the browser/network tools and MCP calls ask, every
  time (no "always" answer exists in any mode).
- The host gate still runs first and is unaffected; bypass is still the
  only mode that skips it, and set_permission_mode still refuses bypass
  for a session not launched in it.
- The model is told the mode in a system clause.

Server-side web_search/web_fetch are kept, as in plan mode: they run on
the API and cannot be asked about per call.

default and dontAsk remain aliases for autonomous. autonomous, plan and
bypassPermissions behave as before; RestartJob keeps its old stance
outside acceptEdits. In the /mode picker acceptEdits is third, so plan
stays 2; bypass moves from 3 to 4.

Behaviour change: a launcher passing acceptEdits to mean autonomous now
gets the stricter mode. Checked, not assumed: Vogt main refuses
accept_edits for Klaudia and starts it in the default mode, and nothing
in vogt-estate passes the flag.

Measured: make check passes static, unit and hermetic. e2e had one
failure in TestHeadlessBackgroundAtMaxTurnsWarnsAndExits (the fake API
got a truncated request body at process exit). It passed 5/5 in
isolation and on two full e2e reruns. New tests: per-tool decisions,
agent dispatch (in-workspace edit runs, command asks and does not run
when declined, out-of-workspace and symlink-escape edits ask, added dirs
count), and e2e for the flag, the config key, headless refusal and a
stream-json driver that is asked about the command but not the edit.

Co-authored-by: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com>
Co-authored-by: Klaudia <noreply@greenthread.ai>
… resolving (#308)

* gitguard: stage a baseline path the session edited, and a merge it is resolving

The baseline stored a dirty bit, so a path that was dirty at start stayed
out of every commit even after the session rewrote it, and so did a file
left unmerged by a merge the session was resolving. Record a hash of the
work-tree bytes and the index blob at capture. A stage or commit of a
baseline path is allowed when those bytes have changed, or when git has
the path unmerged. Discarding a baseline path is refused either way, and a
path the session never touched stays protected. The same reading holds for
the Codex hook, which reloads the hashes from its baseline file.

* gitguard: prove a baseline path may be staged once it is unmerged

The earlier merge test staged a file that was clean at capture, so the
guard already allowed it. Plant the conflict in the index after capture
and require both the built-in guard and the hook to allow git add of
that baseline path, while still refusing to discard it.

---------

Co-authored-by: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com>
…ntities (#309)

WI-1300. Sessions whose cwd is a dirty main checkout but which work in
their own clean linked worktree were refused staging files they had
edited themselves, as "uncommitted changes that existed before this run
began" (WI-1281, 1304, 1298, 1323, 1318).

Per-worktree baselines already exist (#289, #308); the plain case passes
in-process. Two things lost them:

- persist.go round-tripped the other work trees' baselines but not their
  identity (gitguard.id). Baseline.other (target.go) treats a zero
  identity as a changed tree: it drops the baseline, and the next touch
  re-captures it with the session's own edits in it. Every reload — a
  `klaudia hook gitguard` call, or a resume — hit this.
- the native frontend (root.go) ran Capture(cwd) at every process start,
  so a session resumed after an engine restart or a hibernate re-captured,
  again recording its own writes as the user's.

Now the identity is persisted, and the native frontend keeps the baseline
beside the transcript (<id>.gitguard.json), saves it after every guard
call, and reloads it on resume (a fork reads its parent's). A file that is
present but unreadable is not taken as "no baseline": the run warns and
captures afresh, which over-protects. Nothing else is relaxed: start dirt
and first-touch dirt in a work tree stay refused for staging and for
discards, and a reloaded work-tree baseline still has to pass the
identity check.

Measured: TestReloadedBaselineCommitsOwnEditInSiblingWorktree fails
without the identity persistence with the field's exact refusal text,
and passes with it. Assumed, not measured: that the 2026-10-13 02:5xZ
refusals went through a reload (a hibernate/wake or a hook) rather than
some other path. The deployed klaudia is 3930637, the base of this
branch, and the plain in-process case does not reproduce.

Co-authored-by: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com>
Co-authored-by: Klaudia <noreply@greenthread.ai>
A Grep call that named only a pattern returned matching paths, because
output_mode defaulted to files_with_matches. Models that never pass a mode
(grok, in the WI-1311 report) then saw no lines and reported the tool as
returning no content. Content with line numbers is what a pattern-only call
is asking for, so it is now the default, capped at 200 lines with the
remainder counted; files_with_matches and count stay explicit and uncapped.
Context flags (-A/-B/-C) also force line numbers, since context lines are
unreadable without them.

Measured: CGO_ENABLED=0 go build ./..., go vet ./..., go test ./internal/...,
and make hermetic all pass. Assumed: nothing outside internal/tools depends
on the old default — the only callers asserting bare paths were the tool's
own tests, which now pass output_mode explicitly.

Co-authored-by: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com>
Co-authored-by: Klaudia <noreply@greenthread.ai>
* agent: nudge a turn that only announces its next step

A headless or goal-loop run that ends on "I'll read X next" has done
nothing, and recovering it costs a whole re-prompt. When
ContinueOnTrailOff is set, a tool-less reply that matches an
announcement pattern gets one user-message nudge back into the same
loop, capped at two, and the count lands on Result.TrailOffNudges.

A question and a reply carrying AUTOPILOT: DONE or <goal-complete/>
still end the turn. The flag is on for -p, the goal loop, and the
stream-json peer, and off for an interactive TUI turn where ending is
how the user gets to answer.

Co-Authored-By: Klaudia <noreply@greenthread.ai>

* agent: report trail-off nudges, bound them by the run's limits, match only the last sentence

Fixes the three blockers from the WI-1301 review of PR #311.

Reporting: Result.TrailOffNudges was set but never surfaced. It now
appears as trail_off_nudges on the -p JSON result and the stream-json
result line (omitted when zero), and sumResults adds it across a
background-drain follow-up. docs/embedding.md lists the field.

Limits: the nudge's continue skipped the max_turns and budget checks at
the bottom of the loop, so MaxTurns=1 with three announcements ran three
turns. The nudge now fires only while withinLimits(opts, res) holds;
past the cap the announcement stands as the answer.

False positives: the patterns matched "I'll <verb>" anywhere in the last
~200 characters. The detector now looks at the last sentence only, and
exempts conditional offers ("If you approve, I'll push") and deferrals
("in a follow-up PR", "later"). The review's three examples are negative
test cases now.

Measured: go build, go vet ./..., go test ./internal/..., scripts/hermetic.sh
and go test ./e2e/... pass. A live -p run on grok-4.7, told to answer
"I'll read notes.txt next." with no tool call, returned trail_off_nudges:1,
num_turns:4, and went on to read the file. Not measured: how often the
conditional/deferral exemptions skip a real trail-off in practice.

Co-Authored-By: Klaudia <noreply@greenthread.ai>

* agent: treat sign-offs as endings, not trail-offs

The WI-1301 re-review found closing lines nudged as announcements:
"Let me know what you think.", "Summary above. Let me know.",
"Done. I'll leave the rest to you.", "I'm going to stop here." and
"I will now stop." A last sentence with "let me know" or a stop or
hand-back verb (stop, pause, end, wrap up, leave, hand back/over) now
counts as an ending. The five are must-not-nudge tests.

Measured: go build, go vet ./..., go test ./internal/..., scripts/hermetic.sh
and go test ./e2e/... pass. Assumed: an announcement whose last sentence
uses one of these verbs for something else ("I'll read the end of the
log") is rare enough to give up.

Co-Authored-By: Klaudia <noreply@greenthread.ai>

---------

Co-authored-by: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com>
Co-authored-by: Klaudia <noreply@greenthread.ai>
TestCodexSubagentMCP drove the server with a #!/bin/sh fake codex, but
refuseWrapper (fdedc37) rejects every executable whose first bytes are
"#!": a script can prepend --dangerously-bypass-approvals-and-sandbox,
and that flag does not conflict with the -s the server passes, so the
child would run unsandboxed. The server therefore exited before
initialize and the one test that speaks MCP to the binary could never
pass. The fixture is now a compiled Go helper built by the test, which
is what the check accepts. refuseWrapper is unchanged and
TestRefuseWrapper still covers the script, --yolo and bypass-flag cases.

The README's environment-variables table was also behind the code.
KLAUDIA_CODEXSUB_DEPTH, which this server reads and sets, had no row,
and neither did six older variables the same test reports. Documenting
them turns TestREADMEDocumentsEveryEnvVar green.

Verified: make check (exit 0), including go test ./internal/codexsub/,
./internal/cli/ and ./cmd/klaudia/.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
codex exec rejects the pair: "--session-id is only supported when starting
a new persisted thread". spawn_isolated therefore died before the child
started on any binary whose help advertises --session-id.

Keep the id. Result.SessionID is how the caller resumes the child, so
dropping the id would make a child that the binary can identify
unidentifiable. --ephemeral stays only for the binary that does not
accept --session-id, where there is nothing to resume and the child
should stay out of the session list.

TestSessionIDIsUUID now fails on the old argv, which passed both flags.

Based on origin/wi-1138-codexsub-e2e (fast-forward of origin/main plus
the fakecodex fixture). That branch touches cmd/klaudia, not this test.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
A pathless `git commit` records the whole index, and gitguard let it run
because "the add that staged it was checked". An entry the user staged
before the session began was never added by the session, so it rode along
in the session's commit (WI-1596, seen on vogt-dev: commit 11aadc6 swept
in an already-staged file).

Capture now records the tracked paths with an index change (the porcelain
X column, unmerged entries aside) as Staged, persisted with the baseline.
A plain commit or --amend is refused while any of them still has the index
entry it had at capture. Committing by pathspec (`git commit -m x -- own`)
is allowed, and so is a plain commit once the session has restaged or
unstaged the entry itself. A baseline written by an older build has no
Staged list and behaves as before.

`-qm wip` style clusters now take their value for commit (m, F, C, c) and
stash (m), so the message is not read as a pathspec.

The other two WI-1596 observations are not misses: in that scratch setup
`git clean -f` had no untracked file to remove and the second repository
had nothing to restore, so both were no-ops. With untracked files and a
dirty second repository both are refused, by this build and by 9d0a51d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: sprooty <sprooty@sprooty.com>
…along

git commit -i and --include record the named pathspecs and the index as it
stands, so a pathspec did not make the commit safe: work staged before the
run began was committed with the run's files. Those forms now also check the
paths staged at capture, the same set a pathless commit already checked.

An index entry that cannot be re-read is a refusal, not a pass. A path git
answers for and does not have is the session having unstaged it, which stays
allowed, and so do the pathspec-only commits.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
… deletion

-i inside a short cluster (-qi, -iq, -im) was only matched as a standalone
flag, so it committed the index along with the named paths. Long options are
now matched by unambiguous prefix per subcommand, the way git does, so --incl
and --inc are --include while --in stays ambiguous with --interactive. A
pre-staged deletion was recorded as hashAbsent and then read as "the session
unstaged it"; an unchanged entry is still the user's, whichever hash it has.
hashIndex also fails closed unless git itself answers exit 1, so a missing
git or a directory that is not a repo refuses instead of passing.

Co-Authored-By: Klaudia <noreply@greenthread.ai>
…ng stdin

Three reasons spawn_isolated with an output_schema never finished:

The field was a json.RawMessage, which the MCP SDK's schema inference
treats as a byte slice, so the published tool schema typed it as an
array of integers. A model holding a JSON Schema object could not pass
it. It is now a map[string]any, which infers an object. Boolean schemas
can no longer be expressed; callers pass objects.

codex exec reads stdin whenever it is a pipe, even with the prompt
already in argv, printing "Reading additional input from stdin..." and
exiting 1. The server's stdin is the MCP transport, a pipe the child
inherited. The child now gets os.DevNull.

With stdin closed the child died on a missing HOMELAB_LITELLM_SESSION_KEY.
The session bootstrap exports the provider credentials into this
process, and the child authenticates as the same provider, so it needs
them. os.Environ already carries that set; a test now pins it, because
nothing else said the inheritance was deliberate.

Verified: go vet ./... and go test ./... both pass.
A linked worktree has no config of its own, so its identity was anchored
on the one config every worktree shares. git checkout -b and git worktree
add rewrite that file to record a branch upstream, which changed its inode
and dropped every other session's remembered baseline; the next touch then
captured afresh and recorded files the session had written as the user's
(WI-1670).

Stamp the shared config by the lines that say where the tree points
(core.worktree, core.bare) instead of by inode, and always record the git
dir's own config so the remembered git dir stays the worktree's.
@thedancingdeveloper
thedancingdeveloper merged commit a7683ba into main Oct 16, 2026
3 checks passed
@thedancingdeveloper
thedancingdeveloper deleted the fix/gitguard-linked-worktree branch October 16, 2026 00:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants