Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b4462b9
feat(api): worktrees-api, the command layer with no window attached
brethash Oct 17, 2026
0c8eada
refactor(api): the log becomes a sink, and the first 16 handlers move
brethash Oct 17, 2026
a42f90b
refactor(api): the nav-dot poll moves, and its registries stop being …
brethash Oct 17, 2026
2d8b928
refactor(api): projects and their snapshots, and R2 starts
brethash Oct 17, 2026
5f0674b
refactor(api): settings, planning, and the mutating verbs
brethash Oct 17, 2026
1d0d695
refactor(api): usage, Claude's service status, and the release list
brethash Oct 17, 2026
c5be483
refactor(api): config surfaces, MCP setup, and Ask Claude
brethash Oct 17, 2026
8db2f44
refactor(api): sync, agent setup, diagnostics, pull requests
brethash Oct 17, 2026
a01a04b
refactor(api): the docs viewer and its server move with their handlers
brethash Oct 17, 2026
7a4d873
refactor(api): the dock, the Files tab, and the last of the 143
brethash Oct 17, 2026
06a8959
test(api): make the no-tauri rule a gate instead of a comment
brethash Oct 17, 2026
3b85805
docs: the CHANGELOG entry, and close the sandbox-log note in AGENTS.md
brethash Oct 17, 2026
26e0451
test(api): a headless harness, and the wiring checks run() needs
brethash Oct 17, 2026
0db742a
ci: run the api crate's tests, and aim the release gate at the crate …
brethash Oct 17, 2026
d5028c3
docs(api): one engine process per host — the app becomes a client of …
brethash Oct 17, 2026
2816f05
test(api): the forced-ask test scripts its clock instead of racing th…
brethash Oct 17, 2026
1da695e
docs: ROADMAP — the bundled CLI is a private sidecar first; the PATH …
brethash Oct 17, 2026
df91df3
docs: the Server Edition proposal, and Claude Code's grep skipping bi…
brethash Oct 18, 2026
97735f9
fix: remove the two raw NUL bytes that hid files from Claude Code's grep
brethash Oct 18, 2026
f308062
docs(proposal): two security settings are decided by whichever client…
brethash Oct 18, 2026
bbfed64
fix(app): one .setup — tauri kept only the second, so no Ctx was ever…
brethash Oct 18, 2026
ad09f5d
docs(proposal): Appendix B — every process-global static, ruled for o…
brethash Oct 18, 2026
72fce61
fix: a client's string is never read as a flag (R3, OWASP M3)
brethash Oct 18, 2026
c25cc2a
fix: Codex permissions and pi trust are host policy; loading a client…
brethash Oct 18, 2026
9495e3c
fix(app): push terminal-history options only after hydration
brethash Oct 18, 2026
c04243d
docs: record decision 7 (host policy is engine-only), handshake v1, a…
brethash Oct 20, 2026
fecee55
Merge origin/main (Kimi harness, #509 #510) into the worktrees-api ex…
brethash Oct 20, 2026
9b97a62
fix(core): doctor --kimi stops scanning at `--` (R3)
brethash Oct 20, 2026
cd0e60c
docs: counts and pointers after the Kimi merge
brethash Oct 20, 2026
4799b42
Merge origin/main (kimi plan usage, #511 #512) into the worktrees-api…
brethash Oct 20, 2026
4067c33
docs(changelog): the 150th command is get_codex_permissions, not one …
brethash Oct 20, 2026
eb0f72e
Merge origin/main (#520, the OAuth token off curl's argv) into the wo…
brethash Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/close-out.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ make test
make lint
cargo test -p worktrees-core
cargo test -p worktrees-cli
cargo test -p worktrees-api
cargo test -p app --lib # `check`/`build` do NOT compile `mod tests`
cd app && ./node_modules/.bin/tsc --noEmit && cargo check -p app
```
Expand Down
17 changes: 12 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,20 @@ jobs:
pnpm -C app build
- name: cargo build -p app
run: cargo build -p app --locked
# `build`/`check` do NOT compile `mod tests`, so without this line the app
# crate's unit tests only ever run when someone types the command — the
# same gap that shipped a broken test module for the CLI crate above.
# It matters most on ubuntu: the Linux half of `proc_cwd` (/proc/<pid>/cwd)
# is dead code on a mac and this is the only place it is ever executed.
# `build`/`check` do NOT compile `mod tests`, so without these lines the
# app and api crates' unit tests only ever run when someone types the
# command — the same gap that shipped a broken test module for the CLI
# crate above. The api line matters most on ubuntu: the Linux half of
# `proc_cwd` (/proc/<pid>/cwd) is dead code on a mac, and this is the only
# place it is ever executed. It lived in the app crate until the command
# layer moved to `worktrees-api`, and for a while after that move it ran
# nowhere in CI, under a comment here saying the opposite.
- name: cargo test -p app
run: cargo test -p app --lib --locked
# No `--lib`: `tests/headless.rs` is the one place the crate is driven
# the way `worktrees serve` will drive it, through public API only.
- name: cargo test -p worktrees-api
run: cargo test -p worktrees-api --locked
# The `app/scripts/*-check.mjs` family — source-slicing guards for rules
# tsc and the unit tests cannot see. Last, because they are the cheapest
# and a compile failure above is the more useful report.
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ jobs:
# There used to be a Go toolchain here, a `git clone` of a third-party
# viewer, and a gate against it, because the viewer was `mo` and `mo`
# does not validate the `Host` header (proposal §13.2). The server is
# ours now — `app/src-tauri/src/docserver.rs` — so what is gated is our
# ours now — `crates/worktrees-api/src/docserver.rs` — so what is gated is our
# own source, and the gate below runs it.
- name: build the documentation viewer's browser bundle
run: |
Expand Down Expand Up @@ -127,11 +127,14 @@ jobs:
# `Host` headers** and sends only one, which made the first run of that
# case in the transport findings a false pass (§6.1). `--exact` so a
# rename cannot turn this step into a filter that matches nothing and
# exits 0 — which is the shape a gate fails in.
# exits 0 — which is the shape a gate fails in. The CRATE matters as much
# as the names: these tests moved to `worktrees-api` with the command
# layer, and under `-p app` the same filter matches nothing. Only the
# count check below notices either mistake.
- name: GATE — the docs server refuses a forged Host and serves a loopback one
run: |
set -euo pipefail
cargo test -p app --lib --locked -- --exact --nocapture \
cargo test -p worktrees-api --lib --locked -- --exact --nocapture \
docserver::tests::a_forged_host_is_refused_and_a_loopback_one_is_served \
docserver::tests::a_duplicate_host_header_is_refused_rather_than_resolved \
docserver::tests::a_cross_origin_request_is_refused_and_nothing_says_otherwise \
Expand Down
77 changes: 74 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,23 @@ started in a home or scratch directory the moment the app began installing it
globally. It now handshakes, advertises zero tools and says why; `test/mcp.bats`
pins that, and the bats suite caught the change by asserting the old contract.

**A client's string is never a flag (R3) — and `--` alone would have made it
worse.** Every core `cmd_*` parser sorts flags from positionals by the leading
`-`, and `worktrees-api` pushed the app's strings in bare: `relink`'s `slug:
"--all"` relinked every place, `--force` included, and came back OK. Three
parts, all required. The api builds every argv with `worktrees_api::argv::Argv`
(our flags are `&'static str`, a value is glued on as `--x=<v>`, positionals go
after `--`; a test fails on any other call to a `cmd_*`). The parsers honour
`--` (`worktrees_core::argv::split_options`). The boundary refuses a `-`-led
branch, base or slug with core's `safe_arg`/`branch_arg`, which the MCP server
shares. The trap is the middle part: core was safe by ACCIDENT, because the
parser refused every `-`-led positional as an unknown flag. Honour `--` and the
value reaches git, which permutes options the same way, and `do_switch`'s
`git fetch origin <base>` with `--upload-pack=<cmd>` RUNS `<cmd>` (measured, on
a local-path origin). So a git call carrying a client value puts its own `--`
in front of it, and teaching any other parser `--` starts with walking where its
positionals land.

## Gates (run before any PR)

```sh
Expand All @@ -91,6 +108,7 @@ make test # bats suite vs the Rust binary (fake git/tmux shims)
make lint # shellcheck + bash-3.2 gate (shim + install.sh)
cargo test -p worktrees-core
cargo test -p worktrees-cli # MCP protocol unit tests live here
cargo test -p worktrees-api # the command bodies + tests/headless.rs
cargo test -p app --lib # `check`/`build` do NOT compile `mod tests`
cd app && ./node_modules/.bin/tsc --noEmit && cargo check -p app
```
Expand Down Expand Up @@ -148,6 +166,29 @@ pipefail promotes that to the pipeline's status — so "found" reads as "not
found". Two probe results were misread as product bugs before this was spotted.
Write to a file, then grep the file.

**Inside Claude Code, `grep` is not grep, and it skips binary files without a
word.** The shell snapshot defines `grep` as a function that runs Claude's
bundled ugrep with `-I`, so any file holding even one NUL byte is skipped
rather than searched. `projcfg.rs` held one, a literal NUL in a comment, so
`grep -r USER_ONLY_KEYS crates/worktrees-core/src` listed every file that CITES
the const and not the one that DEFINES it, and a single-file grep printed
nothing and exited 1. A lane concluded from exactly that, on 2026-10-17, that
the const "no longer exists"; it sits at line 51, and the claim was relayed
before anyone checked. A "not found" that a conclusion rests on needs a second
witness: `git grep` or `/usr/bin/grep` (`command grep` skips the function),
which both REPORT a match in a binary file instead of skipping it.

**A backslash escape written through a script can land as the raw byte.**
`"\x00"` or `"\u0000"` inside an ordinary Python string literal IS a NUL by
the time the file is written. One archived session summary says in its own
words that a Python heredoc did this — in a sentence whose `\x00-\x1f\x7f` had
itself become raw bytes — and `projcfg.rs`'s NUL fits the same shape. When a
script writes source or docs, use raw strings (`r"…"`) or double the
backslash, then check the file with `file`, which must say text. Do not count
on git to notice: it sniffs only the first 8000 bytes, so it showed that
summary's diffs as `Binary files differ` and rendered `projcfg.rs`, whose NUL
sat deeper, as ordinary text.

**A pipeline's exit status is the LAST stage's.** `make test | tail -15` exits 0
because `tail` did, and a mid-stream `not ok` scrolls off a 15-line window — so
the run reads green whatever bats said. Redirect, then check both halves:
Expand Down Expand Up @@ -322,6 +363,17 @@ PAIR of directions (title/lifecycle must notify, clock fields must not), each
confirmed red by breaking the fix the opposite way — too narrow is as wrong as
too broad and does not look like it. Third sibling of the two notes above.

**A test that races two sleeps asserts the scheduler, not the rule.**
`a_forced_ask_queued_behind_a_fetch_shares_it` slept 40ms and expected to land
inside a 150ms fetch on another thread; a macOS CI runner spawned that thread
late enough that it did not, and the failure read as the PR cache fetching
twice. Locally it passed 40 of 40, which is why it reached CI. The rule was
about three STAMPS (asked, started, done), so the fix is the `_in` seam one
level down: `answer_with` takes the clock, the test scripts the stamps, and a
`Barrier` keeps the shape without deciding the outcome. The Rust twin of
`termresize-check.mjs`'s virtual clock — any test with a `sleep` on both sides
of a race wants the same treatment.

**A new test must be shown to FAIL first.** ROADMAP's zombie-children item
records a regression test that passed identically with and without its fix.
Break the thing under test (drop the `skip_serializing_if`, restore the old
Expand Down Expand Up @@ -443,6 +495,19 @@ after it is "command not found" — which reads as a sandbox refusing curl.

- **Commands must be `async fn`** — sync handlers run on the main thread and
freeze the UI for every git/tmux shell-out.
- **`Builder::setup` REPLACES, and `state::<T>()` compiles for any `T`.**
tauri 2.11.5 stores `self.setup = Box::new(setup)`, so a second `.setup(…)`
in the chain silently discards the first. The first held core's log sink from
#358 (v0.35.0) on, and then, in #508, `IDENT` and the `Ctx` itself. Every
`ctx()` panicked "state() called before manage()", and #508 sat 9/9 green
with an app that could not answer one command: nothing in CI runs the app,
and the check that "exactly one Ctx is managed" counted the line textually,
in the dead closure. Fixing that surfaced a second of the same kind: the poll
still asked `state::<viewer::Viewer>()` for a registry that had moved into
the `Ctx`. `api-boundary-check.mjs` now counts `.setup(` and refuses any
state lookup but the `Ctx` and the window tracker. Both failures exist only
as `[panic] … before manage()` lines in `app.log`, so after any change to
`run()`, launch it (`sandbox.sh --app`) and grep the log; no suite will.
- **GUI launches get launchd's bare PATH** (no homebrew → no tmux).
`fixup_gui_path()` in lib.rs resolves the login-shell PATH at startup —
don't add subprocess calls that assume PATH before it runs.
Expand Down Expand Up @@ -1059,9 +1124,15 @@ after it is "command not found" — which reads as a sandbox refusing curl.
`cli-missing`, and that state deliberately suppresses the offer, so you would
measure the wrong thing and conclude the feature is broken. Point `CARGO_HOME`
and `RUSTUP_HOME` at the real ones or the build re-downloads the registry.
Second gap, unrelated: `--app` moves the CONFIG dir to
`net.casadelvalle.worktrees.sbx` but `app.log` still goes to the **plain**
identifier — a sandbox writes into your real app's log unless HOME is faked.
Second gap, unrelated, and now CLOSED: `--app` moves the CONFIG dir to
`net.casadelvalle.worktrees.sbx`, and `app.log` used to go to the **plain**
identifier regardless, so a sandbox wrote into your real app's log unless
HOME was faked. `log_dir()` read `APP_IDENT`, a `const`; it reads a runtime
`IDENT` now, set in `.setup()` from `app.config().identifier` — the same
field `app_config_dir()` is `config_dir().join(…)` of, which is why the
config dir already followed the override and the log did not. One line is
still written under the default: the `startup v… PATH=…` line, emitted
before any app handle exists to ask.
- **A vanished sandbox app means the human closed it.** `tauri dev` exits 0 when
the window closes, which is indistinguishable from a clean shutdown because it
is one; a harness kill is 143. Twice in one session that was reported as a
Expand Down
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning: [S
## [Unreleased]

### Changed
- **The 149 app commands moved into a new `worktrees-api` crate**, leaving the
Tauri layer as thin wrappers. Nothing changes for the desktop app — the same
149 commands with the same arguments, verified against `main`, plus
`get_codex_permissions` for the host-policy change below — but the
command layer can now be called by something that is not a window, which is
what `worktrees serve` will need. `app/src-tauri/src/lib.rs` goes from 11,288
lines to about 2,100; the PTY host, the documentation server and its viewer
move with the handlers that drive them.
- **A sandboxed app no longer writes into your real app's log.**
`app/scripts/sandbox.sh --app` runs under its own bundle identifier and has
always had its own config dir, but `app.log` was keyed to a compile-time
constant and went to the installed app's folder regardless. It follows the
running identifier now. (The one `startup` line is still written before the
app can be asked which identifier it is.)
- **A project the worktrees rules can be applied to (or updated in) now shows
"Repair / upgrade…" in its right-click menu**, with no number beside it, so
Apply is one menu item away instead of buried in Project settings. The rules
Expand Down Expand Up @@ -35,6 +49,10 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning: [S
brings "Repair / upgrade…" back, with a **Review** button on its To do row,
and no longer tells a repository with no remote to push it. Once GitHub says
the branch's PR is merged, the row offers **Remove the place**.
- **`--` ends the options of `new`, `switch`, `open`, `close`, `rm`, `relink`,
`provision`, `doctor` and `status`**, so `worktrees new -- <name>` takes
what follows as the name however it is spelled. It used to be an unknown
flag.

- **Kimi Code is a fourth agent: `worktrees new|open --ai kimi`** (measured on
kimi 2.2.0). A lane runs in its own `~agent~kimi` session, `--model` takes
Expand Down Expand Up @@ -74,6 +92,27 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning: [S
too. Settings → Usage can turn it off.

### Fixed
- **Opening the app no longer sets what Codex and pi may do.** Settings →
Codex → Permissions and Settings → pi → Project trust were sent to the
backend every time the app loaded, from the window's own saved settings, so
whichever window loaded last decided them for every lane. With one engine
serving several windows or devices, opening a stale one was enough to widen
Codex to Full access everywhere. Both now live on this machine, in
`host-policy.json` beside the app's settings, and change only when you click
them in Settings; the panels show the machine's value. A value you had
chosen is carried over once, the first time the new version starts. The
CLI and lanes started over MCP still follow `$WORKTREES_CODEX_PERMISSIONS`,
`$WORKTREES_PI_PROJECT_TRUST` and your config, as before. If you never
chose a value in the app, lanes the app starts now follow those too, rather
than the default the window used to push: check `codex_permissions` in
your config if it says `full`.
- **Reloading the window no longer briefly turns terminal scrollback saving
back on.** With "Keep each tab's output between restarts" (or "Give each tab
its own command history") switched off, a reload sent the defaults — both on
— to the backend before your settings had loaded, and if the 15-second save
landed in that gap, open dock tabs had their output written to disk anyway. The switches now reach the backend only
once your settings have loaded, and a refusal shows an error rather than
being dropped.
- **"Write .worktrees.toml" in Project settings now writes the file.** In a
repo with nothing to suggest — no gitignored credential file, no ports, no
compose — the button greyed out, came back, and nothing appeared: `init`
Expand All @@ -84,6 +123,25 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning: [S
(the same all-comments file `--print` has always emitted, which `doctor`
reads back clean). Without `-y` nothing changes, except that the message
now says how to ask for the template anyway.
- **The Claude and Codex usage readers log to app.log again.** Since v0.35.0
the app's builder called `.setup()` twice, and tauri keeps only the LAST
closure, so the one that hands core its log sink never ran: every line those
readers wrote was dropped without a trace. There is one `.setup` now, and
`app/scripts/api-boundary-check.mjs` fails if a second appears.
- **A branch, base or place name that begins with `-` is no longer read as a
flag by the app's commands.** They handed what the app sent straight to the
CLI's argument parsers, so a base of `--no-fetch` turned the fetch off,
`--brief=…` planted a brief the new agent opened on, and relink's place name
`--all` relinked every place, `--force` included — and `worktrees serve`
will put any client's strings there. Such names are now refused, with the
reason, before anything runs. A new branch is held to git's own rule
(`git check-ref-format --branch`), so `feat x` is refused up front and the
new-worktree form comes back with what you typed. Behind that, every value
rides after a `--`, and the git calls that carry one end their own options
too: `switch`'s fetch of a new branch's base would otherwise have RUN a base
of `--upload-pack=<cmd>`. One narrowing: a place whose name contains `..`
(only `--name` can make one) can no longer be opened, switched, closed or
removed from the app — the MCP tools have always refused it.
- **The Claude usage meter no longer puts your Claude login token where other
programs can read it.** To fetch the usage bars, worktrees passed Claude
Code's OAuth access token to `curl` as a command-line argument, and any
Expand Down
26 changes: 18 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# parallel test gate until the migration retires it.
[workspace]
resolver = "2"
members = ["crates/worktrees-core", "crates/worktrees-cli", "app/src-tauri"]
members = ["crates/worktrees-core", "crates/worktrees-api", "crates/worktrees-cli", "app/src-tauri"]

[workspace.package]
version = "0.42.0"
Expand All @@ -24,3 +24,4 @@ toml = { version = "1", default-features = false, features = ["parse", "serde"]
libc = "0.2"
clap = { version = "4", features = ["derive"] }
worktrees-core = { path = "crates/worktrees-core" }
worktrees-api = { path = "crates/worktrees-api" }
Loading
Loading