An Excel or Notepad CSV/TXT export (UTF-16 with a BOM, or Windows-1252) was refused as "File bytes do not match declared type", which reads like a corrupt file. Name the encoding and tell the user to save it as UTF-8.
Two gaps in how a regular user's browser handled the admin-only settings
writes:
- The role comes from GET /api/me, which answers after page load, and on
a failed request admin-ui.js falls back to a single-user admin. A save
made then was sent, came back 403, and still said "Settings saved",
while the refused server-wide change stayed on the device. A 403
FORBIDDEN on a settings write now ends that save like a known
non-admin's: synced keys back to the server's value, the one admin-only
notice, and the page is read-only from then on (_noteSettingsWriteRefusal).
The display-key list moved into one method (_displaySettingKeySet), so
that split no longer depends on GET /api/settings having loaded.
- A regular user's run mode and respawn presets were skipped for the
server but overwritten by the server's on the next page load, while
"Saved preset" said otherwise. They are now kept as that user's own on
the device and put back over the server's after the settings load and
on codeman:me (_rememberOwnSetting, _applyOwnSettings).
Admins and single-user mode are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
docs/multi-user-plan.md lists the system operations as admin-only, but
these routes had no role check, so any signed-in regular user could run
them in multi-user mode:
- POST /api/auth/revoke with an empty body cleared EVERY login session on
the server, every admin's included. A non-admin now revokes only their
own logins, and a named sessionToken only when it is one of theirs.
- POST /api/system/update (update and restart the server for everyone),
POST /api/system/span-displays (a window on the server's desktop),
POST /api/tunnel/qr/regenerate (clears every user's pending QR login
code) and POST /api/cleanup-state (drops every user's stale session
records) now answer 403 FORBIDDEN to a non-admin.
- GET /api/tunnel/info lists only a non-admin's own logins (it showed the
address and browser of every user's sessions).
Admins and single-user mode are unchanged. The tunnel panel's Regenerate
QR button no longer reports success for a refused request.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GET /api/settings returned the whole settings.json to every signed-in user.
An admin's App Settings save stores the Deepgram key there
(voiceSettings.apiKey), so any regular user could read it, and their
browser copied it into its own voice config and dictated on the admin's
account. caseOrder and lastUsedCase also named the admin's cases.
In multi-user mode a non-admin now reads the file without those three
(settingsForNonAdmin). Admins and single-user mode get the file as it
is. Pinned by route tests and the live multi-user test.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- test/routes/system-routes-settings-admin-only.test.ts (app.inject): a
non-admin PUT /api/settings is 403 FORBIDDEN before the body is parsed, and
nothing is written, no watcher toggled, no tunnel started, no history limit
set; the same gate on PUT /api/execution/model-config, PUT /api/config and
PUT /api/cases/order; admins and single-user mode write as before; GET stays
readable.
- test/settings-admin-only-multiuser.test.ts (live server, port 0, real
multi-user auth): settings.json is byte-identical after a non-admin's PUTs,
and a non-admin's quick-start or Ralph loop no longer records lastUsedCase.
- test/settings-admin-only-ui.test.ts (real index.html in JSDOM): a non-admin
App Settings save sends no PUT, keeps per-device keys, keeps synced keys at
the server value, and toasts once only on a real synced change; the
background writes are skipped; admins still PUT; zh-CN for both toasts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
feat(codex): raise codex approval, question and trust dialogs as BLOCKED
Conflict resolved against #634 in src/session.ts _probePaneWorking():
#624's assignment (a dialog on screen is never work) is kept, and #634's
trust-dialog verdict block (log the hold once, remember the reading)
follows it, before _readWatching().
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
feat(shortcuts): rebindable previous/next session, duplicate-chord refusal, rebinds stay out of the PTY
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fix(deepseek): find pnpm when installing the terminal profile, and install it with dsh
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fix(skill): keep the §0 bootstrap intact when Claude Code loads the skill with arguments
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fix(uploads): refuse an unsupported video before uploading it
Conflict resolved against #621 in src/web/public/image-input.js (the
_promptAttachKind doc comment only): #621's comment is kept with #622's
clause that a video MIME counts only for the containers the server
admits; #622's video MIME check and #621's document branch both stay.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On codex-cli 0.162.0, with 0.163.0 released, every launch first shows the
"Update available" prompt, and choosing Skip leads to a variant of the
workspace trust prompt: "2. Back to Agent Command Center" over the key-hint
row "enter continue · esc back". The pattern only accepted cancel,
interrupt, quit, answer or all as the closing word, so a fresh git repo
launched from Codeman sat on that trust prompt reading idle, with no red
alert and no blocked signal (measured live on a throwaway instance).
Adding "back" is safe because the hint row must still START with one of
the anchored hints ("enter continue" here): the popups the user opens at
the keyboard (/model, /permissions, /review, Implement this plan?) end in
"esc back" too, but their row starts with "enter select", so they stay
unmatched, as every existing negative fixture confirms. The new fixture is
the live capture with the scratch path replaced.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The DeepSeek install command asked npm for dsh and pnpm in one go. On a
machine that already has pnpm through corepack (`corepack enable` puts a
pnpm shim in node's own bin directory, which is npm's global bin for nvm
and Homebrew installs), `npm install -g pnpm` refuses to overwrite that
shim with EEXIST, and because both packages were in the same command the
whole install failed: dsh was not installed either. Measured with npm 10
against a scratch prefix holding a corepack-style shim. On master the same
machine installed dsh fine and the profile install used the existing pnpm.
The command is now `npm install -g @deepseek-ai/dsh && (command -v pnpm
>/dev/null || npm install -g pnpm)`, so pnpm is installed only when the
install environment has none, and a failed dsh install still fails the
command. The two hints that embed it follow (the launch-table drift test
requires that), and the catalogue is regenerated; install.sh withholds
the command for a launcher-only CLI, so its block is unchanged.
The test now runs the real stock string through /bin/sh with a fake npm
on a PATH it controls, for the three cases: no pnpm, an existing pnpm,
and a failing dsh install.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Docker rule was applied to the CASE folder and its common git dir
only. A Docker case can be linked to any host folder, including a managed
worktree (`<repo>/.claude/worktrees/<name>`), which is a natural setup for
running one of the parallel agents in a container. Removing that worktree
from the local case's dialog, or through Close Session's "Kill & remove
worktree", ran `git worktree remove`, which runs a git status INSIDE the
tree (container-written files, checkout filters on this machine) and then
deletes the whole tree, ignored files included, while the container still
mounts it. Reproduced on an isolated instance: DELETE answered 200 and the
Docker case's workspace was gone.
That contradicts the invariant the PR itself documents in CLAUDE.md ("git
never runs ... at or inside a Docker workspace"). removeCaseWorktree now
refuses (400) a present worktree that is, sits inside, or holds a Docker
case workspace, before any git runs; the DELETE route passes the Docker
roots it already reads for the case check. A worktree whose folder is gone
is unaffected (git only drops its record there). Unit and route tests
cover both directions and an unrelated Docker workspace; docs updated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The toast every user sees once per release had no zh-CN entries, so a
Chinese interface showed it, button included, in English. The button
gets an entry and the frame a translateDynamic pattern that keeps the
version, the "(+N more)" count and the highlights (those come from
CHANGELOG.md and stay English). Tested through the real i18n.js in JSDOM,
plus a pin on the strings settings-ui.js builds, which the pattern
depends on.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`changeset version` prefixes the first line of every committed changeset
with its commit ("- d18deed: **Lead.** text"), so a changeset that opens
with its bold lead-in, which is the shape the release notes ask for, was
never matched in a changesets-bot release. The pattern now allows that
prefix.
The release notes also put group labels in bold at the start of a line:
"**Fixes.**" and "**Fixes applied while landing.**" (1.41.1, 1.41.0,
1.35.0, 1.33.3). Those were taken as highlights, so a release with few
features announced "... · Fixes applied while landing". They are skipped
like the Thanks block. Tests cover both, and a release with no lead-ins.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The shipped-json test asserted at least one highlight. Many releases are
written without bold lead-ins (1.32.1, 1.29.1 and 1.24.7 here, and every
changesets-bot release whose changesets are plain sentences, this PR's own
changeset included), and the generator handles that on purpose: it warns
and writes an empty list, and the client then stays quiet. Running
`npm run version-packages` in a scratch copy with only this PR's changeset
produced `{"version":"1.42.0","highlights":[]}` and turned the gate red
on exactly the release commit. The version check, which is what keeps a
hand bump from shipping the previous release's notes, stays.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The wiki said .codeman-uploads/ is the one path "a local agent, a container
and a remote host all see the same way", two sentences after saying uploads
are refused for remote (SSH) sessions. Only a local agent and a Docker case
(workspace bind-mounted at the same absolute path) share it, which is why the
route refuses remote sessions; say that.
The same paragraph said every image goes in after a re-encode on the device,
but _normalizeImageForUpload passes a GIF through untouched to keep its
frames.
CLAUDE.md and a test comment described the image fallback as behaving
"as before 1.36", a release that never existed (1.35.0 was followed by
1.40.0). It means the image path as it was before this change.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude Code rewrites a skill body that it loads with arguments: every bare
$1, $2 ... becomes the matching word of the request (0-based). Nine lines of
the agent preamble used bare positional parameters, so an agent that pasted
the §0 bootstrap (every session without a pre-seeded preamble: Agent Skill
injection off, the default, or a remote or Docker session) wrote helpers
that queried session "5" with timeout=claude and looked up "5=" instead of
CODEMAN_PASSWORD= in the .env, sending every call unauthenticated.
- $1/$2 -> ${1}/${2} in preamble.sh and the identical §0 heredoc in
SKILL.md; same bash, and Claude Code's pattern (\$\d+(?!\w)) skips it.
- CODEMAN_PREAMBLE 1.33.4 -> 1.41.2, so a cache file written by the broken
bootstrap (which carries the old stamp) is rewritten on the next run.
- A comment at the top of the preamble states the rule.
- New test: Claude Code 2.1.300's substitution applied to SKILL.md with
sample arguments must leave the body, and the pasted heredoc, unchanged.
It fails on the old file with the exact sessions/5 and timeout=claude lines.
- plugins/codeman re-synced; patch changeset.
Verified with real Claude Code 2.1.300 (the delivered heredoc is now
byte-identical to preamble.sh; before, 9 lines differed) and by running the
pasted helpers against a local recording server.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The dialog's own prompt glyph armed idle confirmation and the quiet dialog passed it, so a fresh case reported idle about 4 s in. An agent that waited for idle and then prompted typed into the dialog, and its Enter confirmed No, exit and killed the pane.
The scan must schedule its own next read rather than wait for PTY output, but a call inside the throttle was simply dropped. The dialog's own paint is often the last output a launching pane produces and lands inside that throttle, so the session sat on the dialog with no keystroke ever sent. A stopped session no longer scans, since the kill's own output kept it answering a dialog in a dead pane.
Claude Code's and Codex's selection dialogs ignore a bracketed paste, so
after the previous commit a pasted `3` + Enter confirmed the highlighted
option instead of option 3. sendInput() now types a single character with
`send-keys -l` as master did (submitsAsKeys()). One character cannot form
the burst Codex reads as a paste, so the Codex fix is unaffected.
The trailing-`;` escape moves into escapeTmuxTrailingSemicolon() and now
also covers every `send-keys -l` call in sendInput() and
sendInputToPane(); tmux 3.5a types `x;` for `send-keys -l 'x\;'`.
Docs: the @fileoverview lines and orchestrator-loop-architecture.md still
described `send-keys -l`; CLAUDE.md, architecture-invariants.md and
cron-guide.md name the single-character exception.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
sendInput() typed the text of a text+Enter pair with `send-keys -l` and
sent Enter 50 ms later. Codex detects an unmarked paste from a fast
character burst and folds an Enter inside that window into the paste as a
newline, so the prompt stays in the composer unsubmitted. Measured with
codex-cli 0.162.1 in a throwaway tmux pane: 1505 chars stuck 3/3 as
`[Pasted Content 1506 chars]`, a 98-char line stuck 1/4. SubmitVerifier
re-presses Enter for the short case, but it cannot match the long one: the
composer shows the placeholder instead of the prompt's head.
The text now goes in via set-buffer + `paste-buffer -p -d` (markers only
when the pane requested bracketed paste), followed by the separate Enter as
before. Same pane, same texts: 4/4 and 3/3 submitted. Each send gets its
own named buffer; a paste that fails (pane or server gone) deletes it,
since tmux never trims named buffers on its own.
Also escapes a trailing `;` in the text, which tmux treats as a command
separator (`send-keys -l 'x;'` typed `x`, a lone `;` typed nothing).
sendInputToPane() has no callers in src/ and is left as it was.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`GET .../wait` answers `blocked` at once for a codex pane showing a dialog,
because a screen-read dialog is a level the server is still reading. The
duplicate branch of `POST .../input` (a tagged retry after a dropped
connection, answered from current state) still used the plain
`currentSignalFor()`, so a retry with `wait: "blocked"` on a pane the first
delivery had already blocked waited out its whole timeout for an edge that
had passed. It now uses `currentSignalForWait()` like the GET route; a
caller that asked only for `idle` still gets `idle`.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
While a codex dialog was up, the session re-read its screen every 2 s with
a synchronous `tmux capture-pane`, for as long as the dialog stayed up. That
read costs about 8 ms of blocked event loop (measured 2026-10-14), and an
approval can sit unanswered overnight: 1,800 execs an hour per waiting
session, measured as 300 in 10 quiet minutes in the unit test.
A dialog leaves the screen only through a repaint, and tmux sends every
repaint to the session's client as PTY output. So while the last reading
saw the dialog and nothing has been drawn since, it is still up and the
read is skipped; a safety-net read still runs every 30 s
(BLOCKED_QUIET_RECHECK_MS). The clear keeps its 2 to 4 s latency, because
the answer itself is output. Never skipped once a reading has missed the
dialog: tmux updates the pane before it sends the repaint, and the
confirming read must not wait for output that may already have arrived.
The session-state tests now change the fake screen the way a real pane
changes (with output), and two new tests pin the skip and the confirming
read (the second fails with the guard removed).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With Codeman Agent Skill on (agentSkillEnabled), every session create
writes the skill into the folder the session runs in, so a session in a
worktree leaves an untracked .claude/skills/codeman/ (claude) or
.agents/skills/codeman/ (codex, gemini and the rest) behind. git then
refuses `git worktree remove` for that worktree forever, and an agent
running `git add -A` there would commit Codeman's skill into its branch
(reproduced: the worktree's status showed "?? .agents/" and
"?? .claude/").
A create now excludes <skills dir>/codeman/ next to the hooks file, for
every skills folder the registry names (capabilities.agentSkillDir,
read at call time by the route and passed in as data, so no CLI id is
branched on). Like the other lines it is only added when git does not
already ignore the path, and only to the repository's local
info/exclude.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When a managed worktree's folder is deleted by hand (a user in a file
manager, an agent's rm -rf), git keeps its record and lists it as
prunable. Codeman then had no way out: the dialog showed the row as
"missing" with no action, DELETE answered "No worktree named ...", and
the name could never be used again (the branch still existed, and with
another branch git refused "a missing but already registered
worktree"). Reproduced on git 2.43; git's own `git worktree remove` on
the missing path drops the record with exit 0.
Removal (only removal, never a session start) now accepts a worktree
whose folder is gone when git lists it at exactly the managed path, as
prunable or locked, and no component on the way is a symlink or a file.
The live-session and lock refusals still apply. The dialog offers
Remove... (not Open) on such a row, with a note saying what it does.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The removal guard and the per-worktree session count compared each
session's workingDir lexically against the worktree paths git lists,
which are real paths. POST /api/sessions stores workingDir as it was
spelled, so a session started in a worktree of a linked case whose
folder is a symlink (a repository on another disk, macOS /tmp, a
symlinked cases folder), named through that link, counted for no
worktree, and DELETE /api/cases/:name/worktrees/:worktree removed the
folder under the running agent (reproduced: 200 instead of 409).
Each session folder under the case path is now respelled onto the
repository's real top level before the comparison (respellUnder, pure
and lexical, so no filesystem call per session).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A codex dialog the session now reads off its screen gets exactly what a
Claude `permission_prompt` hook gets, so every surface that already handles
that event needs no second path: `web/screen-blocked.ts` turns the session's
`blockedChanged` edge into the `blocked` wait signal, an Approvals Inbox
item, the `hook:permission_prompt` broadcast (with `source: 'screen'`), web
push and webhooks, and a run-summary row. That lights the red "needs you"
tab alert and desktop notification, the phone overview's NEEDS YOU row and
the TUI. When the dialog leaves the screen the item it opened is resolved,
and `approval:resolved` clears every client.
It is an ALERT and never a keystroke, following #610's shape for DeepSeek:
the item is `answerInTerminal`, push carries no `approvalId` (so no
Approve/Deny), and the answer route refuses it. The dialog was read from a
screen the agent writes most of, so a forged match must cost a false alert
and nothing more. The message every surface shows is built from the CLI's
registry label, never from the dialog's text. The item gets no `capture`
callback, only its frame as context, because the inbox's option parser and
staleness sweep are shaped for Claude's dialogs and the session owns this
item's lifetime. A CLI whose hooks report blocked gets nothing from the
screen, so declaring both could never alert twice.
The answer route now checks `answerInTerminal` BEFORE its hooks gate and
names the CLI: a codex session has no hooks, and "Session mode cannot have
pending approvals" would be false about the item the caller holds.
Wait signals: `blocked` is available where hooks report it OR a CLI declares
`blockedLine` (`blockedSignalAvailable()`); `stop` stays hooks-only, since
nothing on a screen says a turn ended, so `until=stop` on codex is still a
400 (now suggesting `idle, blocked or exit`). A screen-read dialog is a
level the server keeps reading, so a `GET .../wait` that asks for `blocked`
while one is up answers at once; a caller that asked for `idle` alone keeps
getting `idle`. The MCP and `codeman agent` help text say so.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Codex is the one CLI Codeman launches with its approvals on by default, and
it declares `hooks: 'none'`, so nothing tells Codeman it stopped to ask. A
codex session parked on "Would you like to run the following command?" read
as plainly idle, and one parked on a question read as WORKING, because the
question's own key hints end in `esc to interrupt`, codex's working line.
This adds a generic, optional registry field,
`capabilities.workDetect.blockedLine` (plus `blockedLines`, the window), and
the session state that reads it:
- `isBlockedScreen()` (session-activity.ts, pure) joins the last
`blockedLines` non-blank rows of a capture and tests the pattern once, so a
pattern can demand several markers and anchor the dialog's footer on the
end of the window.
- `Session` reads it off the same capture the idle confirmation already
takes, emits `blockedChanged`, and exposes `screenBlocked`. It raises on
one reading, and while a dialog is up re-reads the screen every 2 s,
because answering resumes the turn and a busy pane would not reach the
settle path again until the turn ended. It clears only after two readable
captures in a row without the dialog, so a capture taken mid-repaint cannot
clear and re-raise it; an unreadable capture changes nothing. A blocked
screen wins over the working line in the same capture.
- The pattern goes through `compileVersionRegex()` in both the schema refine
and at use (lazily cached), like every other config regex.
Codex's pattern was measured on a live codex-cli 0.162.0 pane on 2026-10-14
(`-a on-request -s read-only`, widths 200 down to 36): it needs a `› N.`
option row and then the dialog's key-hint row as the last row, allowing for
that row to wrap below about 60 columns. Command, edit and network-escalation
approvals, single and multi-question dialogs and the startup trust prompt
match; the idle and working views, menus the user opened (`/model`,
`/permissions`, `/review`), the composer holding the dialogs' words and a
transcript quoting them verbatim do not. `Implement this plan?` is left out
on purpose: its footer is the same as `/model`'s and the turn has ended
there. The captures are kept as fixtures (trailing whitespace trimmed, the
scratch path replaced).
Nothing consumes `blockedChanged` yet; the next commit raises the alert.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The worktree API needs a front door that works from the place cases are
already picked. The toolbar case picker gains a third action row,
"Git worktrees…" (the phone case sheet gets a Worktrees button next to
Create New Case; no header button anywhere), opening one dialog per case:
- the repository's worktrees, with the managed ones (.claude/worktrees)
offering Open (start another session in it) and Remove…, an inline
confirmation with "Also delete its branch" (git branch -d) whose git
refusal is shown verbatim through textContent;
- a New worktree form (name, optional branch and start point) and a
"Start sessions with" picker over every enabled, installed run mode,
built from the registry catalogue as data, never an id branch.
Launching goes through run() with options.worktree and the same temporary
_runMode swap the custom-model entries use, so every mode keeps its own
path: claude and shell create through POST /api/sessions with the
worktree as workingDir, every other CLI through quick-start's worktree
field. Tabs are named w<n>-<worktree> and the Run button's mode is left
as it was.
Close Session offers "Kill & remove worktree" for a session that runs in
a Codeman-managed worktree; the removal waits for the server to confirm
the session is gone (closeSession now resolves true/false), and a dirty
tree keeps the worktree with git's reason in a toast that opens the
dialog. zh-CN strings and toast patterns for all of it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sessions in one case share its folder, so several agents on one repository
edit, build and commit in the same checkout and step on each other. A case
whose folder is a git repository can now keep worktrees under
<repo>/.claude/worktrees/<name> on a new branch (default worktree-<name>),
which is Claude Code's own --worktree layout (measured in Claude Code
2.1.296) and the one Codeman's worktree pill already recognizes.
New module src/git-worktrees.ts (pure name/branch gates, worktree list
parsing, exclude text; IO through an injectable runner) and routes
GET/POST /api/cases/:name/worktrees and
DELETE /api/cases/:name/worktrees/:worktree:
- git runs as argv with -- before paths, non-interactive env, process
group timeouts, --no-optional-locks on reads; names are gated before
git and confirmed with git check-ref-format --branch.
- Only <repo>/.claude/worktrees/<valid-name> is created or removed; every
path component is lstat'ed and realpath-checked, so a symlinked .claude
cannot redirect a create or remove outside the repository.
- /.claude/worktrees/ (and the per-workspace hooks file Codeman writes,
which otherwise blocks git worktree remove) go to the repository's
local info/exclude, never a tracked .gitignore.
- Removal is git worktree remove WITHOUT --force, refused while a live
session runs inside; branch delete is git branch -d, never -D.
- Docker and remote cases are refused with a 400, and git never runs for
a case at or inside a Docker case workspace.
Starting a session reuses the existing create paths: quick-start takes
a worktree name (validated, local cases only) and runs hooks, the skill,
the model override and the session itself in the worktree;
POST /api/sessions takes the worktree as workingDir. The unified session
list names the worktree of a live session from its folder layout, so a
codex or shell session in one gets the worktree pill too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
last_miss_cause.causes was z.array(z.unknown()).max(20), so a statusline
payload carrying more than 20 causes failed validation and lost the
whole post: plan usage, model, cache and the footer with it. Truncate
to the first 20 with a preprocess that slices before the array
validates, so an over-long array costs one bounded slice rather than
per-element work across every entry.
The status-telemetry route gated the model report on
capabilities.statusLineTelemetry but stored the prompt-cache report for
any session mode, even though both ride the same statusline payload the
exporter POSTs. Gate the prompt-cache store on the same capability so a
CLI with no statusline exporter no longer carries prompt-cache state it
never actually reported.
_promptAttachKind returned 'video' for any video/* MIME, so an Android
3gp or an mkv streamed up to 1GB before the ?kind=video route refused it
with a 400. Mirror the server's own video-container set so those are
filtered client-side and get the existing unsupported-file toast at once.
Follows the maintainer's follow-up suggestion on #606.
The Attach button, the Compose and paste-dialog pickers, terminal drop and the
clipboard now take documents beside images and videos: the office formats
pdf/docx/pptx/xlsx/zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml.
A document is admitted by its name's extension (the MIME a phone attaches to a
.md is anyone's guess), buffered under the same 50MB image cap, and judged on
its bytes by the same fail-closed sniffer: a magic-number signature for the
binary formats, UTF-8 with no NUL byte for text. It is saved under a sanitized
stem of its name, so Q3 report.pdf is findable as paste-<ts>-<hex>-Q3-report.pdf.
sh/py/js/html are refused by name with a clear 400. That is a scope choice, not
a security property: file-raw already serves non-media text inert, so this keeps
the server from storing what it would serve or an agent could run, and html and
svg are the ones deliberately kept out. One client classifier (_promptAttachKind)
now returns image, video or document, and the server's dispatch mirrors it: an
image MIME wins whatever the name says, a document extension takes the document
branch, and a foreign extension with no image MIME is refused only after the
image sniff fails, so nothing the image path accepted by its bytes is lost.
The root CLAUDE.md had grown to 202 KB, all of it loaded into every
session. This lands the split drafted on 2026-10-10 (it sat uncommitted
in the shared checkout through two releases), redone on top of today's
master so nothing that landed since is lost:
- src/web/CLAUDE.md: the frontend invariants (every src/web/public/ UI
subsystem, the module notes, the XLSX preview limits, the z-index
stack). Claude Code loads it when a file under src/web/ is read; the
root file tells sessions to Read it before editing src/web/public/.
Its link targets are relative to the file so they resolve on GitHub.
- docs/releasing.md: the COM procedure (now nine steps, with the MCP
registry publish), behind a pointer in the root COM section. The
Version line stays in the root file.
- Root CLAUDE.md (130 KB): everything else, plus the split's trims: the
Core Files table, the reference-only rows of the Additional Commands
table, Data Flow and the respawn presets, whose rules already live in
the root's other paragraphs (the "Read before touching a subsystem"
line keeps the table's doc pointers).
Every other paragraph of master's CLAUDE.md lands in exactly one of the
three files, checked line by line; the paragraphs master changed since
the draft (tile grid, OSC 52, prompt uploads, file-path links and the
HTML view, markdown preview, z-index, MCP server, agent CLI and others)
are in their current form. Both new files join CLAUDE.md in
.prettierignore. Four code comments and docs/architecture-invariants.md
now point at src/web/CLAUDE.md for the moved sections.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- isUserBoundShortcutEvent skips toggleSessionSidebar (it has its own gate) and an
entry re-captured onto its own default, so ^L still reaches the CLI.
- Re-enabling a shortcut and Reset run the duplicate check and refuse when the
chord was taken meanwhile; Ctrl/Shift+Enter and Ctrl/Cmd+V are reserved with
Alt+1-9, and the Alt-digit check no longer excludes Cmd.
- Alt+[ / Alt+] keep their Alt/Option+[ labels via displayBindings.
- Behavioural tests (vm harness) replace the source-string test.
- Docs: wiki Rebinding / Why-chords-behave-oddly, architecture-invariants note.
- Small: _shortcutActions indentation, stale Alt-block comment, typeof check
instead of Object.hasOwn, full-width parentheses in zh-CN.
_dockForeignTile posted close-request only when this window knew the session
was popped out. A pop-out opened from another dashboard window was not in this
window's detachedSessions, so it stayed open beside the newly docked tile and
two views sized one PTY. The request now goes out for every docked id;
dashboards ignore it and only that session's pop-out reacts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
_onTileAdoptedElsewhere acted only on a tear-off, a pending tear-off or a drag
that just ended. A header drag carries the tile id even when this window has
Detach Tiles off (no tear-off), so another window could dock the tile and its
claim could arrive before this drag ended; the tile then stayed here as well.
The header drag (_draggedTileId) now counts as dragging too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There is one stored grid per browser (codeman:tile-grid), and the window that
docked the tile has just written it. When this window then hears the claim and
drops the tile, removeTile (or, for its last tile, closeTileGrid with
keepStored: false) overwrote or forgot that stored grid. The removal now runs
under _tilePersistHold, and the last-tile close keeps the stored grid.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>