mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
e214691429dac34acae07406b1fda2bbb3ab7b4b
1113
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bcebc81fcd |
feat(custom-model): detect llama-swap model conflicts before switching
Root-caused the user's earlier confusion ('the terminal says opus even though
something is waiting for llama to load'): llama.cpp runs exactly one model at
a time, and llama-swap unloads/reloads it on demand - a swap can take
anywhere from a few seconds to well over a minute, during which a session
looks indistinguishable from one still on the native backend.
1. Feature-detects llama-swap (vs. plain llama.cpp/any OpenAI-compatible
server) via its own GET /running, which plain llama.cpp has no concept of
at all. New GET /api/model-endpoints/:id/running-status route exposes this
read-only, for the frontend's polling loop below.
2. Before applying a selection, POST /api/sessions/:id/custom-model now checks
what llama-swap currently has loaded. If it differs from the requested
model AND another live session's own customModel selection is actively
using that loaded model, the apply is refused with a
{requiresConfirmation, currentlyLoadedModel, affectedSessions} payload
instead of silently switching. A "confirmed: true" field on the retry
skips the check. Switching with nothing else affected proceeds
immediately, no confirmation asked, only ever when there is something to
warn about.
3. The frontend (runCustomModelEntry) shows a native confirm() naming the
affected session(s) and the model they'd lose, matching this codebase's
existing convention for this class of decision (delete case, kill
session, etc.) rather than a new modal. On a successful apply the response
also carries modelSwapInProgress; when true, a new _watchLlamaSwapLoading
poll shows a sticky "Loading <model>..." toast via the new running-status
route until llama-swap reports the target model ready (bounded at 2
minutes), so a prompt sent mid-swap reads as "loading", never as silence
or an answer from whatever was loaded a moment before.
Checks are read-only against llama-swap's own /running - never /props, which
takes a ?model= and can itself trigger a load as a side effect of asking.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
|
||
|
|
da933d70be |
feat(sessions): offer to rebuild the sessions a host reboot destroyed
A host reboot takes the tmux server down with it, so every pane dies, reconciliation finds nothing to attach to, and the board comes up empty. Picking yesterday's work back up meant finding each conversation in history and resuming it by hand, one at a time. The boot pass now works out what the reboot killed and leaves it on offer. It runs inside restoreMuxSessions(), in the window where reconciliation has reported the dead sessions and cleanupStaleSessions() has not pruned their records yet, which is the only place the records can still be read. The board shows a banner, and nothing is created until the user clicks it. A click rather than an automatic restore is what makes the reboot heuristic acceptable. The heuristic cannot tell a reboot from a crash that took tmux down inside the same window, so it decides whether to ASK, never whether to act: a wrong yes costs a line of text the user dismisses instead of N CLI processes nobody asked for. Four things are re-checked when the click arrives rather than trusted from boot, because hours can pass and the board moves on. The owner's privilege grant re-resolves through the env clamp. The workspace must still be on disk. A conversation the user already resumed by hand from the Resume list is skipped, since two panes running --resume on one conversation would fight over the same transcript. Entries leave the plan synchronously before the first await, and the route is single-flighted, so a double-click or two devices cannot both reach the same entry. A restored session comes back attached, idle and disarmed. Respawn controllers and Ralph loops are deliberately not re-armed: a machine that just came up is the worst moment to turn an autonomous run loose. Its workspace hooks are installed by the restore route itself, because the boot-time sweep sits behind a gate that is false after a reboot and has finished long before the click; without them a session goes silently blind, with no stop or idle events for respawn, no Approvals Inbox item and no red tab on a blocking dialog. Stats collection starts the same way. The pane is new, so the conversation continues and the terminal scrollback does not. The banner says so rather than letting an empty pane read as a broken restore. The plan lives in memory only. A server restart drops it, which costs the convenience this adds and never the conversation: the conversation is the transcript under ~/.claude/projects, which the Welcome screen's Resume list and the Session Manager already read, so a dropped plan returns the user to resuming by hand. clampEnvOverridesForOwner moves to src/session-env-clamp.ts, since the question it answers is about session privilege rather than about HTTP and it now has a caller outside the route layer. Its test hook stays re-exported from session-routes.ts. Claude sessions only for this pass. The other CLIs name their thread in their own config object, which this does not thread through yet. Remote and docker sessions are skipped on purpose, because both need another host or a container to be up and a freshly booted machine cannot promise either. Refs #411 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5c25a52f95 |
fix(custom-model): wait for a freshly launched session to go idle before applying
Root cause of every 'Session is busy' apply failure reported from live
testing: a just-launched CLI reports itself 'busy' for its own startup
(boot spinner, workspace-trust check) well before runCustomModelEntry's
apply call could reach it, and the apply route's isBusy() guard correctly
cannot tell that apart from a real turn in progress — it exists precisely
to refuse restarting a session mid-turn, and a fresh boot looks exactly
like one from the outside. Confirmed live: replaying the identical apply
call by hand against the same session, once it had settled, succeeded
immediately.
Fixed by waiting on the session's own readiness signal before applying:
GET /api/sessions/:id/wait?until=idle&timeout=20000, one GET already built
for exactly this ('Agent wait primitives', CLAUDE.md) rather than inventing
a client-side poll loop. A timeout there is a normal 200 per that
endpoint's own contract, never an error, so a session still busy after 20s
just reaches the apply call anyway and gets the route's own honest error —
now visible, since the previous commit made error toasts sticky and
stopped discarding the real error text.
Tests: new case in custom-model-run-menu-ui.test.ts pins the ordering (the
wait call happens, and strictly before the apply call) and its exact query
string.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
|
||
|
|
409a6e65f9 |
fix(custom-model,toast): surface the real apply error, and make error toasts sticky with a close button
Two related fixes, both needed to actually diagnose 'Session started on the native backend — could not apply the custom endpoint' reports from live testing: 1. runCustomModelEntry()'s apply call went through _apiJson(), which unwraps a success body but SWALLOWS a failure response entirely and returns null — discarding the one thing (error, errorCode) that would tell 'endpoint unreachable' apart from 'not a discovered model', 'remote/Docker session', or a dozen other real causes the apply route already reports distinctly. Switched to _api() so the actual response body is read on failure too, and the toast now includes the real message. 2. showToast() defaulted every toast, error or not, to a 3s auto-dismiss with no way to read it again — exactly what made the above generic message impossible to act on even before the fix above. Error toasts now default to sticky (duration: 0, no auto-dismiss) unless a caller opts into a duration, and every toast — sticky or not — gets an explicit close (x) button, since a sticky toast with no way to dismiss it would just accumulate across repeated failures. Tests: custom-model-run-menu-ui.test.ts's two apply tests updated for the _api() switch (their mocks previously stubbed _apiJson, which the apply call no longer goes through), plus a new test pinning that the real server error string reaches the toast on a failure. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG |
||
|
|
a1c35da0d8 |
fix(input): deliver a recovered keystroke before the Enter that submits it
Every message typed on an Android phone lost its last character. An Android soft keyboard commits the last typed character and sends the Enter key in ONE InputConnection transaction, so the committed-text `input` event and the Enter keydown are both processed before any zero-delay timer runs. The orphaned-input recovery from #388 resolved its candidate only on such a timer, and that lost the character twice over: * ORDER — xterm emits `\r` synchronously from the Enter keydown, and the local-echo composer submits `pendingText` right there. The recovered character arrived one macrotask too late to be part of the prompt. * LOSS — that same `\r` bumps the canonical counter, so by the time the candidate resolved, `canonicalCount > snapshot` read as "xterm spoke for this keystroke" and stood the recovery down. The character was not merely late, it was dropped. Drain pending candidates synchronously at the next keydown instead, from xterm's custom key handler, which runs before xterm processes that key. The counter then still holds the value it had while the candidate's own keystroke was current, so the stand-down decision is made against the right keystroke, and the recovered byte reaches the composer ahead of whatever the new key emits. The timer stays as the fallback for a keystroke with no key after it. Physical keyboards are unaffected: there the timer has already resolved the candidate long before the next key arrives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9a9e542a7d |
fix(custom-model): bound the model-picker dialog's height and make its list scroll
The dialog had no max-height at all, so an endpoint with many discovered models grew it past the viewport with nothing to scroll — reported live as both "takes up the full page" and "the list is truncated", which turn out to be the same bug. Gives #customModelPickModal .modal-content the same bounded-height + scrollable-body shape cronModal's .modal-lg already uses (max-height + flex column on the content, overflow-y:auto + flex:1 on the body), scoped by id rather than folded into the shared .modal-sm class three other modals already use for short, fixed content. max-height: min(70vh, 520px) scales with the viewport (a phone gets 70% of its height; a 4K display never gets a needlessly tall dialog) rather than committing to one fixed pixel value that would be wrong at either end. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG |
||
|
|
5a9ff07f57 |
feat(custom-model): ask which model on launch when an endpoint has more than one, and re-discover models every 5 minutes
Two enhancements requested after live-validating PR #430 against a real llama.cpp server: 1. Model picker dialog. Picking a Run-menu Custom Endpoints entry used to apply the endpoint's defaultModelId (or the first discovered model) silently. Now, via the new selectCustomModelEntry() (session-ui.js): - exactly one discovered model launches straight away, same as before - two or more open a new #customModelPickModal listing every discovered model; defaultModelId (if set) is marked but never auto-chosen, since the point of asking is letting ONE launch deliberately differ from the saved default, not just confirming it The endpoint is re-fetched at click time rather than trusting anything cached from the dropdown's own render, since the model list can have changed (the sweep below, or a settings-panel edit) since it opened. runCustomModelEntry() itself — the actual launch, routed through run() for the in-flight lock, snapshot-guarded against applying to the wrong session — is unchanged; it now just always receives an explicit model id from one of these two paths instead of computing one itself. 2. Periodic re-discovery. Every saved endpoint's models now refresh automatically every 5 minutes in the background (CUSTOM_MODEL_REDISCOVER_INTERVAL_MS, server.ts, registered the same way as the Codex plan-usage poll it sits beside — this.cleanup.setInterval, off under testMode), so a model the server starts or stops serving shows up without another manual "Discover" click. The manual POST .../discover-models route and the new refreshAllCustomModelHosts() sweep (custom-model-routes.ts) now share one pure merge step (applyDiscoveredModels: stamps lastDiscoveredAt, drops a defaultModelId that no longer appears) rather than two copies that could drift. The sweep is best-effort per host — one endpoint being unreachable on a cycle never blocks the others — and re-reads the store before each host's write, keyed by id, so a concurrent edit or delete from the settings panel always wins over a sweep that started before it. Tests: test/custom-model-endpoint-rediscovery.test.ts is a new, dedicated file for the sweep (kept separate from custom-model-routes.test.ts because that file's data dir is shared across every test in it — one temp HOME per FILE, not per test — which would make a sweep-touches-every-host assertion meaningless there). test/custom-model-run-menu-ui.test.ts gained a new describe block driving the real picker modal through JSDOM: single-model bypass, multi-model dialog with the default marked-not-chosen, picking a row closes the modal and launches with that exact model, the endpoint re-fetch, and the two "vanished by click time" toast paths. Docs: docs/custom-model-endpoints.md, docs/wiki/Custom-Model-Endpoints.md, docs/api-reference.md and CLAUDE.md's dense feature paragraph all updated — the last of these also caught up two sentences that had gone stale after the draft-review fixes landed (the picker routes through run() now, not a raw run*() call). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG |
||
|
|
60e1bd52f7 |
fix(custom-model): act on the draft review — unparseable onclick, unwrapped envelope, wrong-session apply, missing lock, no tests
Addresses every blocker, both majors, and all but one minor from the
maintainer's review of the draft PR.
Blockers:
1. Every generated inline onclick was unparseable. JSON.stringify's own
double quotes terminated the double-quoted HTML attribute at the first
one, leaving btn.onclick null on every picker entry and every Discover/
Edit/Delete button. Fixed with escapeHtml(JSON.stringify(...)) per
argument, the same idiom deleteCase's onclick already uses four lines
away in session-ui.js. This also closes the live-HTML-injection route
through modelId (server-controlled, from the endpoint's own /v1/models
reply): with quoting intact, a `>` inside it can no longer terminate the
<button> tag early.
2. GET /api/model-endpoints wraps its body in the {success,data} envelope
like every other /api route (server.ts's preSerialization hook applies
to arrays too), so Array.isArray(hosts) was always false in production
and the picker/settings panel silently saw nothing. Both call sites now
go through _apiJson(), which already exists for exactly this.
3. A failed or declined run*() (missing CLI, isBusy, a caught exception)
returns normally without ever changing activeSessionId, so the apply
step used to silently re-point and restart whatever session the user was
already looking at. runCustomModelEntry() now snapshots activeSessionId
before the launch and requires it to have actually changed.
Majors:
4. Routes the launch through run() itself via a temporary _runMode swap
(never persisted — setRunMode() would sync it to the server) instead of
a parallel hardcoded dispatch table, so a custom-model launch now holds
the same _runInFlight lock every other Run click gets. This also
resolves the "hardcoded runners map contradicts the PR's own design"
minor: dispatch is run()'s own, so a CLI whose customModelInjection
recipe lands later needs no update here.
5. New test/custom-model-run-menu-ui.test.ts drives the real session-ui.js
against a JSDOM window (runScripts:"dangerously" — this JSDOM only ever
parses markup this module generated itself) for exactly the DOM-level
facts the review said needed no Playwright and no tmux: a generated
button's onclick genuinely compiles and fires, a dangerous modelId never
produces a live element, the envelope unwrap works, the session-changed
guard holds, run() actually gets called (proving the in-flight lock
engages), and _runMode is restored afterward. Confirmed against the
pre-fix code first (reproduces btn.onclick === null exactly) so this
isn't a vacuous pass. Plus new tests in custom-model-routes.test.ts and
render-index-html.test.ts for the other fixes below.
Minors:
- Generated entries now filter through isCliAvailable(), matching
_refreshRunModeAvailability's own gating of the stock entries.
- The CRUD panel is now gated on customModelEndpointsEnabled
(applyCustomModelEndpointsVisibility(), wired to the toggle's onchange
and to settings-modal open) instead of always rendering; the endpoint GET
no longer fires unconditionally either.
- API keys are never handed back to the browser on GET, POST or PUT —
redactApiKey() replaces the field with a computed apiKeySet: boolean, and
a PUT with no apiKey now keeps the stored one server-side
(applyStoredApiKey()) instead of the client resending a value it was
never given. New tests cover both directions (kept vs. replaced) by
observing the actual auth header a subsequent discovery request sends.
- "+ Add endpoint" hides for a non-admin in multi-user mode
(_applyCustomModelAdminGate(), also wired to admin-ui.js's codeman:me
event, since the real role can resolve after settings were first opened)
— endpoint writes were already admin-only server-side, but the button
used to render for everyone and eat a 403.
- design doc (custom-model-endpoints-plan.md §4) now says up front that its
toolbar-button design was superseded by the Run-menu picker.
- docs/api-reference.md gained a Custom Model Endpoints section (every
route, the apiKeySet/defaultModelId contract, the restart mechanics).
- Wiki page now covers un-pointing a session (curl/delete, no UI yet) and
that the picker is desktop-only for now.
- .set-inline-form uses --control-bg instead of a hardcoded black alpha
(CLAUDE.md already records that exact literal turning the settings
preview into a grey slab on light skins), .run-mode-custom-models gets
the same gap: 2px .run-mode-menu's own flex gap only applies one level
up, and the index.html comment naming the wrong function is fixed.
- __codemanCustomModelClis's JSON is now escaped against a literal
</script> (CliEntry.label is user-clis.json-settable, unlike
__codemanCliAvailable's booleans-only payload) via a new exported
escapeScriptJson(), pure and unit-tested without needing a WebServer.
- Added defaultModelId + the new /v1/model-endpoints routes to
docs/api-reference.md; left the "no zh-CN for the new Models-section
group" minor unaddressed only insofar as the wider Models section (task
routing, thinking effort, etc.) has never had zh-CN coverage either —
everything this PR itself introduces (labels, hints, button text, the
Run-menu's "Custom Endpoints" header) IS translated in i18n.js.
Regression caught while fixing #4: the admin-gate's codeman:me listener is
a module-level document.addEventListener() call, which threw in
run-mode-ui.test.ts's minimal vm-context fake document and failed all 10
of that file's tests. Fixed with optional chaining before it ever reached
the branch this commit lands on; full targeted suite (route tests,
structural guards, every settings-ui.js-loading frontend test) reverified
green afterward.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
|
||
|
|
25fae9ad10 |
feat(custom-model): generate Run-menu entries from saved endpoint profiles
Follow-up to #393, picking up the work Ark0N invited in his merge comment: "generate those entries from the saved profiles rather than a fixed duplicate per harness, and put it in a follow-up PR so this one stays the backend... The Run-menu picker is yours if you want it." Adds the frontend surface the backend has been waiting on: - Run menu: a "Custom Endpoints" section lists one entry per (harness that supports customModelInjection, saved endpoint) pair, e.g. "Claude Code (llama.cpp)". The harness list comes from window.__codemanCustomModelClis, injected at page render straight off the CLI registry's own capabilities (never a hardcoded id list in the frontend), so a CLI whose injection recipe lands later appears with no frontend change. Picking an entry runs that harness's own existing run*() function unmodified (case creation, env overrides, everything, forced to a single instance) and then applies the endpoint's default model to the session it creates via the existing POST /api/sessions/:id/custom-model route. Entries are hidden for a remote/docker active case, since that route already refuses both. - Settings: App Settings -> Models gets a "Custom model endpoints" group wiring up the customModelEndpointsEnabled toggle (declared since #393, read by nothing until now) plus CRUD against the existing /api/model-endpoints routes: list, add/edit (inline form), delete, discover models. - Backend: CustomModelHost gains an optional defaultModelId, the model the picker applies with no further choice per endpoint (one generated menu entry per CLI+endpoint pair, not per CLI+endpoint+model). The route refuses a value that isn't one of the endpoint's own discovered models, and a fresh discovery drops a default that no longer appears rather than carrying an invalid one forward. Docs: docs/custom-model-endpoints.md describes the new picker and settings panel; CLAUDE.md's Custom Model Endpoint Profiles entry drops the "backend-only" status note and documents the picker's generation mechanism. Tests: four new route tests cover defaultModelId validation, acceptance, and the drop/keep behaviour across a re-discovery; a new render-index-html test pins the __codemanCustomModelClis injection (present, agent CLIs supporting the capability, antigravity and shell excluded) and its solo-window skip. No browser test was added for the Run-menu picker itself or the settings CRUD panel (this box has no tmux, so the live server used by test:browser/test:mobile could not be exercised here) -- worth a Playwright pass before merge, same as any other frontend PR. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG |
||
|
|
d0a5a583cd |
feat(remote): wake a sleeping host when a session is created or attached
Pressing Run on a remote case whose host was asleep failed with `could not verify tmux on remote host 192.168.50.137: …` — an ssh error that blames tmux for a machine that is merely suspended. The only wake paths were typed input on an established session and the banner's Wake button, so OPENING a session (the moment the user actually decides to use that host) had none. `RemoteWakeRegistry.ensureHostAwake()` reuses the existing probe/wake/readiness machinery for a host that has no session yet, and is wired into the two user-initiated create paths: `POST /api/quick-start` for a remote case (before the tmux prereq probe, which is what surfaced the misleading error) and `POST /api/sessions` with `attachRemoteSession`. A host without a wake target is not even probed, so its behavior and latency are byte-identical. The wake is blocking — the caller gets the session or an error — but bounded by REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS (40 s) instead of the 90 s session default, because the dashboard sits behind a reverse proxy whose default `proxy_read_timeout` is 60 s: a longer wait would be cut off at the proxy while the session was still being created. The budget has to cover the whole request (40 s wake + 1.5 s probe + the tmux probe's own 15 s = 56.5 s worst case), which is why it is 40 s and not 45. A timeout now says the host did not come back, and an unreachable host without a wake target says so instead of pointing at tmux. The wiring is deliberately in the HTTP ROUTE, never in the shared session service: `cron-service.ts` builds sessions there with nobody waiting on the answer, and a wake on that path would power the host on for every schedule — the timer-driven re-wake invariant #1 exists to prevent. Both halves are asserted (importers of `remote-wake`, and `ensureHostAwake` having exactly one caller file), so a future caller has to come through the guard test. A rejection from the wake IO is caught too: a broken target must fail the wake, not the route. `remote:hostWaking`/`remote:hostWakeFailed` now carry `forNewSession` for the session-less case, where "input is queued" would be untrue; the toast then reads "the session starts when it is back". Live wake numbers are unchanged (this reuses the measured ~12 s S3 path); the route behavior is covered by new tests in session-routes.test.ts with an injected registry, so no test opens a real socket or ssh. |
||
|
|
8dfc965d13 |
fix(remote): stop the wake handlers shadowing each other; enforce the input cap
Two findings from a final review pass over the wake-on-LAN feature. `_onRemoteHostWaking` / `_onRemoteHostWakeFailed` were defined in BOTH `panels-ui.js` (toasts) and `host-wake-ui.js` (banner). Both files mix into `CodemanApp.prototype` and `host-wake-ui.js` loads later, so the panels-ui copies were silently shadowed: the toast never fired, and a wake started for a BACKGROUND session (input on a non-active tab) produced no notification at all, since the banner handler only acts on the active session. The handlers now live only in `host-wake-ui.js`, show the toast unconditionally, and update the banner when the woken session is the active one. `appendBoundedPending` dropped only WHOLE chunks, so a single input value over the cap (one large paste is one `input` value, up to the 100 KB input schema) was kept in full: "bounded at 4 KB" held per chunk, not per session, and nothing was logged. The surviving chunk's head is now trimmed too, code-point aware so a multi-byte character is never split into a replacement char. Adds the guard that would have caught the first one: every SSE dispatch handler must be defined in exactly ONE frontend module. The existing test only asserts a handler EXISTS somewhere, which two modules both satisfy while one is shadowed. |
||
|
|
c9515b1d4c |
fix(terminal): keep the output a pane capture could not contain
Live terminal events are queued while a buffer load runs, and the load discards that queue when it ends. That is right when the loaded buffer is the server's accumulated byte history. The route appends to that history right up to the moment it serializes the response, so a queued event already appears in it and replaying it would duplicate output, most visibly Ink's cursor-up redraws. A tmux pane capture is a photograph, current only as of the instant `capture-pane` ran. Output printed afterwards was queued and then dropped, and nothing scheduled a re-fetch to recover it: `_onSessionNeedsRefresh` is wired only to the 128KB overflow path. The CLI's next partial redraw then landed on a frame the terminal never received. How much went missing depended on which capture the route served. A `?full=1` load returns the capture alone, with no history in front of it, so it lost everything from the capture to the end of the chunked write. A `?tail=` load returns history, a clear, and then the capture, and the route reads that history after the capture, so it lost everything from the response to the end of that write. The chunked write dominates either way. An agent CLI hides the loss on its next full redraw; a shell session does not, because its output is linear and nothing repaints it. Queue entries now carry their arrival time, and `_finishBufferLoad` takes a `since` cutoff, so a capture load replays exactly the tail that arrived after the response headers. The earlier events stay dropped, because a payload that carries history does hold those. All four paths that fetch a terminal buffer and write it now decide this the same way, through one `_bufferLoadFinishOpts` helper, so they cannot drift apart: `selectSession`, `_onSessionNeedsRefresh`, `_onSessionClearTerminal` and `_maybeRefetchFullHistory`. The second of those is the one that stings. It exists to restore output the client already dropped once under backpressure, and it was dropping more output while performing that recovery. The cache-hit write inside `selectSession` stays on discard deliberately: it runs before the fetch, so its queue holds only events the capture that follows already contains. Two further things had to change for that tail to still exist when the load ends, and a browser test is what found both. `chunkedTerminalWrite` is what ends the load for every non-empty buffer, so the flush policy travels to its own finish calls; the call in `selectSession` runs only when the write was skipped. `_beginBufferLoad` no longer empties the queue when one load re-enters it, which it does on every write, because that reset discarded the whole fetch window before anything could replay it. The response already distinguishes the sources. `source` reads `mux-visible` or `mux-full-history` for a capture and `history` for the byte stream. Follows #395, #396 and #397, which fixed the ways the replayed frame itself could disagree with the terminal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5b920cb43d |
feat(sessions): land auto-naming opt-in, in the prefix form, from the first user prompt only
Finishes #376. The contributed keystroke tracker sat on the raw byte stream and named tabs wrong five ways (every prompt, every write path, a bare Esc eating the next prompt's first character, pasted newlines as Enter, any CSI clearing the draft) and replaced the whole name, which dropped the case from the tab and reset the w<n> counter. This lands the feature with each of those closed: - First prompt means the first: applyAutoName() flips a placeholder to `auto` whether or not the string changed. nameSource is now the tri-state placeholder | auto | manual; the name setter is the only manual path. - Only user-originated input counts: write()/writeViaMux() take SessionWriteOptions.fromUser, set by the browser WS path and POST /input only, so Ralph, respawn, cron, approvals and the trust-dialog keys can never name a tab. A startMode 'shell' CLI never feeds the tracker (a capability, not an id check); the send-key route feeds trackUserInput() because its line feed bypasses the session. - Prefix form `w3-case: title`: parseSessionPrefix() already renders it as the title with the prefix in the tooltip and the next-session counter still matches it. Composed within MAX_SESSION_NAME_LENGTH. - Tracker rules per key: bare Esc resolves at chunk end; mouse/focus reports, Tab, cursor keys, Shift+Tab are no-ops; Up/Down and Ctrl+P/N/R taint the draft so Enter submits nothing rather than a fragment; bracketed-paste newlines and Ctrl+J / Shift+Enter join with one space; the draft keeps its head past 8192 code points; an escape past 64 bytes is abandoned. - Title: slash commands by shape (a path is a prompt), `!` escapes refused, first sentence only past 8 code points ("e.g." is not a title), 72 code points on a word boundary. - Synced `autoNameSessions` setting, default OFF (the prompt reaches mux-sessions.json, session:updated and /api/search), App Settings -> Appearance -> Tabs, read fresh per prompt after the eligibility check. Tests: test/session-auto-name.test.ts (tracker, title, composition, ownership, emit gating), the wiring test (once, prefix, setting off, manual protected), test/routes/session-name-routes.test.ts (PUT /name flips to manual and persists). Verified live on an isolated instance: API and browser-typed prompts name the tab, a second prompt does not, shells and renamed tabs are untouched, nameSource survives a restart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
1380b023e2 |
fix(remote): make the wake banner's poller page-wide and independent of tab switches
Reported as 'the tab shows no banner' while the host was verifiably unreachable: the banner only started polling from selectSession, which RETURNS EARLY for the tab you are already on (so a page loaded with the remote tab active never polled), and a long-lived tab keeps running the JS it loaded — the feature was invisible to anyone who did not switch tabs after the deploy. The poller is now page-wide: one interval (created on init and on the first session switch), re-targeted whenever the active session changes, plus a visibilitychange wake-up. It no longer depends on any single selection path running. Also adds test/sse-dispatch-table.test.ts: a static guard that every [SSE_EVENTS.X, '_onFoo'] entry names an event constants.js defines AND a handler some module defines. Both halves fail silently (a typo'd constant is an undefined table key; a renamed handler just never runs), which is exactly how a new banner can never appear with no error anywhere. |
||
|
|
e8f7772320 |
fix(remote): offer the WoL config dialog after a failed wake too
A configured-but-broken target (host replaced NIC, command removed) had no way out: the dialog hung off the 'no target configured' branch only, so the banner would keep offering a Wake button that keeps failing. |
||
|
|
8b5a13435a |
feat(remote): host-unreachable banner, manual wake, and native MAC wake-on-LAN
The reactive wake (typing into a session whose host slept) left the state invisible: nothing told the user the machine was asleep, and with no wake target configured there was nothing to do about it. Adds: - RemoteHost.wakeMac (comma-separated) - Codeman builds and broadcasts the magic packet itself (UDP port 9), so the common case needs no external script. The existing wakeCommand stays as the explicit override. - GET /api/sessions/:id/reachability - probes (throttled, cached, and it never wakes) and reports HOW the host can be woken, or that nothing is configured. - POST /api/sessions/:id/wake - wakes, waits, reattaches the pane and flushes buffered input; 400 with a routable message when no target is configured. - The amber host-unreachable banner + its 'Wake' / 'Configure WoL' action, and a small config dialog that saves via PUT /api/remote-hosts/:id. - RemoteWakeDeps.resolveRemote: host config is re-resolved for LIVE sessions (throttled + cached), so saving the dialog takes effect without a restart. |
||
|
|
a81f430e41 |
feat(remote): wake a sleeping host from user input (Wake-on-LAN)
A durable remote session survives SSH drops (COD-104/108), but nothing brought the HOST back: after the remote machine suspended, the local tmux pane's ssh child stalled silently and `send-keys` SUCCEEDS against it, so typed input vanished with no error anywhere. Add an optional per-host `wakeCommand` (Wake-on-LAN wrapper, e.g. whuff) that the input route runs when a wake-enabled host is unreachable: input is buffered, the host is woken, the pane is reattached, and the buffer is flushed in order. Detection is a throttled bare TCP probe on wake-enabled hosts only, and only REAL user input may wake a host - the auto-reconnect watcher and boot recovery deliberately cannot, or the host would be re-woken seconds after every suspend and could never stay asleep. |
||
|
|
de864e7d63 |
fix(terminal): restore the history anchor after xterm parses, not before
flushPendingWrites() captured the viewport of a user who was reading scrollback, called terminal.write(), and restored the anchor on the next line. xterm parses on its own schedule, so at that point the buffer has not moved: the guard `viewportY !== preserveViewportY` was false, scrollToLine was never called at all, and the Codex redraw landed a tick later and took the viewport to the live bottom with nothing left to pull it back. Scrolling up during a stream still got dragged down, which is what #358 reports, and a refresh was the only way back to a coherent view. The restore moves inside xterm's write callback, the first moment the redraw's effect exists, and runs before _scheduleTerminalWriteFlush() so a deferred remainder re-captures the restored anchor rather than the bottom. Two things follow from it running later: - A live anchor now wins over the sticky scroll-to-bottom. The two are captured at different moments (_wasAtBottomBeforeWrite at the frame's first batchTerminalWrite, the anchor at flush time), so a scroll-up in between leaves both set, and running both would jump to the bottom and come back a frame later instead of staying put. - The anchor is dropped if the active session changed or a buffer load started while the write was in flight. It indexes the buffer it was captured from, and selectSession() resets the terminal and chunk-loads a different scrollback. The existing regression passed throughout, because its write mock moved the viewport synchronously, which real xterm never does. The harness now models an asynchronous parse (redraw lands, then the callback fires), and all five of the anchor tests fail against the old code. Fixes #358 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
025f061383 |
fix(docker): pre-fill the copied case instead of blanking two fields
The previous version cleared the case name and the in-container directory on the
grounds that they must differ. That left a form with three fields mysteriously
filled and two empty, and turned the most common operation — changing
/srv/app/api to /srv/app/web — into retyping a long path.
Both are now pre-filled, with focus on the in-container directory and the caret
at the end, since the tail is what changes. What stops an unmodified submit is no
longer an empty field but a guard: the values applied are recorded, compared at
submit time, and if nothing changed the reason is stated next to the field and
focus moves to it, without sending a request that is certain to be refused.
The server refuses these anyway (a duplicate case name, a twin case on the same
container and directory) and its errors are clear; but making a round trip to be
told "you forgot to edit the field you are looking at" is worse than saying so on
the spot. The guard only applies when a source case was actually selected, so
filling the adopt form from scratch is unaffected.
⚠️ The status text is written into dockerLinkStatus. My first version referenced
an id that does not exist (dockerAdoptStatus), which made the explanation vanish
silently and left only a toast. The test now extracts that id from the code and
looks it up in index.html, pinning that it must really exist.
(cherry picked from commit
|
||
|
|
7a5543da09 |
feat(docker): add "copy an existing case" to the adopt panel
The backend already lets one adopted container back several cases pointing at
different in-container directories, but using it meant retyping the container
name, host and workspace one by one — exactly the friction that leaves a
capability unused. Picking an existing case from a dropdown now carries those
three over, leaving only the two fields that must differ: the case name and the
in-container directory.
Clearing those two is the point of the feature, not a convenience: keeping the
old name is refused by the server as "case already exists", and keeping the old
directory is refused as "a twin case on the same container and directory". Both
errors are clear, but a form pre-filled with values that are guaranteed to be
rejected is a trap. Focus lands on the in-container directory — the thing the
user came here to change.
⚠️ Only adopted containers are listed (docker.owned === false). A Codeman-built
container's lifecycle belongs to its one case — a second case would be torn out
by that case's recreate or delete — so the server refuses it anyway, and listing
it here would only manufacture a baffling error. `owned` may be absent and absent
means owned, so the test is `!== false`, not truthiness.
CaseInfo.docker gains containerWorkdir and owned for this: the former is the
"which directory does this case use" half of the picker, without which the user
cannot tell what to change it to; the latter backs the filter above. ⚠️ Both
places that build a docker CaseInfo (the list endpoint and the single-case query)
must set them — filling in only one makes the picker work or not depending on
which read path was taken, and a test pins "exactly two".
(cherry picked from commit
|
||
|
|
e5684d0bba |
fix(ui): don't create a compositing layer for a hidden full-screen overlay
`backdrop-filter` promotes an element to its own compositing layer. A
position:fixed full-screen layer that is created and then hidden was measured to
leave a stale hit-test region behind in Chrome: the page renders perfectly, but
pointer events across the viewport go nowhere.
The report came from a long-lived tab connected to a remote server, where a
connection blip shows and then hides #offlineOverlay. The symptoms were a
terminal that would not scroll and, at the same time, an unrelated
click-to-expand that also stopped responding, while a freshly opened tab was
fine; a read-only console command (getComputedStyle + elementFromPoint, both of
which force a hit-test recomputation) then cured it. Two unrelated features
dying together and one read-only command fixing both points at hit-testing
itself rather than at either feature.
So the `backdrop-filter` moves onto the actually-visible selector and the layer
is never created while hidden. Only the two persistent overlays change:
offline-overlay (toggled with [hidden]) and file-preview-overlay (toggled with
.visible). path-picker and path-preview are created and removed by JS, leave
nothing behind, and are untouched.
⚠️ This is an evidence-based inference, not a fix verified by reproduction:
reproducing it needs a long-lived page that has been through a connection blip,
which I could not manufacture in a controlled environment. The guard test pins
both halves — no such property while hidden, and a real blur while shown — so a
later cleanup cannot quietly delete the effect.
(cherry picked from commit
|
||
|
|
c7cc8e28d5 |
fix(sse): stop reloading the whole terminal when a reconnect lands on the same session
handleInit() did not distinguish a first load from an SSE reconnect: it always
cleared the terminal caches in _resetAllAppState() and re-ran selectSession() for
the session that was already on screen. Every reconnect therefore refetched up to
1 MiB of buffer and reset+rewrote xterm. On a link that drops a connection about
once a minute (measured at ~57s intervals against a healthy server) that reads as
the page refreshing itself and throwing away your reading position.
A reconnect that lands back on the still-open session now keeps the terminal
caches and activeSessionId and resyncs through _onSessionNeedsRefresh(). That
path still reloads the buffer, so output produced during the outage is not lost,
but it preserves distance-from-bottom — the same rule #259 established for a
refresh the server triggered rather than the user. The WS is reconnected
explicitly when it is not already on that session, since skipping selectSession()
skips its _connectWs() call.
First load (gen === 1) takes exactly the path it took before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rv24Pk4qzrsDYdVyDyJQmT
(cherry picked from commit
|
||
|
|
01da577053 |
fix(input): recover when the seq counter falls behind the server watermark
Browser input is delivered exactly once by (clientId, seq). The server records a
watermark per clientId and discards anything not above it as a duplicate — but
acknowledged it with an ACK indistinguishable from "applied". The client then
dropped the record from its queue, the UI looked perfectly normal, and the
terminal received nothing at all.
The counter is persisted to localStorage through a debounced write. Kill the page
between "sent" and "persisted" and the restored counter is below the server's
watermark, after which every keystroke lands under it, is discarded, and is
ACKed. Reloading does not help: the clientId is restored from localStorage
alongside that stale counter. Measured on a real session — typing into the same
session from a fresh browser (new clientId, no watermark on the server) worked
perfectly, which is what localised the fault to client state.
Three changes:
- on rejection the server replies {"t":"ia",seq,"dup":true,"last":<watermark>}.
It still ACKs, so the client can drop the record from its queue, but it now
says the input was not applied and supplies the number needed to climb out.
- on `dup` the client lifts its counter above the watermark and re-queues.
⚠️ Only records whose FIRST delivery is being retried are re-sent: a retry
judged duplicate means the mechanism is working (the original did arrive), and
re-sending would type the same text twice.
- the counter is now persisted synchronously. The queue payload can stay
debounced, but the counter is the thing that has to survive a crash, and
leaving it on the lossiest path cancels the only guarantee there is.
⚠️ Reading the watermark is defensive: the session arrives through a structured
port, and a port missing that method must not take the whole input path down —
a throw inside the handler means the ACK is never sent and the record is stuck in
the client queue forever, which is worse than the ambiguity being fixed. A mock
port's test timeout is what exposed this.
(cherry picked from commit
|
||
|
|
631386d3f7 |
fix(cjk): forward Ctrl/Alt-modified navigation keys to the CLI
claude advertises "Jump to bottom (ctrl+End)", so that chord has to actually
reach it. But PASSTHROUGH_KEYS carried only the bare forms (End -> \x1b[F) and
CTRL_KEYS held just six letters (c/d/l/z/a/e), which cannot express End. Ctrl+End
therefore failed in both directions:
- with an empty composer it went out as a bare \x1b[F, the modifier silently
dropped, so the CLI received a plain End;
- with text in the composer the forwarding branch requires empty, so nothing was
forwarded and the browser default applied — the caret jumped to the end of the
draft, which is the "the shortcut now edits my input box" the user saw.
Encode them as CSI 1;<mod><final> instead, and forward Ctrl/Alt-modified
navigation keys whether or not the composer is empty: they are commands for the
CLI, and the composer has no editing semantics for them worth preserving (bare
Home/End still use the old table and edit locally).
⚠️ Bare Shift is deliberately excluded: Shift+arrow selects text in the composer,
a real editing gesture that must stay local. Shift held together with Ctrl/Alt is
still encoded into the modifier mask.
(cherry picked from commit
|
||
|
|
b3a6ba2eb6 |
feat(terminal): make Shift+drag select, and right-click copy the selection
In a native terminal running a TUI with mouse tracking on (claude, codex), Shift
is the "let me select text" modifier: it bypasses the application's mouse
reporting so the emulator selects locally. Users bring that habit here, where it
did nothing — measured, `hasSelection` was already false during a Shift+drag and
no clearSelection call ran at all, because there was never a selection to clear.
The mismatch is that the two Shifts mean different things. xterm reads Shift as
"force selection", but that path is only taken when the application really has
mouse tracking on. The server strips the mouse DECSETs for claude/codex/gemini
(isAltScreenStripMode), so xterm's mouseTrackingMode is permanently `none`, that
branch is unreachable, and Shift instead lands in _onIncrementalClick — which
EXTENDS an existing selection. Extension is a no-op while selectionStart is
empty, so the drag had no anchor.
So plant the anchor xterm is missing. The listener sits on the capture phase of
the `.xterm` root, an ancestor of the `.xterm-screen` that SelectionService binds
to, and therefore runs before xterm's own mousedown; xterm then extends from our
anchor and the drag behaves like any other. Length is 0 so a Shift+click without
a drag does not select a stray character. An existing selection is left alone —
that is a genuine extend gesture, and xterm handles it correctly.
Right-click copies the selection (the mintty/PuTTY convention), completing the
gesture: until now there was nowhere for a finished selection to go. With no
selection the native menu is not hijacked — taking it away while offering
nothing in return is a pure loss.
(cherry picked from commit
|
||
|
|
d9364f52e1 |
fix(webview): refuse a backslash or tab-led recovery path, which the URL parser reads as an origin
The lost-frame handler in webview-tabs.js remounts a web-tab frame at the path the frame reports it lost. It promised "path only, never an origin" and collapsed a leading run of slashes so `//host/x` could not jump the frame off the proxy, but it left two spellings through that the WHATWG URL parser treats the same way: a backslash, which is read as `/` for http(s) schemes, and an ASCII tab or newline, which the parser deletes before it looks at anything, so `/\host/x` and `/<tab>/host/x` both resolve to `https://host/x`. That mattered only in direct-mode tabs, where `POST /api/webviews/:id/open` returns no embedUrl and the recovered path is resolved with `new URL(path, src)` straight into the frame's src; a page in such a tab could remount its own frame on a foreign origin. Not an escalation (the page can already navigate itself anywhere, and the remount carries no Codeman-origin access), but the comment did not hold and the existing test only covered the form that already worked. The handler now strips tab, CR and LF, collapses any leading run of `/` or `\` to one `/`, and refuses whatever still opens a second separator. The new test drives the reachable direct-mode branch with all four spellings and fails without the fix. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
b0dddc9c57 |
Merge pull request #402 from shenlvkang-collab/pr/webview-route-masking
fix(webview): let a proxied single-page app route on its own path, and recover a frame that reloads |
||
|
|
653e3cdf96 |
Merge pull request #423 from Ark0N/fix/xterm6-selection-background
fix(terminal): name the selection colour the way xterm 6 does |
||
|
|
9acc5aad50 |
fix(terminal): name the selection colour the way xterm 6 does
Every per-skin xterm palette declared its selection layer as `selection`, the key xterm.js renamed to `selectionBackground` in v5. An ITheme is a plain object handed straight to the terminal, so an unknown key is not an error, it is dropped: all seven skins have been drawing xterm's built-in default, rgba(255,255,255,0.3), rather than the colour sitting next to it in the palette. Nobody saw it on the dark skins, where white at 30% is close to what those palettes asked for. On the four light skins it is white over a near-white background: blended, Paper Gray's selection differs from its own background by 3/255. That is not a subtle highlight, it is no highlight, and it looks exactly like a selection gesture that failed, which is part of what #360 reports on Android Chrome. test/skin-themes.test.ts pins both halves: the key name, and that the blended selection stays at least 16/255 from the background on every skin, plus the light-skin fallback landing under that floor, which is what makes this a fix rather than a rename. Refs #360 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7c3c5b8f72 |
fix(mobile): show the Codex shift-arrow keys only on codex sessions
The two keys #408 adds to the mobile keyboard accessory bar send Shift+Left and Shift+Right, which are Codex bindings (edit the last queued message, step back through the prompt stack). They shipped on both agent layouts, so a claude, pi, grok, omp, deepseek or gemini session got two keys that do nothing. That was not only cosmetic: a tap goes through sendNavKey(), which adds the session to _echoPassthroughSessions and hands editing to plain PTY echo until Enter or Ctrl+C, so on a phone a dead key also switched off the local echo that makes typing feel instant there. The reveal now follows the shape the 🧠 key already uses. The buttons stay in both templates, carry an accessory-btn-codex marker class, and are display:none in styles.css until the bar element carries codex-enabled. The class has to live on the bar rather than on the keys because setMode() rebuilds the buttons' innerHTML on every layout switch. syncCodexKeys() toggles it from the active session's mode (the same lookup _isShellSession() uses) and is called at init and from refreshForActiveSession(), which selectSession() already invokes on every switch. A session's mode is readonly on the server and fixed at create, so no other event can change the answer; the welcome screen (no active session) reads as not codex and hides the keys. The frontend id-branching guard (test/cli-registry-no-id-branching.test.ts) scans only src/**/*.ts, so the mode comparison in a public JS file is in bounds, the same as the existing shell check beside it. Tests: the new describe block in test/mobile-shell-keyboard.test.ts pins the marker class in both templates, the CSS pair, the class for a codex session in both layouts, its absence for claude/shell/pi/omp/deepseek, the re-sync in both directions on a session switch, the no-session case, and the init + refresh wiring. All six positive assertions fail without the source change. README and the changeset now say the keys are Codex-only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
1e5a53830f |
Merge pull request #408 from shenlvkang-collab/feat/codex-shift-arrow-keys
feat(mobile): add Shift arrows for Codex queued input and prompt navigation |
||
|
|
0c00a40530 |
Merge pull request #407 from Ark0N/feat/iphone-duo
iPhone Duo support: fold-aware dialogs, and a fold is no longer mistaken for the keyboard |
||
|
|
21dcec5d24 |
test(mobile): follow the 600px phone cut on the Duo branch
Rebased over #390, which moved the phone tier's cutoff from 430px to 600px. The palette's compound fold rule now lives in the 600-768px band mobile.css pads, the cascade samples the palette inside that band, and the closed iPhone Duo (466pt) is a phone rather than a small tablet while the open one (626pt) stays a tablet. Comments in both stylesheets, the device registry, CLAUDE.md and architecture-invariants say 600. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
707ea345eb |
fix(statusline): GET /api/settings never writes, and a save sends the collection switch only on a flip
Two follow-ups to #361's sticky telemetry switch. GET /api/settings reconciled an absent showPlanUsageLimits by persisting true, but readJsonConfig() answers {} for ANY read failure (a parse error, EACCES, EMFILE, a read landing inside PUT's non-atomic write), not only ENOENT, and every page load calls this route, so one unlucky read replaced the whole settings file with a one-key file. The route is a plain read again and the default moved into the reader: readPlanUsageTelemetryEnabled() treats an absent key as ON, the same way readWorkspaceHooksEnabled() does, which is what the desktop chip already shows for an install that never touched the setting. saveAppSettings() sent showPlanUsageLimits on every save. The chip defaults OFF on handhelds, so a phone saving its font size persisted false and switched collection off for every desktop, whose chip then went stale with no error anywhere. The key is now stripped like the other per-device display keys and re-added only when the save FLIPS the chip relative to what the device had (planUsageCollectionFlip), so an explicit toggle on any device still writes it in either direction. Tests pin both: the GET route with a mocked filesystem (absent, missing, EACCES, garbage, explicit), the reader default, and the flip helper plus its wiring in saveAppSettings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
b2b2c767ea |
Merge pull request #361 from timkjr/fix/statusline-injection-opt-out
fix(statusline): inject plan-usage telemetry via ephemeral CLI flag, never disk |
||
|
|
b6dbbbcfe0 |
fix(mobile): fold padding keeps phone sheets flush, scopes the palette rule, caps the response viewer under 430px
Three cascade problems in the fold reserved-region CSS, each measured by computed style in headless Chromium (styles.css + mobile.css in index.html link order): - The unconditional .path-picker-overlay / .path-preview-overlay fold rules at the end of the file beat the `padding: 0` both overlays set under 600px, so every phone got a 16px and 18px gutter on dialogs built flush (393 and 500px: edges floating off the screen). The fold strip is now restated on a ZERO base inside the same media query: 0/0 without a fold, the strip alone with one, 16/18 plus the strip from 626px up as before. - .modal.command-palette-modal was unscoped, so outside the 430-768px band (where mobile.css pads the palette with a shorthand) it ADDED 0.75rem with no gutter to compose with and pushed the shell 6px off centre at 393, 900 and 1400px, while inside the band the shorthand beat the generic .modal rule on the bottom side and the palette lost its block-end gutter. The compound rule now lives inside that band and restates both sides. - The tabletop cap on .response-viewer lost to mobile.css's `max-height: 92dvh` under 430px (same specificity, later file). mobile.css now carries an identical twin at its end. test/foldable-layout.test.ts simulates the padding cascade across both files at every breakpoint, with and without the fold rules, and requires the two to differ by exactly the fold strip; it also pins the palette rule to the band mobile.css keys on and the response-viewer twin to the styles.css value. Its model reproduces the Chromium numbers, and against the pre-fix stylesheets it fails on all three problems. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
ef15768e5f |
fix(mobile): keep the keyboard layout through a fold or rotation with the keyboard up
A shape change with the keyboard up re-baselined initialViewportHeight to the SHRUNK visual height, so heightDiff was 0 and the settle event the OS fires at the new width (or any later address-bar drift) satisfied the hide branch and ran onKeyboardHide() with the keyboard still on screen: accessory bar hidden, toolbar lift dropped, main's padding cleared. It could not recover, since no further 150px drop re-arms the show branch against a baseline already sitting at the shrunk height. Baseline to window.innerHeight instead when the keyboard is up: the page sets no interactive-widget, so the keyboard shrinks only the visual viewport and the layout viewport stays the display's full height on both engines, the same fact updateLayoutForKeyboard() relies on. The vm harness now models the two heights separately (resizeTo takes an optional layout height) and pins the fold flavour (626x590, 466x378, 466x378), the rotation flavour (393x359, 852x150, 852x160) and the eventual close. All three fail against the old line. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
8389423459 |
feat(mobile): iPhone Duo support (fold-aware dialogs, no phantom keyboard)
Apple's "Designing for iPhone Duo" asks an app to adapt to both displays, to stay continuous as the device opens and closes, and to treat the band a partly-open display folds through as a reserved region. Three things here. 1. A visual-viewport resize that changes the WIDTH is the device changing shape (a rotation, or a foldable opening or closing) and is never the virtual keyboard, which only ever takes height. handleViewportResize() read any height drop over 150px as the keyboard appearing, so closing a Duo (890 to 678pt tall) latched keyboardVisible with no keyboard on screen: the accessory bar appeared, main grew 84px of dead padding, and updateAppHeight() stopped refreshing --app-height. The latch was sticky, because clearing it needs the height back within 100px of a baseline belonging to a display the user is no longer looking at. Rotating any phone hit the same latch. The shape branch re-baselines instead, which is also what lets a keyboard opened after the fold be detected. 2. The hinge is now a reserved region in CSS. --fold-inline-end and --fold-block-end measure the strip to keep clear from the Viewport Segments env() variables, and are 0px everywhere else, so the seven centred overlays are inert by construction off a foldable. Each shrinks its content box with padding rather than the box itself, so the backdrop still covers the far side of the fold and still swallows taps there. 3. iPhone Duo (outer) and iPhone Duo (inner) join the mobile device registry, derived from Apple's published pixel specs at 3x. Verified in Chromium: flat, a dialog stays centred at 313 of a 626pt viewport; in book pose it centres at 153 inside the 0-305 leading segment with its right edge at 293, while the backdrop still spans all 626. The 3-term calc on the offline overlay resolves to 367px in tabletop pose and 20px flat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e6e5a62d9b |
Merge pull request #380 from opticon454/feature/cli-catalog-consumers
feat(cli-registry): drive install.sh and the Docker agent image from the CLI catalogue |
||
|
|
dff7aeef3f |
Merge pull request #390 from JDProfresh/fix/phone-breakpoint-480
fix(mobile): raise the phone breakpoint from 430px to 600px |
||
|
|
edeaa15986 |
feat(terminal): configurable normal and bold font weight (#403)
Bold text on the theme's default foreground carries exactly ONE cue, the weight step. Claude Code marks its markdown bold with a bare ESC[1m and changes no colour, and xterm substitutes a bright colour for bold only when the foreground is a palette index 0-7, so the substitution never fires for default-foreground text. A family shipping only a regular and a bold face keeps that step small (measured on Consolas: glyph ink rises from 14.25% to 16.57%), and picking a different family does not help, because 400 stays 400 whatever the family. Lowering the NORMAL weight is the only way to widen the gap. Two per-device settings beside "Terminal font" in the Font group, each defaulting to xterm's own value for its slot, so an untouched install renders exactly as it did before. Both thread into the main terminal and the Agent Teams panes, and apply on save without a reload. The bundled face had to be unclamped in the same change or the settings would look broken on a stock install. fonts/jetbrains-mono-variable.woff2 carries a wght axis of 100 to 800, but styles.css declared the face `400 700`, and the descriptor is what the browser synthesizes from: at that range 100, 200 and 300 rendered identically to 400 and 800 identically to 700 (measured in headless Chromium, both directions). The two families ahead of it in the default stack, Fira Code and Cascadia Code, exist only if the user installed them, so for most installs "normal = 300" would have been a no-op. Declared `100 800`, every step is distinct: 61%, 77% and 90% of the ink at 400, and 800 adds ~14% over 700. Nothing in the stylesheets asks for a monospace weight outside 400-700, so widening it changes nothing that rendered before. Details that are easy to get wrong and are pinned by tests: - Each slot falls back to its OWN xterm default, so an unset bold weight can never inherit `normal` and become a visible change. - A live save refreshes both echo overlays. They cache terminal.options.fontWeight and paint it into their spans, so without it the characters being typed keep the old weight while the rest of the screen changes. Most visible on a phone, where local echo is on by default. - A live save reaches open Agent Teams panes, which read their options at construction, exactly as applyTerminalSkin() propagates its own. - A stored weight the picker does not list (a hand-set 350) is added to the select rather than dropped, so merely opening App Settings cannot reset it. - _awaitTerminalFont() is untouched. CharSizeService measures through the CSS `font` shorthand, which resets the weight, so the measured face is always the 400 one and a weighted descriptor would request nothing new. Verified end to end in a headless browser against a live server: the save reaches the running terminal with no reload, the settings PUT stays 200 (both keys are display keys and are stripped before it, since SettingsUpdateSchema is strict), the value survives a reload, and the painted terminal really changes weight with the bundled font (lit-pixel ink 0.83 / 0.95 / 1.00 / 1.13 / 1.21 at 100 / 300 / default / 700 / 800). Proposed and analysed by @irisitymichaelgrundberg in discussion #403. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
708cb2cbf0 |
fix(tabs): let a wrapped desktop tab strip grow the header instead of clipping itself
The fixed 120px/96px caps on the two wrapped layouts were row counts in disguise: a third row was clipped into a ~4px scroller, hiding tabs inside a container nothing invites you to scroll, while the header had the page below it to grow into. Both layouts now share one rule capped at var(--tab-strip-max-height, 40vh), a safety net for an absurd session count rather than a row limit. Verified before shipping: .header is min-height + flex-shrink: 0 so it can grow, and terminal-ui's ResizeObserver refits the terminal when it does; updateTabOverflowMode() returns early for any non-desktop viewport, and below 1024px mobile.css pins the header to max-height: 48px, so this is desktop-only in effect; the selector is comma-grouped rather than :is(), so each arm keeps (0,2,0) and mobile.css's overrides still win on source order. PostCSS parses the file cleanly (prettier ignores styles.css). Authored in a parallel session against this shared checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
48f30f3055 |
style: drop em-dashes from the text added in c2114615
House style, and these land in the changelog. Only the sentences added in the previous commit are touched; the em-dashes in contributor text and in the pre-existing COD-54/COD-115 comments are left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c211461500 |
fix: merge-time follow-ups for #409, #404 and #399
#409 (Claude truecolor). The changeset becomes the changelog, and its premise does not hold on tmux 3.2 or newer. Measured here on tmux 3.4: `default-terminal` sits at its compiled default of `tmux-256color`, a live claude pane reports `TERM=tmux-256color`, and supports-color reads that as 256 colors, where rgb(55,55,55) lands on ESC[48;5;237m — visible, just not the color the theme named. The invisible block the PR describes needs TERM to resolve to a 16-color entry: tmux older than 3.2, or a ~/.tmux.conf setting `default-terminal screen`, which Codeman's own tmux server does read (it passes no -f). Both the changeset and the invariants paragraph now say that, so the next report here gets paired with the reporter's tmux -V instead of being read as universal. The change itself stands on the simpler argument: claude was one of two entries not asking for truecolor while twelve do. Also reorders buildClaudeEnv(). It applied the registry's unset/exports AFTER the whole env was built, so a clis.json entry naming CODEMAN_HOOK_SECRET_FILE or PATH would strip it on the direct-PTY path while the tmux pane kept it — buildEnvExports() emits `...cliEnv` ahead of `export CODEMAN_MUX=1` and cannot. The block now runs first and Codeman's own keys are assigned on top, matching the pane. #404 (Ctrl+Z trap). Adds the missing changeset, and records what the trap does not cover: an agent CLI already holds its tty with ISIG off (verified on three live panes: `susp = ^Z -isig -icanon`), so this is defence for the startup window rather than a fix for the steady state, and two input paths still reach the PTY unfiltered — the mobile accessory bar's one-shot Ctrl and the CJK textarea. #399 (path picker sort). The server sorts by name and cuts at 500, so the client sorting those 500 by date gives "the newest of the first 500 by name", which is wrong in exactly the >500-entry folder the date sort exists for. The status line now says "(first 500 by name)" so the cut is legible, with the reasoning parked on _sortEntries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
37929cb671 |
Merge pull request #404 from timkjr/pr-ctrlz-suspend-trap
fix(terminal): trap Ctrl+Z in non-shell sessions to prevent accidental suspend |
||
|
|
8c237223b0 |
Merge pull request #399 from shenlvkang-collab/pr/path-picker-sort-jump
feat(files): let the path picker jump to a typed path and sort by name or date |
||
|
|
7af4dbc0f8 |
feat(docker): derive the agent image's npm CLI list from the catalogue
docker/agent.Dockerfile hardcoded the four npm-published CLIs it installs, one of the several lists that had to be kept in step with the registry by hand. It now takes them as `ARG CLI_NPM_PACKAGES`, supplied by scripts/build-agent-image.mjs from config/clis.stock.json, with the default set to today's list so a bare `docker build` still produces the same image. The arg is expanded unquoted because word splitting is what turns the list into several arguments, which is exactly why every token is validated against ^[@A-Za-z0-9][@A-Za-z0-9/._-]*$ on the producing side; a package name carrying a space or a metacharacter is refused rather than reaching the RUN line. Verified by building the layer: four packages in, four arguments out, and the default still applies with no arg. The list is filtered on each entry's `enabled` flag — the field whose absence was the maintainer's §3 finding, where a CLI shipping disabled still got baked into every image. No stock entry is disabled today, so that assertion would pass vacuously; a unit test feeds the pure helper a fabricated disabled entry so the fix is covered now rather than the first time someone ships one. ⚠️ It reads the STOCK catalogue, never the merged registry. A user's ~/.codeman/clis.json must not change what is inside an image tagged codeman/agent:base, or two machines holding that tag hold different images. Four CLIs keep hand-written layers because the registry cannot describe what makes them special: pi's --ignore-scripts, deepseek's pnpm companion and dsh-tui profile, and the three standalone installers. Rather than extend the schema for a Docker-only benefit, the coverage test requires each to carry a written reason AND still be present, so an exclusion cannot quietly become an omission. There are two producers of this command line and there have to be — the .mjs cannot import TypeScript, and src/docker-hosts.ts builds the same argv for the in-app auto-build — so a parity test pins them together, package list, arg pairs and rendered argv. Their order is pinned too: a different order is a different RUN string and so a needless cache miss between the two build paths. docker/server.Dockerfile is deliberately NOT edited (PRs #373 and #377 both modify it); its narrower list is asserted as a declared omission list instead, so the divergence is reviewable without touching the file. Also fixes the in-app hint at index.html, which the new coverage test caught still omitting omp. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015EMxQreQUZX5ZyybxAGh12 |
||
|
|
b357fe832e | feat(mobile): add Shift arrow keys for Codex prompt navigation | ||
|
|
65ddedd1d4 |
fix: act on the 1.27.0 pre-release review
A Fable 5.1 reviewer read the whole release diff against 1.26.2 and returned
SHIP WITH FIXES. These are its findings, verified before acting on each.
**The changelog advertised a feature the code refuses (major).** The #401
changeset and docs/web-tabs.md both listed `*.localhost` in the loopback set.
The follow-up in
|
||
|
|
8b23f3e260 |
feat(rail): sort the vertical tab rail by activity, and give its rows the home screen's card
The vertical rail lists exactly the sessions both home screens list, so it now answers their question the same way instead of showing the raw tab order. Order: new per-device `tabRailSort` (App Settings -> Appearance -> Tabs -> Vertical Rail Order, default "By activity"). It runs `CodemanSessionOrder` over rows classified by `_mobileOverviewState`, i.e. literally the home screens' comparator, `lastSubmitAt`-anchored running group included. It is applied as the flex `order` property, never by reordering the DOM. `#sessionTabs` stays in `sessionOrder`, which is what keeps the Alt+N badge honest (it names a shortcut, not a row position, so it deliberately does NOT run 1,2,3 down a sorted rail), and keeps drag-and-drop, the arrow-key walk, the sidebar filter and `_scrollActiveTabIntoView()` all reading the list they always read. A session changing state then moves one inline style instead of forcing the full rebuild that would restart every card's animation on every SSE tick. The incremental render path re-applies it, since a state flip adds no tab and never reaches the full rebuild, and an empty string is what clears it when sorting stops. Web tabs are pinned past the cards by a CSS `order: 9999`, since `renderWebviewTabs()` emits the same markup for every layout and the flex default of 0 would interleave them. Drag is switched off while sorting (the drop rewrites `sessionOrder` correctly and the sort puts the card straight back, so the affordance would be a lie); 'manual' is the way back. Cards: detailed rail rows become bordered cards on `--bg-card`, with the stamps line on its own full-width row and the pill at its right end. The state dot goes 6px to 9px, keeps its orbiting ring while working and gains the green halo; idle mutes toward `--text-muted` as the home rail does. Needs/error/ waiting reuse `home-sessions-blink-red`/`-yellow` rather than a second copy. These card rules are RAIL-SCOPED and deliberately absent from the comma-grouped selectors that carry both vertical surfaces: the rail is an occasional, resizable list you scan, while the sidebar is a permanently-docked nav column where 20 stacked cards read as a wall. Every state-dot rule also excludes `.tab-alert-action`/`.tab-alert-idle` by hand, because those alert rules are only (0,3,0) and these are (0,5,1)+. Lines: the lineage bracket already drew in the rail, but its track sat 6px from the left edge, so half of its 11px outer glow was clipped by the window frame and it read as a thread pinned to the frame. It now runs at 10px, mid-channel in the gutter the rail already reserves. Tests: test/tab-rail-order.test.ts (17) drives the real `isTabRailSorted()` and `_tabRailSortOrder()` out of app.js, covering the row model (a WORKING row ranked by `lastSubmitAt`, which would otherwise rank every running turn as freshly started and fail no rendering test), the Alt+N badge, and the opt-out. Verified in Chromium across sorted/manual/simple/header-strip/sidebar with the setting flipped at runtime: no page errors, and the header strip and sidebar render byte-identically to before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |