Claude Code's EnterWorktree moves the running process into
<repo>/.claude/worktrees/<name>, while Session.workingDir stays on the main
checkout. The Files panel kept walking workingDir, so it listed a tree the
agent had left.
paneCwd already follows the move, measured on Claude Code 2.1.318 in both
directions, so nothing new has to track the agent. It also follows every cd
in a shell, so a new SessionState.liveWorktree narrows it to a linked
worktree, read from the .git file without starting git.
Only a folder strictly below workingDir is ever an answer, and the listing
only starts there: returned paths stay relative to workingDir, so the
preview, raw and edit routes are unchanged and confinement does not widen.
A worktree outside the session folder stays unfollowed.
The panel asks with root=live, shows a strip naming the worktree with a
switch to the session folder, and follows the agent in and out while open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#689's density steps hide every `.header-system-stats .stat-label` once the
toolbar is narrowed, and #692's readings (GPU, VRAM, GPU MHz, CPU MHz) have
nothing else naming them: at tight, dense and tiny the header showed "40%"
beside CPU's "12%", "3.2G" beside MEM's "7.9G", and two bare "1800 MHz" /
"4200 MHz" with no way to tell the GPU clock from the CPU one. At Compact +
tiny, where values are hidden too, the two clocks (no ring) were left as
0-px items that still took an 8 px flex gap each. Neither PR shows it alone.
- The four host items carry `stat-item--host`, excluded from the density
label hide, so they keep their labels at every step.
- `stat-item--ringless` (static on the two clocks, toggled by
`_updateHostSensors` on VRAM while its total, and so its ring, is unknown)
is hidden at Compact + tiny instead of staying an empty item.
Tests: the browser test drives every density in all three styles against
the real markup and styles.css (it fails on the previous files), and the
unit test pins the VRAM ringless toggle.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflict in config/test-suites.ts resolved by keeping both sides: #689's
header-divider and #692's header-host-sensors browser tests are both
listed in BROWSER_TEST_GLOBS.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The new-session row's agents now go through _runModeAvailableForCase (session-ui.js), the rule extracted from _refreshRunModeAvailability, for the case the row launches in: a Docker case offers what its container has (live probe, else availableModes, else ungated), so a container-only Run mode is offered and is the row's default again.
The default is resolved per render (pick, else the Run button's mode, else the first offered), so a query that moves the row to another case starts on the Run mode when that case offers it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Keep the Run button's mode when fewer than two agents are offered (_commandPaletteAgent), so a Docker case whose host offers only Shell still starts the container agent the Run button shows; pinned by a new palette test.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
palette agent switch: aria-hidden on .command-palette-agent, arrows lose role="button" and aria-label (no nested buttons in the row)
i18n: zh-CN entry for the "Agent for the new session (Left/Right to change)" tooltip
docs: Keyboard-Shortcuts Ctrl+K row says Left/Right change the agent on the New session row
test: pin the aria-hidden switch and the zh-CN tooltip entry in command-palette-ui.test.ts
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The palette's "New session" row now shows the agent it will start and
lets Left/Right (or the row's arrows) switch it. The agents come from the
injected CLI catalog, filtered to enabled and installed entries, so no CLI
id list lives in the palette. The pick is a one-shot launch through run()
with a temporary _runMode swap: the Run button's mode is left unchanged.
Merge-time fix for #659.
The Expand button swapped its title and aria-label to "Restore size"
while on, so a screen reader announced "Restore size, pressed". The
label now stays "Expand" and aria-pressed alone carries the state, the
way the MD, Lines and Wrap toggles work.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Under 600px the window stays full screen whatever Expand or a drag says, and both
controls are hidden. Stepping onto a folder-grid entry goes through the grid so its
index follows. Alt+left/right is swallowed at the ends of the list too. Adds tests
for the key handler, the grip clamp and the history/grid case, zh-CN titles and wiki lines.
- A closing notice card stays in the DOM for its 200 ms fade, and its
link and "See what's new" buttons still worked there: a double click
opened the notice twice. close() now makes the card inert and every
button handler does nothing once it is closing.
- Notice links (and the combined card's "See what's new") open with
noopener,noreferrer. noopener alone still sent this instance's origin,
which can be a tailnet name or a tunnel URL, as the Referer. The plain
update toast is unchanged.
- The https rule is checked again at the button
(CodemanSecurityNotices.urlOk): a caller handing showSecurityNotice or
showUpdateNotice an unchecked URL gets no link button.
- A detached session window (/session/:id) is rendered without
__codemanCliAvailable, so it could never select the OpenCode notice,
yet it took the update toast and recorded the version, and the
dashboard then showed the notice as a separate card. While any notice
is pending, a solo window now shows and records nothing and leaves
both to the dashboard; with none pending it shows the plain toast as
before.
- The wiring tests pin the page's own clock (JSDOM gives the window its
own Date). On the real clock 18 of them would have failed on every push
from 2027-01-01, when the fixture notice expires. New tests pin the
once-only close directly (the old double-click comment claimed more
than it checked), the single open, the sink check, the solo windows,
the scroll cap and the light-skin ink.
- CLAUDE.md now says the public .json files are served no-cache, the
exception to the year-long immutable cache.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A hand-maintained src/web/public/security-notices.json carries warnings
about things outside Codeman that its users should act on. The first:
OpenCode's report of unauthorized access to its legacy Console, which
may have exposed provider API keys saved there.
- Pure core in constants.js (CodemanSecurityNotices): select() keeps the
notices that apply now (inclusive local-day dates, not yet seen, https
links, malformed entries skipped), and one naming a CLI only when
window.__codemanCliAvailable[cli] === true. Unknown means not
installed: a warning about software you do not have is noise. The CLI
id is data in the JSON; no code branches on it. plan() decides the
surface, so a load shows each notice once.
- An existing install on a new version gets the notice INSIDE the
release's update toast: the highlights as before, a warning section,
and the notice's link next to "See what's new". With no notice the
update toast is the plain one it always was.
- Everyone else (a fresh install, a same-version reload) gets its own
card in the toast stack: shield, "Security notice" eyebrow, title,
message, the link, Dismiss and the ×; amber-to-red accent bar, theme
tokens only (dark and light skins), full width on phones, an
opacity/transform entrance and none under prefers-reduced-motion,
role=alert.
- A notice is recorded as seen only when the user closes it, dismisses it
or follows its link, never when it appears. Storage blocked shows
nothing. The notice's own text is textContent and carries its own
zh-CN, so i18n.js leaves it alone; the card's own copy translates.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review of 8659ba25..be987fbb:
- Usage: a Free account answers premium_interactions {entitlement: 0, remaining: 0,
percent_remaining: 0, unlimited: false, has_quota: false}, which read as 100% used. The parser now
returns null when has_quota is false or the entitlement is not positive, with that payload as a
fixture. Free meters chat (200) and completions (2000) too, so the comment no longer says they
are unlimited on every plan.
- agent read: rows were skipped until a `❯ <prompt>` row, so an answer taller than the pane
(prompt scrolled off the top) read as "". With no prompt row AND no banner / trust dialog on
screen, the rows above the composer are now the tail of the last answer. Text typed in the
composer (a prompt-shaped row between the two rules) is not a prompt row.
- Page keys: measured on 1.0.95 in tmux that Copilot pages on PageUp/PageDown (three PageUp moved
lines 109-140 to 010-042, PageDown returned) as well as on SGR wheel reports, so copilot joins
claude and opencode in _localScrollbackIsHollow(); the stale-tracking-flag fallback the comment
and docs describe is now true, for the main pane and a TerminalTile (same function), with tests.
- X-GitHub-Api-Version is 2022-11-28, a version GitHub supports (verified against the live
endpoint); readPlanUsageTelemetryEnabled() is checked before isCopilotAvailable() so a switched-off
chip skips the resolver probe too.
- Palette label for Copilot in panels-ui.js; stale comments in types/session.ts and stock.ts.
- Docs: Past Sessions continues with --continue (resumeSessionId is for API callers), nothing sets
continueSession on a respawn, and only a tab name the user set is passed as --name; the usage
wording for Free; the wheel and mouse-strip paragraphs in CLAUDE.md and
architecture-invariants; the changeset describes the whole feature.
Conflicts resolved against #604, #606 and #601:
- constants.js: the rooted link pattern takes html-md's `markdown` and
`html?` with #606's m4v/ogv; #604's relative pattern gets the same list
(link-provider-regex pins the two equal); #604's findFilePathLinks /
folder helpers and html-md's rendersAsPage() are both kept, and
window.CodemanFilePaths exports all of them.
- terminal-ui.js: #604's resolved `target` (relative, ~/ and folder links)
now also goes through rendersAsPage(), so every link kind routes .html to
the page preview; .md routes there through FILE_PREVIEW_EXTENSIONS.
- CLAUDE.md, api-reference, architecture-invariants: both sides' text kept.
- test/folder-grid-ui.test.ts (#604) stubs rendersAsPage() like the other
panels-ui harnesses.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
What counts as an HTML page lived in three places: HTML_VIEW_EXTENSIONS
on the server, a /\.html?$/ regex in the terminal link provider and a
HTML_PAGE_EXTS set in panels-ui.js. The two frontend copies are now one
rendersAsPage() next to previewsInFileViewer() in constants.js, used by
the link provider (and through it every grid tile and the split's Pane
B, which share registerFilePathLinkProvider) and by openFilePreview()
and its toolbar. A new test pins it equal to the server's list.
toggleFilePreviewPage() now returns the preview load, so a caller (and
the coming frontend test) can await the flip.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Conflicts resolved:
- CLAUDE.md: kept master's Prompt uploads paragraph and its longer
File-path links paragraph (XLSX admission limits), and inserted the
PR's html-view sentence after the download-only rule.
- src/web/public/terminal-ui.js: kept master's tile-aware
getSessionId() in the shared file-path link provider and added the
PR's .html routing to the file preview on top of it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Multi-user: a non-admin's folder must resolve inside their own userSpacePath
or the session's workingDir (folderAllowedForUser, the path picker's rule),
checked on the resolved folder before it is read and again on each served
file after symlink resolution; 403 otherwise.
- listFolder probes the local folder with probePathKind({ pastCap: true })
first (unknown -> 503 via describeUnknownPath, never 404), then async
fs.realpath; resolveFolderFile uses probePathKind instead of fs.stat.
- The guard runs on the realpath before readdir.
- FOLDER_PATH_LINK_PATTERN ends a folder at ':' (quadratic `://` rescan on
colon runs; PATH=/a:/b linked as one folder).
- Load counters drop stale preview / folder-grid responses; closing the grid
invalidates a listing still in flight.
- validFolderFileName refuses a leading dot; the remote listing matches
extensions in any case.
- Docs: folder grid in the z-index lists; multi-user rule and bounded probe in
the invariants and security docs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Folder paths in the terminal and the response viewer are now links
(rooted or ~/ without an extension, relative with a trailing slash); a
click opens a grid of the folder's supported files, newest first, with
subfolder chips and a parent button. A tile opens the file preview,
which pages through the folder with ‹ › / ←/→; the preview of any file
gets a "Show folder" button.
Server: POST /api/sessions/:id/folders grants + lists a folder (local
fs or one ssh round trip for a remote case) under the attachment guard;
GET …/folders/:folderId/files/:name/(raw|preview|thumbnail) serves one
file by bare name through resolveServableAttachmentPath, so browser
requests still never carry a path. Grants live in their own small
registry so a big folder cannot evict the session's attachments. The
attachment raw route's body is now the shared serveServableRaw helper.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Terminal and response-viewer links only knew rooted absolute paths. Agent
output is full of the other forms: Claude Code echoes an attached image as
`~/repos/x/garage.png`, scripts print `builds/captures/x.png`, and Claude
Code wraps tool output a few columns short of the edge, so a long path
arrives as `.../captures/2` + `026-10-10/x.png` and only its prefix linked.
- constants.js: RELATIVE_FILE_PATH_LINK_PATTERN + findFilePathLinks() (one
entry point for both consumers; never inside an http(s) URL),
resolveLinkedFilePath() (relative -> session workingDir, ~/ as is),
terminalPathLine() (glues a path a program broke over lines; guarded so
ordinary short lines are never joined).
- terminal-ui.js: provider uses them, no overlapping links, opens the
resolved path. Touch selection keeps terminalLogicalLine.
- app.js: response viewer links the same forms; only its own links are
resolved, a markdown document's rebased links stay as they were.
- Server: registerExternalAttachment accepts ~/ (local homedir(); remote
probe expands $HOME on the host). Guards run on the resolved path.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Refuse to mint for an HTML file in a hidden directory or directly in a
broad root (/, home, tmpdir, cases and user-space roots): the capability
serves the directory tree recursively to the page's own scripts.
- Keep HTML's source view and editor: a Page pill (per-device htmlRendered,
like MD) toggles to the file-content text path, and a refused mint (remote
case, broad root, hidden dir) falls through to it automatically.
- Tests: html-view auth exemption edges, revocation on logout / admin logout /
user deletion, out-of-workspace mint (blocked tree, confinement), hidden
and broad roots.
- Docs: Working-With-Files, architecture-invariants, security-architecture,
the file-routes comment and CLAUDE.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Clicking an agent's `.html` path showed source text or downloaded it, so a
generated report could only be viewed by asking the agent to start a web
server. Render it instead, without serving HTML same-origin:
- POST /api/sessions/:id/html-view mints a capability for the file's own
directory (workspace path, or outside it under the attachment guard).
- GET /html-view/:cap/* serves the page and its relative assets with
`Content-Security-Policy: sandbox` WITHOUT allow-same-origin, so the page
runs in an opaque origin (also as a top-level tab) and cannot read the
Codeman document, its cookies or its API.
- Served files: non-dot, asset-allowlisted, realpath-confined to that
directory, blocklist applied; GET only. Capabilities are memory-only with a
rolling TTL and are revoked with the web-tab ones on logout.
- Frontend: the file preview renders HTML in a sandboxed iframe (popout opens
the same URL); terminal `.html` links go to the preview, not the log viewer.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Compact now draws every reading the same way, ring then label then
value: CPU and MEM get an accent ring (red past 80%, like their value)
in place of the sparklines, the plan windows keep their green, yellow
or red rings, the dot separators go, and both pills share one label and
one value style. The sparkline markup, history and CSS are removed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two directions from Discussion #426, each a per-device setting and the
new default.
Tab Grouping (tabGrouping: 'state' | 'none', default 'state', option C):
tabs are grouped needs you (red, plus failed sessions), waiting (yellow),
working and idle (also ended, exited panes and web tabs), most urgent on
top. The desktop header strip draws a row per group with its label and
count in a left gutter; the flat vertical rail and the sidebar draw a
section per group; tablets keep their scrolling row with inline dividers;
phones keep the chip row in group order without headings. Classification
is the home screens' own (_mobileOverviewState/_mobileOverviewExit), the
fold into four groups is pure in CodemanTabTriage (constants.js). It is
flex `order` plus aria-hidden heading/break elements reconciled in place
after both render paths, never a DOM reorder, so Alt+N, the keyboard walk
and drag keep reading tab order; a drop is refused across groups. Named
groups in the vertical rail take precedence.
Header Stats Style (headerStatsStyle: 'classic' | 'compact' | 'tiles',
default 'tiles', option G): tiles give WS, CPU, MEM and each plan window
a label-over-value tile with a bar underneath; compact is one WS/CPU/MEM
pill with sparklines plus a plan-ring pill; classic is the header as
before. Desktop only (classic below 768px and in solo windows). The
clustered styles move #connectionIndicator into #headerSystemStats and
WS stays out of a hidden System Stats pill. The extra parts are always
rendered and hidden by default in CSS, so classic is unchanged.
Tests: test/tab-triage.test.ts, test/header-stats-style.test.ts; three
source pins in test/tab-rail-order.test.ts follow renamed lines.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A markdown preview opened by attachment id under a bare file name
(attachment cards, history drawer) no longer resolves relative refs
against the workspace root: filePreviewText carries attachmentId, and
the rebase pass turns those images into their alt text and unwraps
those links. Absolute-path and workspace previews are unchanged.
- _renderMarkdown(text, { breaks = true } = {}): the File Viewer passes
breaks: false, so a hard-wrapped paragraph renders as one paragraph;
the Response Viewer keeps a <br> per newline.
- Absolute paths linkified inside a rendered document now carry the
preview's data-session-id.
- CLAUDE.md, architecture-invariants and the Working-With-Files wiki page
now say that only an in-workspace path clicked in the terminal keeps
the tail viewer.
- Tests in test/file-preview-markdown.test.ts for all three fixes,
including an end-to-end run of the shipping app.js + marked + DOMPurify.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- _restoreOverlayFocus(key, modal) now leaves focus alone when something
outside the overlay already holds it (not <body>, not inside the modal).
The Session Manager's "Switch to session" and "Open folder" call
selectSession() before closeSessionManager(), and the restore was pulling
focus back from the terminal to the header button. Both close methods pass
their modal; a regression test drives that order.
- Test harness: focusHarness() routes getElementById through a local binding
instead of leaking globalThis.__els, and its modal stubs report their own
search box as contained, as the real DOM does.
- CLAUDE.md and docs/architecture-invariants.md: record that the global
Escape handler calls every close method on every Escape (capture phase),
so a close method with side effects must return early when not open.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review feedback. The global Escape handler in app.js calls both
`closeSessionManager()` and `closeCommandPalette()` on every Escape, whether or
not either overlay is open, in the capture phase. Nothing was saved in that
case, so `_restoreOverlayFocus()` fell through to `terminal.focus()` and moved
focus before the focused element's own Escape handler ran:
- split view: with focus in Pane B, keys typed after Escape went to Pane A
- any text field (File Viewer editor, search and history filters, case picker):
keys typed after Escape went into the terminal
- inline tab rename: the capture-phase focus fired the input's blur (which
commits) before its own Escape handler (which cancels), so Escape committed
the rename instead of cancelling it
Both close methods now bail out on `classList.contains('active')`.
Separately, gating the terminal fallback on `activeSessionId` alone only covered
the welcome screen. On a touch device with the keyboard down, focus sits on
`<body>`, so closing the Session Manager focused the terminal and brought the
keyboard up — `selectSession()` deliberately skips that focus, and this
overrode it. It now goes through `_shouldFocusTerminalForTabSwitch()`.
Tests: the Session Manager case's modal stub now uses the harness's
`makeClassList()` (without `contains` the new guard reads it as "not open" and
skips the restore the case is about), plus two new cases — closing either
overlay without opening it first with an active session asserts the terminal was
not focused, which is the path the global Escape chain takes and none of the
five existing cases covered, and a touch device with the keyboard down asserts
the same. Each was checked against the unguarded code: removing either guard
turns exactly its own case red.
Both the Command Palette and the Session Manager call `search.focus()` on
open, and both closed by removing the `active` class and nothing else. Hiding
a focused input does not hand focus back to anyone — the browser drops it on
`<body>` — so after Escape closed the overlay every keystroke went nowhere and
the user had to click the terminal before they could type again.
Measured in headless chromium against a real shell session, one overlay at a
time:
overlay activeElement after Esc can type afterwards
App Settings XTERM yes
Session Options XTERM yes
Token Stats XTERM yes
Monitor Panel XTERM yes
Session Manager BODY no <- fixed here
Command Palette BODY no <- fixed here
The four that worked did so because they use `FocusTrap`, whose `deactivate()`
restores focus to whatever held it before. These two never got one. Every close
path has the same hole — Escape, the close method, picking an item — so the
restore lives in the close functions rather than in the global Escape chain.
Deliberately only the save/restore half of `FocusTrap`, not the whole thing:
`FocusTrap.activate()` moves focus to the first focusable element, which in
neither overlay is the search box, so adopting it wholesale would trade "type a
filter the moment it opens" for "focus survives the close" — and the former is
the reason Cmd+K exists. The terminal fallback is gated on there being an
active session: an overlay opened from the welcome screen has no terminal to
return to, and focusing one on a phone summons the on-screen keyboard over a
screen with no input on it.
The five new cases were checked against the unfixed code first: four of them
fail without this change.
Review follow-up on #503. marked percent-encodes link and image destinations, and the rebase pass encoded them a second time, so a space or a CJK character in a file name made file-raw look for a file literally named my%20image.png; refs are now decoded once (a malformed escape is kept as written) and stripped of ?query along with #fragment. Root-relative refs resolve from the workspace root as on GitHub instead of falling through as Codeman URLs. Rebased links carry the preview's own session id and the response-viewer delegate prefers it, so a document opened from another session's attachment card opens its links in that workspace rather than the active tab's.
The sanitizer no longer allows name=: marked never emits it, and <img name="app"> made document.app that image, which every inline onclick="app.…()" handler resolves before the global, so one rendered README broke every viewer button until a reload. Adds the zh-CN strings for the three toolbar titles.
Clicking a .md in the Files panel showed wrapped source with an Edit
pencil and no way to see it rendered, although marked + DOMPurify were
already on the page for the Response Viewer. The viewer now renders
.md/.markdown through that same pipeline (one parser, one click
delegate) with an MD pill back to source, and the plain-text view gains
Lines (CSS-counter gutter) and Wrap toggles. All three persist per device
in their own localStorage keys.
- Relative images are rebased onto the workspace-confined file-raw route
under the document's directory, built inside a <template> so no fetch
fires before the rewrite; a failed load degrades to alt text. Relative
links become a.rv-path so the existing delegate opens them in the
viewer; fragment and http(s) links are untouched.
- The rendered container carries data-i18n-skip so the translator does
not rewrite the document's prose.
- Markdown fetches the route's 10000-line ceiling; other text keeps 500.
- avif renders inline (file-content image set, file-raw MIME map), and
avif/ico printed paths open the viewer instead of tailing bytes. .md
deliberately stays with the tail viewer for printed paths.
- Normalize the value shapes ExcelJS loads before formatting: Date cells are
formatted from their serial (UTC), so they no longer render as a local-time
string a day early at negative UTC offsets; rich text joins its runs,
hyperlinks show their text, error values show the error, and formula and
shared-formula results (including error results) recurse. Excel serials are
rounded to whole milliseconds so 00:05 no longer shows as 00:04.
- sendTile() skips hidden rows and columns, and at the 2500-cell cap returns a
truncated tile with a warning instead of failing the whole preview.
- ExcelJS now parses a STORE-only archive rebuilt from exactly the entries
admitXlsx() inflated and counted, never the fetched bytes. Admission walks
local headers while JSZip reads the central directory, so overlapping
entries could show the two readers different sheets. A duplicate local
entry name is refused. The theme fallback reads the admitted entry too.
- Row and column headings take their size from the same axis math as cells.
- Document the admission, worker-only loading and SPREADSHEET_ASSET_VERSION
rules in architecture-invariants, and list .xlsx in the attachments panel
help and the `codeman attach` error text (built from the accepted list).
xlsx files were download-only. Add a read-only, virtualized preview (sheet
tabs, number formats, merges, theme colours) parsed entirely in a browser
Web Worker with exceljs and fflate, loaded only when a spreadsheet is
opened. The workbook is checked against ZIP-bomb, entry and cell limits
before exceljs loads; cell text is written with textContent, formulas are
never evaluated and nothing referenced by the workbook is fetched. On the
server xlsx only joins the existing allowlist and classification, with a
10 MB cap on ?preview=true. xls and ods stay download-only.
_loadSessionManagerList() re-projects each unified item into the
history-record shape _buildHistoryItem renders, and dropped these three
fields. The row's own onActivate still read them from the unified item, but
everything built from the record did not: the ⋯ menu's "Resume session"
relaunched a codex row as claude (no mode, no resumeId), a resumed session
lost its conversation id, and Cmd+K rows showed no mode badge. Same class
of bug as the worktree fields the re-projection already carries (#266).
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Issue #464, "text gets muffled sometimes, in both TUI default and fullscreen".
The screenshot is not a dropped frame or a frozen renderer — it is arithmetic.
Claude Code's TUI wraps its frame at the width the PTY reported and erases the
previous frame by walking the cursor up the rows it believes that frame took. A
browser terminal of a different width makes each logical line occupy more
physical rows than Ink counted, so `eraseLines(n)` clears too few and the new
frame paints over rows nothing erased: doubled lines, and short tool summaries
sitting inside longer prose rows with the prose's tail still visible.
Reproduced against this repo's own xterm before changing anything — a 120-column
PTY against a 62-column terminal renders every wrapped line twice. `test/
terminal-pty-geometry.test.ts` pins that, and pins the clean render at matching
widths beside it, so the assertion cannot be satisfied by code that fixes
nothing.
Four ways the two drifted apart, none of them observable from either end:
1. `fitAddon.fit()` resizes xterm to `proposeDimensions()` RAW while every
server-facing path reported those floored at 40x10. Measured in Chrome at
430px: font size 44 proposed 13 columns, the server was told 40, and xterm
stayed at 13. Three call sites each did their own fit-then-floor, and two
re-read the proposal after the fit — `_shrinkPaddingToFit()` runs exactly
there, so the container had moved.
2. `throttledResize` (keyboard up) and `sendResize` (session detached into its
own window) reflowed locally and withheld only the SIGWINCH. That is the one
combination that cannot be right: a reflow nothing is rendering for buys
nothing and costs correctness. Both now withhold everything, and the
keyboard's settle timer still sends the one resize that stops the PTY going
stale.
3. `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size — a geometry
change — and told the server nothing at all, so raising the font on a phone
left the CLI wrapping at the old column count.
4. `Session.resize` DECLINES a small-viewport request while a desktop connection
holds an active sizing claim, and said nothing, because resize was write-only.
`syncTerminalGeometry()` is now the one function that may change the terminal's
size: it fits, floors and applies as a single step, so the numbers xterm holds
are the numbers the server is told. A test sweeps every module for a bare
`fit()` on the main terminal, and finds exactly one — the owner's own.
For (4) the client cannot win, so it is told the truth instead: both transports
answer a resize with `session.ptyCols`/`ptyRows` (`{"t":"zc"}` on the socket,
the body of the resize POST) and `_onPtyGeometryReport` adopts them. A terminal
that keeps a shape the PTY refused does not render "too narrow", it renders
garbled. Adopting can leave the pane wider than the screen and the container is
`overflow: hidden`, so `.pty-oversized` grants horizontal reach for exactly as
long as the mismatch lasts: correct-and-reachable beats correct-and-clipped
beats garbled. That rule sets both overflow axes and its own `touch-action`
because mobile.css loads later and sets `.terminal-container { overflow:
visible; touch-action: none }` — a bare `overflow-x` would leave overflow-y
computing to `auto` and hand the browser a vertical scroll container the
terminal's touch handler knows nothing about.
Verified in Chrome at 430px against a live server, with a desktop client holding
the claim: the phone adopts 198x43, gets `overflow-x: auto` / `overflow-y:
hidden` / `touch-action: pan-x`, 758px of reach to the right, and keeps its own
vertical scrolling. The pre-fix build was measured in the same harness for the
control.
Two things this deliberately does not do. It does not change who owns the pane
size — the desktop still wins, and `_startMobileResizeRetry` still takes it back
once that goes idle. And `throttledResize` still holds the PTY's shape for the
whole keyboard animation rather than sending a SIGWINCH per step; that decision
predates this and was not re-tested here.
Also in this commit, Ark0N's third-pass review items on #431:
- The response viewer's byte-buffer fallback and `_onSessionClearTerminal` both
used the no-param `/terminal` form, capped only by `terminalBufferMaxBytes`
(32MB) — the largest body the frontend asks for anywhere. One carried no
deadline at all and the other got the 15s tail budget. Both now take the
full-history budget.
- A `?full=1` capture that outruns its deadline falls back to the bounded tail.
The pane is blanked before that fetch, so an abort used to leave a black
rectangle, discard the queued live output and never reach `_connectWs`. A
failed load now still opens the socket, says one dim line where the content
would have been, and clears the tab's spinner — which nothing did, so a failed
select left `aria-busy="true"` set forever.
- `_wsOutputGapSession` is cleared at the repaint that settles it, not in a
`finally` that also ran on the catch. A reconcile that threw, or hit the new
deadline — the flaky link the marker exists for — dropped the gap with nothing
to retry it. `ws.onopen` no longer clears it up front either.
- The replay-clear invariant is pinned in the gate, which is the drift this PR
exists to fix: `_resetTerminalForReplay` must be a queued write and nothing
else, and no module may blank the terminal with a `clear()+reset()` pair.
- `DIAG_ENTRY_MAX_CHARS` replaces the hardcoded 300, bound through a local
first: `CodemanDiag?.x` still throws a ReferenceError when the identifier was
never declared, and that is the one function in the app that must not throw.
- panels-ui's two kill-all clears route through the same helper, and the
xterm-version guard's comment says "resolved lockfile version" rather than
"dependency RANGE", which is what it has pinned since the last round.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Blocker 1: the loading banner hides itself ~200ms after it reopens.
- _showCenterStatus reuses one shared DOM node; dismiss() scheduled
el.hidden = true 200ms later with nothing to cancel it. On the
Claude path, switchingToast.dismiss() is followed by one same-
origin request (5-30ms locally) before _watchLlamaSwapLoading opens
the new banner -- well inside that window -- so the stale timer
fired against the shared node and hid the fresh banner, leaving the
whole model-load wait with no progress text, no log line and no
reachable Cancel button.
- Fixed by parking the pending timeout on the element and clearing it
at the top of _showCenterStatus. Added a regression test that
reproduces the exact repro (open, dismiss, reopen 20ms later,
advance past 200ms) alongside the existing Cancel-button DOM tests;
confirmed it fails without the fix and passes with it.
Blocker 2: the swap-conflict warning named other users' sessions.
- Both affectedSessions scans (POST .../custom-model and quick-start)
walked the whole session map with no ownership filter, so in multi-
user mode a non-admin pointing their own session at a shared
endpoint learned another user's session name and id -- which with
autoNameSessions on is that user's own prompt.
- The swap is still blocked pending confirmation regardless of
ownership (a foreign session is just as real a disruption); only
which ones get NAMED back to the caller is scoped, via the
already-imported canAccessOwned. Added a two-owner test to
test/routes/session-custom-model.test.ts covering both the
foreign-owner (blocked, not named) and same-owner (named) cases.
Smaller ride-along fixes:
- server.ts boot recovery now passes contextLength into
applyCustomModelInjection, so CLAUDE_CODE_MAX_CONTEXT_TOKENS is
correctly rebuilt into _envOverrides after a restart instead of
surviving only because tmux retains the old setenv.
- pumpLlamaSwapLogTail's finally now deletes by IDENTITY, not just by
key, so an aborted pump finishing after a newer entry was created
for the same endpoint can no longer delete that newer entry and
orphan its connection.
- docs/custom-model-endpoints.md now notes that clearing a custom
model removes injected keys by name, including CLAUDE_CONFIG_DIR --
so a session that also had CLAUDE_CONFIG_DIR set via envOverrides
(the per-client-account case) silently falls back to the default
account on clear.
Left for later, as flagged in the review itself: the quick-start
case-scaffolding/cancel ordering (real behavioural reordering across
a large handler, too risky to make without a live re-test), and
retiring runCustomModelEntry's mode === 'claude' branch behind a
launchStrategy registry field (explicitly deferred by the reviewer to
"the next one").
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ea59JhUmHBm1gRCsiYF33R
Four blockers from the 2026-09-18 review:
- PUT /api/model-endpoints/:id now merges modelContextLengths/
modelSizesGB back in from the stored record instead of trusting the
editor's body, so renaming an endpoint or changing its default model
no longer silently drops the context-window floor check and
CLAUDE_CODE_MAX_CONTEXT_TOKENS injection.
- custom-model:swapped-out is now session-scoped (added to
SESSION_PREFIXES) instead of broadcasting to every connected client.
- The quick-start custom-model path now hands setCustomModel() only
the endpoint's own injected env vars, not the full merged set,
matching the restart-in-place path — the full set put
CLAUDE_CODE_EFFORT_LEVEL back after the Session constructor had
already stripped it.
- The quick-start launchModel override for pi/grok/omp is now applied
generically via the registry's legacyConfigField, mirroring
Session._withCustomModelLaunchModel, instead of three hardcoded
mode === '<id>' branches a future CLI's injection recipe would miss.
Also scopes the sticky-toast default (item 5): reverted the blanket
"all error toasts are sticky" default, which had no container cap or
eviction, back to a flat 3s; the one message that needs a moment to
read (a failed custom-model apply) now passes an explicit
duration: 0 at its own call site.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ea59JhUmHBm1gRCsiYF33R
Replaces the size-scaled expected-time estimate + matching auto-timeout
with a generic hardware/model-size disclaimer and a user-driven Cancel
button, per explicit request. Real load time depends on hardware this
feature has no way to know (VRAM, storage speed, GPU contention), so
the old estimate/timeout was a guess dressed up as a fact — worse, one
that could kill a genuinely slow load partway through on slower
hardware.
- _watchLlamaSwapLoading (session-ui.js): dropped maxWaitMs/deadline
entirely — polls indefinitely until ready or cancelled, no automatic
give-up. Message is now "Loading <model> (<size>) on <endpoint> —
this can take a while depending on your hardware and the model
size.", with the real llama.cpp log line still on its own second
line. Removed _MODEL_LOAD_TIME_MATRIX/_estimateModelLoad/
_formatRemaining (dead code once the countdown is gone) —
_lookupModelSizeGB is kept, the GB figure still shows.
- _showCenterStatus (panels-ui.js) gains opts.onCancel: renders a real
"Cancel" button (distinct from the error-type "×" close button,
since Cancel has a real consequence) that calls it on click. Caller
owns what cancelling actually means, same split as the swap-confirm
modal's promise-resolving buttons.
- Cancelling dismisses the banner, shows an info toast (not an error —
this was deliberate), and closes the session, mirroring what the old
timeout used to do automatically but now on the user's own call.
- New .center-status-cancel CSS (bordered pill button, distinct from
the plain "×" close glyph).
Test changes: removed the now-invalid timeout-auto-close/estimate
tests, added cancel-flow tests (dismiss/toast-type/session-close,
never-closes-with-no-sessionId, unbounded-polling), and real-DOM tests
for the new Cancel button (bootAppWithRealCenterStatus, evaluating
panels-ui.js instead of stubbing _showCenterStatus, since this button
is worth verifying for real rather than just through the stub every
other test in the file uses). Typecheck/lint/frontend-syntax clean;
full suite shows no new regressions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG