mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
a0628a40e82b6b52a15fbf0d2d780e5d391ca819
766
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a0628a40e8 |
fix(cli-registry): address maintainer review on #380
Rebased onto current master (the one real conflict was the import line
in docker-hosts.ts Ark0N flagged; kept both), then addressed every
point from the review:
**1. Rebase.** Done — this branch now sits on current upstream/master.
**2. Agent-image special cases are data now, not an id-keyed table
outside stock.ts.** `AGENT_IMAGE_SPECIAL_CASE_IDS`/`AGENT_IMAGE_SPECIAL_CASES`
are gone. `CliDiscovery.install.agentImageLayer?: { kind: 'dedicated';
reason: string }` is a field on the registry entry itself (pi,
deepseek), `reason` is required by schema.ts, both producers
(docker-hosts.ts and cli-catalog.mjs) filter on its presence instead
of an id, and the coverage test reads it from the generated catalogue.
Also added the npm-package-name validation to the TS producer, which
only the .mjs one had — same SAFE_PACKAGE regex, duplicated
(necessarily, one side can't import the other) and now pinned
byte-identical by a new parity test.
**3. Changeset said five, it's eight.** (Not nine — see the DeepSeek
point below, which changes the true count.) Reworded to state it
structurally rather than pin a number that will go stale again.
Then the four behavior-changing findings:
- **DeepSeek was offered as a normal install option but can't actually
drive a pane.** `npm install -g @deepseek-ai/dsh` installs the
launcher only; DeepSeek ships no profile that can run standalone.
The generator now emits an empty install command for any
`launcherProfile` entry, so install.sh's menu (which requires a
non-empty command) skips it and falls through to its docs URL hint
instead — matching what the old hand-written code did before this
PR replaced it.
- **wget-only hosts lost every automatic install, including the npm
ones that never needed curl.** The menu-building loop now filters
PER ENTRY (only a command starting with `curl ` is held back) rather
than wiping the whole menu when DOWNLOADER != curl.
- **The DISPLAY/TRUSTED split and the catalogue refresh didn't hold up
under review** (refresh's only real write was the label; it ran
before the Node existence check; its own eval-detection test was
tripped by the word "eval'd" in a comment). Dropped entirely per
your own recommendation — embedded catalogue only, no network
fetch, no second array. install-sh-invariants.test.ts now asserts
the refresh/DISPLAY machinery does not exist rather than testing its
internals.
The three take-or-leave items, applied:
- `dsh_banner_probe`'s bash 3.2 empty-array bug: `${runner[@]}` →
`${runner[@]+"${runner[@]}"}`. Verified live in a real `bash:3.2.57`
container with `timeout` removed from PATH — crashed before, clean
now, full `detect_all_clis` path exercised end to end.
- `docker-agent-image-coverage.test.ts` now anchors on each layer's
`<binary> --version` proof line instead of `Dockerfile.includes(binary)`,
which stayed true if a layer were deleted but its comment survived.
- Doc drift: docs/docker-cases.md (four → five, and now describes the
data field), docker/agent.Dockerfile's "other four CLIs" comment (no
longer a magic number — CLI_NPM_PACKAGES is generated and can grow),
CLAUDE.md's install.sh size (104KB → ~112KB) and its stale mention of
the now-dropped refresh.
Verified: tsc clean, prettier clean, the full targeted suite (142
tests across the 8 affected files) green, and the full `npm test` gate
diffed BY TEST NAME against a clean upstream/master baseline run on
this same machine — identical 201-name failure set both sides (168
tests / 67 files, all pre-existing Windows-environment noise: symlinks,
PTY spawning, POSIX permission bits — none of it touching anything
this PR changes), zero new failures either side of the diff.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9ZSTEenc8soSu9bTi8Xru
|
||
|
|
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 |
||
|
|
1ca35095e7 |
refactor(install): drive CLI detection, the install menu and hints from the catalogue
install.sh carried nine search-path arrays, eighteen near-identical
check_<cli>/get_<cli>_path functions, and three separately hand-maintained
enumerations of all nine CLIs. They had to agree and did not: upstream
|
||
|
|
7d6f612ef5 |
feat(cli-registry): generate a CLI catalogue for install.sh and the Docker build
Two consumers of the registry cannot import TypeScript: `install.sh`, which runs via `curl | bash` before any checkout exists, and `scripts/build-agent-image.mjs`. Both currently hand-maintain their own CLI lists, and both have already drifted. `scripts/generate-cli-catalog.mts` (`npm run generate:cli-catalog`, plus a `--check` mode) emits from `STOCK_CLIS`: - `config/clis.stock.json` for the `.mjs` and the tests. It carries `enabled` — the field the earlier attempt omitted, which is how a disabled CLI's npm package still got baked into every agent image. - a marker-delimited block inside `install.sh`, embedded rather than fetched. The embedded copy is the FULL catalogue on purpose: the earlier design fetched it and fell back to a hardcoded two-CLI list, degrading silently on an empty response. There is no degraded mode to fall into now. The block is bash 3.2 safe: parallel indexed arrays, no associative arrays, no namerefs, no mapfile. Variable-length lists use OFFSET/LENGTH windows into one flat array rather than a delimiter, so a $HOME containing a space needs no IFS handling and `shell` (no binaries) gets length 0 and is never iterated. Search paths are emitted dir-major, matching the probe order the hand-written arrays use and `test/install-sh-detection-parity.test.ts` pins. Only fields the two consumers need are exported. `launch`/`env`/`capabilities`/ `overlays` are spawn-time concerns the server alone interprets, and a test asserts they never leak into the artifact. `main()` sits behind an `isMainModule()` guard so the sync test can import the renderers. Without it, importing the module would rewrite the artifacts as a side effect of checking them — passing always, guarding never. This commit adds the block; it does not yet delete the hand-written arrays, so the detection pin keeps measuring both against each other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015EMxQreQUZX5ZyybxAGh12 |
||
|
|
84f71e5704 |
test(install): pin install.sh's CLI detection paths before generating them
PR B replaces nine hand-written `*_SEARCH_PATHS` arrays in install.sh with one block generated from `STOCK_CLIS`. This lands FIRST, against the hand-written arrays, so the replacement has something to be measured against. The arrays are not uniform, which is why "generate them from the registry" is a claim rather than an obvious truth: claude alone has `~/.claude/local`, opencode alone has `~/go/bin`, opencode/codex/gemini/pi/omp carry `~/.bun/bin` while dsh/grok/agy do not, and omp's `~/.omp/bin` sits second rather than first. A generated list that silently narrows leaves a user with that CLI installed being told no AI CLI was found — upstream `b6d0f1fa` is that bug, fixed for omp by hand after it shipped. The test asserts a three-way identity: the pinned literals equal what install.sh contains today, AND equal `searchDirs x binaries` from the registry, dir-major so the probe ORDER is pinned too and not just the set. Both halves were verified to fail independently — dropping one path from install.sh fails the first, changing one `searchDirs` entry fails the second — because a pin that cannot fail is worse than no pin. A fourth case asserts every stock CLI with a binary is covered, which is the omp bug restated so it cannot recur silently. No production code changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015EMxQreQUZX5ZyybxAGh12 |
||
|
|
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> |
||
|
|
02b0e27898 |
fix: merge-time follow-ups for #400, #401, #362 and #388
Each item is from the pre-merge review of the PR it names, applied on master rather than by pushing to a contributor branch. #400 (response viewer, shenlvkang-collab) - The brief view opened at `scrollTop = 0`, right when it was a single card holding the last row. Now that it renders the whole turn, the top is the turn's first narration line and the answer can be screens below it, while loadFullContext already scrolls to the bottom of the same turn. A multi-row turn now opens at its newest text; a single card still opens at the top. #401 (loopback links as web tabs, shenlvkang-collab) - Drop `*.localhost` from the auto-route set. Every other member is an address literal that can only mean this box; a `*.localhost` DNS name is not one, and a resolver with a search domain retries `evil.localhost` as `evil.localhost.<search domain>`. The link source is agent-written terminal output, so that set is the whole confinement on a tap that makes Codeman fetch a URL server-side and persist it. The page-side test stays broader (`isOnBoxHostname`), where a false positive only declines to proxy. - A link to the origin root navigated nothing: the path was flattened to '', which openWebview reads as "no deep link", leaving an open frame where it was. - `this.webviews` being set does not mean it is loaded. initWebviews() assigns a truthy empty map and only then awaits the list, so a tap during page load found nothing to reuse and POSTed a duplicate record. Join the in-flight refresh instead. - One dashboard per dev server rather than per host spelling, which is what the method's own comment already promised. - Toast on the auto-create: it writes webviews.json, broadcasts over SSE and adds a Run-dropdown row on every signed-in device, with a new tab as its only previous signal. #362 (remote omp continuation, timkjr) - Accept the allowlisted `mode === 'omp'` arm as-is; a blanket registry render would hand deepseek a locally-resolved --profile and bypass claude's own overlay. A registry-declared switch is the follow-up if a third mode needs it. - Revert the whole-file Prettier reformat of docs/remote-sessions.md (docs/ is hand-formatted and outside `npm run format`), keeping only the two new sections. - Correct three stale passages: architecture-invariants' `exec claude --dangerously-skip-permissions`, the `exec <cli>` paragraph (claude and omp now have their own arms, and the claude pane's PID is the login shell), and omp-integration's `-c 'omp'`. RemoteCommandMode gains deepseek and omp. - Add the missing `_maybeCaptureOmpSessionId` remote-guard test; the sibling guard in `_pinOmpRespawnId` had one and this path runs earlier, on the first idle turn. #388 (keyCode 229 recovery, aakhter) - Gate notifyCanonicalData on shouldSuppressTerminalQueryResponse and isTerminalFocusOrMouseReport. onData also carries the DA/DSR/CPR/OSC replies xterm answers during Ink redraws and its SGR mouse and focus reports; any of those landing between the keydown and the candidate's resolution was read as "xterm spoke for this keystroke", standing the recovery down and leaving the character dropped, worst on a busy agent pane. Reached through window.CodemanTerminalInput: the predicates live in a module IIFE that closes long before this call site, so bare references would throw into the surrounding try/catch and stop the notify from ever running. Every fix has a test that fails without it (verified by reverting each). Full gate green on the combined tree: 358 files, 6849 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9d664ffe01 |
Merge pull request #388 from aakhter/pr/keycode-229-input-recovery
fix(terminal): recover dropped keyCode 229 input (Android/IME) |
||
|
|
a28b04c368 |
Merge pull request #362 from timkjr/feat/omp-remote-continuation
fix(omp,remote): thread remote-omp resume/continue through respawn and reattach |
||
|
|
e35b68e253 |
Merge pull request #401 from shenlvkang-collab/pr/loopback-links-webtab
feat(webview): open localhost links through a proxied web tab from another device |
||
|
|
d9eeb039db |
feat(webview): open localhost links through a proxied web tab from another device
An agent prints `http://localhost:5173/` (a dev server, a preview it just served) and the user taps it on a phone. That address only exists on the Codeman box, so the link was a guaranteed connection error from any other device — while the web-tab proxy fetches from the server, where it works. A loopback link (`localhost`, `*.localhost`, 127/8, 0.0.0.0, ::1) activated in the terminal or clicked in the Response Viewer now opens as a proxied web tab whenever the Codeman page itself is not on that box. A saved proxied dashboard on the same origin is reused, with the link's own path, query and fragment opened inside it (a mounted frame is navigated, not torn down, so its state survives); otherwise one is saved under its host:port, sandboxed like any other web tab, so it is in the Run dropdown next time. Only loopback is routed this way. A LAN or tailnet address may well be reachable from the device (a VPN, the same Wi-Fi) and a direct open is the cheaper, richer path, so those keep opening in a new browser tab; on the box itself every link opens directly. The terminal link provider and the viewer's click handler consult one hook and fall through to their existing behaviour when it declines. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McLWqCWBuQYGuPMScb4Aou |
||
|
|
bd61735393 |
fix(web): show the whole last turn in the Claude response viewer's brief view
The eye button rendered `data.text`, which is one row: the last assistant
row of the transcript. A Claude answer is a median of 3 model messages
(p90 11) split around tool calls, so the brief view usually showed the tail
of an answer ("Done.", "Let me look.") and the substance appeared only after
More. The full view was fine, which is why the brief one read as broken by
comparison.
The brief view now asks `?context=turn`. The reader answers with the
assistant messages of the last ANSWERED turn (`selectLastAnsweredTurn`: the
highest `turn` that has an assistant row, so a prompt queued after the
answer does not blank the view) and the frontend renders them exactly as
the full view renders that turn: one badge, then continuation segments,
gated on the numeric `turn` as before.
`data.text` is unchanged in every context — still the last assistant row,
never `messages.at(-1)` — because agent pollers hash it. Readers that emit
no turns (Codex, the pane parser, DeepSeek, an older server) return `text`
only for `context=turn`, and the brief view keeps its single card for them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McLWqCWBuQYGuPMScb4Aou
|
||
|
|
713f632a64 |
Merge pull request #397 from irisitymichaelgrundberg/fix/detached-session-owns-its-pane-size
fix(terminal): let a detached session's own window own its pane size |
||
|
|
92b5dfacb0 |
Merge pull request #396 from irisitymichaelgrundberg/fix/terminal-font-settle-before-fit
fix(terminal): fit the terminal only once the terminal font is measurable |
||
|
|
890a1b0902 |
Merge pull request #395 from irisitymichaelgrundberg/fix/full-history-replay-row-alignment
fix(terminal): keep row alignment in the full-history pane replay |
||
|
|
a360763890 |
Merge pull request #394 from irisitymichaelgrundberg/fix/ctrl-v-pastes-twice
fix(paste): handle only the first paste event the Ctrl+V trap receives |
||
|
|
57899f879e |
feat(ui): add a Blur entrance animation on all four surfaces
An iOS-style focus pull: the thing arrives out of focus and the blur fades off it as the opacity comes up. Opacity leads the blur (full opacity around 45%, blur still lifting), which is what separates it from a cross-fade. Ships on tabs (440ms), agent windows (560ms), the terminal pane (520ms) and connection lines (380ms), plus a `Soft focus` theme that sets all four. Default stays `legacy`, so an untouched install is unchanged. The terminal pane is the one surface that cannot blur itself the documented way, and `blur` takes a deliberate exception to the "never a filter on .terminal-container" rule. Every alternative was measured against a live xterm and does not work: a backdrop-filter veil on ::before blurs perfectly while STATIC, and Chrome silently drops the backdrop the moment ANY animation runs on that pseudo-element (the veil computes blur(15.3px) while the text behind it stays razor sharp); driving the radius from rAF buys the same full-screen blur per frame plus main-thread work. The cost the rule exists to avoid is inherent to blurring a terminal, so the style buys it knowingly: opt-in, off by default, one ~520ms run per session open, class straight back off, will-change still unset. Worst-case price, headless SwiftShader with no GPU: frame deltas 16.7ms -> 33.3ms for the run, against 16.7ms flat for `fade`. cols x rows measured unchanged at 178x38 before, during and after, so FitAddon never sees it. The line entrance animates `filter` too, where each line already carried its glow. Both kinds now hold it in --line-glow and both keyframes say `blur(N) var(--line-glow)`, so the function lists match and interpolate instead of the glow vanishing for the run and popping back (a lineage line's glow is a different colour, set per element). Its 100% frame omits `opacity` on purpose so the endpoint comes from the element's own resting value: 0.9 subagent, 0.72 lineage, 0.95 working. test/entrance-animations.test.ts is a new static guard over the whole feature, not just this style: the rule -> keyframes -> theme-option chain a style silently does nothing without, the terminal's paint-only property allowlist (the FitAddon rule), the --line-glow contract, and reduced-motion coverage. Mutation-checked both ways. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d4fe3afc9d |
feat(files): raise the download cap to 2GB and stream /api/download
The 50MB cap on file-raw, the attachment /raw route and /api/download was memory protection for a `readFile()` that no longer exists: file-raw and /raw were rewritten to stream through `sendFileBody()` and answer Range requests, so size costs a read stream rather than RSS (measured: a 600MB download moved peak RSS by ~37MB). All the cap still did was refuse legitimate downloads of build artifacts, videos and archives. It is now MAX_FILE_DOWNLOAD_BYTES in config/buffer-limits.ts, default 2GB, env CODEMAN_MAX_DOWNLOAD_BYTES, 0 = unlimited. `parseByteLimitEnv()` is separate from the `parseInt(...) || default` idiom used elsewhere in that file precisely because that idiom reads 0 as falsy and would silently restore the default for the one value that means "no limit". /api/download was the last route that really did buffer the whole file. It now shares sendFileBody() with the other two, so it streams, advertises Accept-Ranges, and is resumable. Its Content-Disposition also goes through buildContentDisposition() rather than raw interpolation. Refusals move from 400 to 413 across all three, which is the correct status for the case; with the cap at 2GB it is a path almost nothing reaches now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
77fcd65b4a |
fix(terminal): force the re-measure, bound the wait, and test both
Review of the previous commit found that waiting for the font does not, on its
own, do anything.
`FitAddon.proposeDimensions()` measures nothing — it divides the container by a
CACHED cell size, and xterm refreshes that cache only from `open()`, from a
resize that actually changed the grid, and on a device-pixel-ratio change.
Nothing in it listens for font loading. So a fit that runs after the font
arrives can still divide by the fallback cell, propose the grid it already has,
and short-circuit before anything re-measures. The wait now ends by calling
`_charSizeService.measure()` itself, which is the step that makes the following
fit see the real font. Private API, as FitAddon's own dependency on `_core` is,
and guarded because a terminal can be disposed mid-wait.
The wait was also unbounded, and it sat behind the buffer-load gate. Neither
`FontFaceSet.load()` nor `FontFaceSet.ready` has a deadline, so a font request
that never settled left the tab spinning with live output queued behind it —
permanently, and on every session, since they share one promise. The comment
claimed the opposite ("a font that never loads must not block the terminal, so
this always resolves"), which was true of the per-face loads and false of
`ready`. It is now raced against TERMINAL_FONT_WAIT_MS, and the await moved
ahead of `_beginBufferLoad` so a slow font cannot hold output back at all —
which also removes the stale-select interaction with `_restoringFlushedState`,
since that flag is not yet set when the wait runs.
The awaited set no longer includes faces that cannot move the measured cell.
The bundled symbols font is ~1.2MB of private-use-area glyphs and xterm
measures `W`, so awaiting it put a megabyte in front of the first frame for
nothing; the generic families match no FontFace at all.
A runtime font change had the same race the boot-time one did:
applyTerminalFontFamily wrote the new family and fit on the next line, against
a family the browser might not have loaded. It now re-arms the wait and fits
again when it settles.
The claim that this could not be tested was wrong: the repo's vm harness
reaches both halves. The new suite pins the family filter, the forced
re-measure, the deadline, a rejecting load, a browser with no font API, and a
terminal disposed mid-wait — plus the four ordering properties in
selectSession, including that iOS Safari's synchronous focus still precedes the
first await. Each assertion was checked by reverting its fix.
Also corrects the docstring's reason for calling `document.fonts.load` (the
stylesheet is render-blocking and long parsed by then; the real reason is that
the WebGL renderer rasterises through a canvas atlas, and canvas text never
triggers a CSS font fetch), restores the JSDoc block the previous commit
displaced from getTerminalDimensions, and fixes a comment that described the
first fit as already having run when the mobile-Safari branch defers it.
|
||
|
|
2b57c595df |
fix(terminal): gate the row-preserving skips on a capture, not the query flag
Review of the previous commit found the guard inverted: the three skips keyed on `?full=1`, which is only what the client asked for. When the capture comes back null — ENOBUFS, a timeout, a vanished pane, or a session with no mux at all — the reply falls back to the byte history, which IS a stream of successive frames and still needs stripping. Gating on the request returned it whole: measured at 82KB against 4KB for the same buffer without `full=1`. A direct-PTY session takes that path on every first selection, not only during an outage. The skips now key on `isFullCapture`, meaning a capture arrived. Three further defects the same review surfaced, all on this path: Keeping the trailing rows is only sound when a cursor move follows to count back up from them. On the two branches where the cursor query fails there is no move, so the caret was left at the bottom of the pane — worse than before. The cursor is now read first and settles both decisions together. The move is relative rather than absolute. `CUP` numbers rows from the top of the browser's screen, so it is only right while the browser's row count equals the pane's, and `resizeWindow` does not wait for tmux, so a capture can be taken before a requested resize applies. Measured against real tmux with a browser four rows shorter than the pane: the absolute move lands on a blank row, the relative one lands on the caret's row. An all-blank pane no longer reads as content. Retaining trailing rows and appending a move made it non-empty, and the caller treats non-empty as "replay this", so a blank screen would have replaced real history — the downgrade `_replayWouldShrinkBuffer` refuses, arriving from the server side where that guard cannot see it. The documentation claimed one line per screen row. `-J` joins a hard-wrapped row into its logical line, so that is false whenever any row wrapped: measured at 10 lines for a 12-row pane. Both entries now say what actually holds, and the stale "NOT repositioned" contract in the mux interface is updated too. Tests: the byte-history fallback is stripped, an empty capture leaves history intact, and the extracted helpers are unit-tested directly rather than through source-text matching. The slice window in the capture test is bounded at the next method, having overrun into its neighbours. |
||
|
|
070e8da81b |
fix(terminal): yield only the resize send, and take sizing back on redock
Review of the previous commit found four defects in it. The guard sat above the local fit, so it suppressed a reflow as well as the server write. tab-rail-resize performs its single settle-time refit through sendResize and has no fallback for a truthy activeSessionId, so dragging the rail stopped reflowing a detached session's terminal in the dashboard. The mobile-keyboard guard fourteen lines below already draws the line correctly — withhold the send, never the reflow — and the guard now sits after the fit. _lastResizeDims is one value for the whole window, and both guards skip updating it, so while a popup owns a session that value no longer describes the PTY. _redock repaired it only for the active session. Pop out A, switch to B, close the popup: selecting A later found unchanged dimensions, returned "unchanged", and selectSession skipped its 400ms redraw wait — while the server, comparing against the real pane, did resize and did raise SIGWINCH, so the fetch painted the pre-redraw frame. _redock now clears the record on every path, active or not. _redock could also fire a resize for a session already gone: _onSessionDeleted redocks before cleanup, so the id can be dead and the request is a guaranteed 404. It now checks the session still exists. restoreTerminalSize — the header's redraw button and Ctrl+Shift+R — silently did nothing for a detached session while still reporting success with dimensions nothing was set to. It now says the session is sized by its own window, where the same button works. The `force` comment claimed a client-side dedupe that does not exist; the deduplication is server-side against the real pane. Corrected to say what the flag actually buys. The _redock doc comment now records that the function writes to the server and is not idempotent. Tests: _redock was the untested half and is the half three of these defects sit in. It now has coverage for clearing the stale record on both the active and inactive paths, re-asserting only for the session being shown, and staying silent for a deleted session. The existing sendResize test now asserts the local fit still runs. |
||
|
|
323730a29d |
fix(terminal): keep row alignment in the full-history pane replay
Switching to a session left the caret one row below the composer's input line, on the box border, and every cursor-relative update the CLI sent afterwards was measured from the wrong row. Any fresh output repaired it, because the CLI then repainted the whole frame. Two things were wrong with the full-history replay, and they compound. The capture never restored the cursor. The visible-frame path ends with an absolute cursor move back to the pane's position; the linear path returned its text and left the caret wherever the last character landed, which for an agent CLI is the bottom-most row carrying text — the status line. The rows it addressed did not line up with the pane's rows either. Four transforms ran over the capture and each can delete a line: the trailing blank rows were stripped, redraw-bloat stripping ran, the trim that cuts everything above the Claude banner ran, and leading whitespace was removed. All four are right for a byte stream of successive frames. A capture is the rendered pane, one line per screen row, so each deletion shifted the frame out from under the restored cursor. The full-history path now appends the pane's own cursor position and keeps every row, so row N of the reply is row N of the pane. The visible-frame and tail paths are untouched. Restoring the cursor is what makes row alignment load-bearing here, and neither CLAUDE.md nor the architecture invariants said so — which is how four line-deleting transforms accumulated on the path. Both now record it. Verified against a live 315x59 pane: the reply carries 59 rows, its row 55 is the composer's input line matching tmux, and it ends with the cursor move that lands there. |
||
|
|
5ac516dd3b |
fix(terminal): let a detached session's own window own its pane size
Popping a session out left both windows sizing the same pane. The dashboard keeps the session active and keeps measuring it, and its terminal is narrower than the popup because the session rail takes width the popup does not have. One PTY cannot hold two sizes, so the CLI drew frames that fit neither window and the popup showed a garbled frame. sendResize and the debounced window-resize handler now stand aside for a session this window has marked detached. A solo window is exempt, since it is the owner. _maybeRefetchFullHistory already stood aside on exactly this condition, so the rule is not a new one. Sizing has to come back when the popup closes: while it owned the session the dashboard sent no resizes, so the PTY still holds the popup's geometry. _redock now re-asserts, with force, because the dimensions the dashboard last sent are the ones it is about to send again. Reproduced with a dashboard and a popup on one session: before, the pane sat at 315 columns while the popup rendered 289. After, both report the same size and the popup's frame matches the pane exactly. |
||
|
|
5130ca6633 |
fix(pr-bot): fail fast when the review model's budget is spent
Claude Code answers an exhausted model budget INSIDE the turn ("You've
reached your Fable limit. Run /usage-credits to continue or switch models
with /model.") and then sits there with nothing to write. The reviewer never
produces a report, so `runTurn` waited out its full 40-minute deadline and
reported a bare "timed out after 40 min without a report", which reads as a
hung reviewer rather than an account that needs attention.
Measured on 2026-09-08: #388, #393, #394 and #377 each lost 40 minutes this
way, and because every attempt counted, all four reached MAX_AUTO_RETRIES and
would NOT have been picked up again once the budget returned. One spent
afternoon quietly took the whole queue out of service.
`findModelLimitNotice()` reads the notice off the pane and `runTurn` returns
a new `limit` outcome instead of waiting. It is consulted in exactly two
places, both of which mean "the turn produced nothing": on a stop where
`isDone()` is still false, and on each timed-out wait slice. A review that
merely discusses usage limits in its own findings therefore cannot be
mistaken for one that hit the wall, and the pattern matches neither the model
name nor a straight apostrophe, since the pane renders a typographic one and
every model prints the same sentence.
A spent budget is an account condition, not a bad PR, so it no longer spends
the per-head retry budget: the queue resumes by itself when the budget does.
Telegram now names the cause and the file to change.
Tests use the pane captured verbatim off the run that lost the 40 minutes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
b87bc6871b |
fix(paste): handle only the first paste event the Ctrl+V trap receives
Ctrl+V in the terminal inserted the clipboard text twice. Right-click →
Paste inserted it once.
`_handleImagePaste()` appends a hidden contenteditable div, focuses it, and
reads the clipboard out of the paste event that lands there. Two separate
routes deliver that event for a single keypress. The function issues
`document.execCommand('paste')` itself, which in Firefox dispatches a
trusted paste event and then returns false, because the trap cancels the
event and the command never completes; Chromium and WebKit refuse that
command and dispatch nothing. The keydown's own default action delivers the
other, because xterm calls the custom key handler before its own `cancel()`,
so returning false never calls preventDefault. Firefox therefore ran the
trap's listener twice and both runs reached `terminal.paste()`. The
context-menu paste involves no keydown at all, which is why that path stayed
correct.
The trap now accepts the first paste event and cancels every later one, so
how many paste events a browser delivers no longer changes what the PTY
sees. Measured on a live install, one Ctrl+V each: Firefox two events and
two writes before this change, Chromium and WebKit one and one, and every
engine one write after it.
The `execCommand('paste')` call stays. Stripping it out also ends the
doubling, and all three engines still deliver one event without it, since
`trap.focus()` has already run when the key's default action resolves. It is
kept because the trap technique arrived in #84 for plain HTTP and for
mobile, and a desktop measurement says nothing about real iOS Safari or
Android Chrome: where a browser aims the default action at the element
focused when the keydown began, the command is the only route into the trap,
and the trap is the only place clipboard image blobs are read.
test/image-paste-trap.test.ts loads image-input.js into a `node:vm` context
with a fake document and fires two paste events at the trap. It covers text
and images, and fails on the old code with the text pasted twice and the
image uploaded twice.
Docs: the invariant goes into docs/architecture-invariants.md as a Terminal
paste section and into CLAUDE.md as a Frontend entry, both recording the
measured event counts and why the redundant call is still there. README.md
and the Keyboard Shortcuts and Input and Voice wiki pages gain a Ctrl+V row,
which all three tables were missing while listing every other clipboard
binding.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
797f0d387c |
fix(remote): address review feedback on omp/claude respawn continuity
- Remote omp command now renders through buildSpawnCommandFromRegistry (the mode-agnostic engine local/docker spawns use) instead of the buildOmpCommand() the CLI-registry refactor deleted. - Session._pinOmpRespawnId()/_maybeCaptureOmpSessionId() now skip host-local ~/.omp resolution entirely for a remote session and fall back to --continue: that resolver only ever reads THIS host's filesystem, which is meaningless (and could wrongly alias an unrelated local conversation) for a conversation that lives on the remote host. - Remote-claude launch now honors an explicit resumeSessionId distinct from sessionId (mirrors claudeDockerPaneCommand's shape), and validates sessionId the same way that sibling does before interpolating it into the remote shell command. - Add the still-missing header-cwd half of the trailing-slash test, and document respawn/reattach continuation + auto-reconnect-vs- clean-exit in docs/remote-sessions.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
88243e9ffa |
fix(remote): never auto-revive a remote session after a clean agent exit
The COD-108 reconnect watcher treated any dead local pane as a dropped transport and re-ran the pane command — so a normal ctrl-c/ctrl-d on a remote omp/opencode/claude auto-spawned a FRESH agent (claude only looked correct because its '--session-id || --resume' fallback resumed, with a loud 'already in use' error first). Distinguish a transport drop from an intentional exit: only reconnect when the durable remote tmux session (codeman-ssh-*) is verifiably still alive on the remote host. A clean exit tears that session down; the watcher now probes it via ssh has-session and skips (remote-gone) when it is gone OR unknown (fail closed). The probe is cached per-session and fired async so the 5s tick never blocks on ssh. Also thread ompConfig/resumeSessionId into the remote builders so a dead-pane respawn of an omp session resumes (--resume <id>) or continues (--continue) instead of launching bare omp. Tests: 3 new cases pinning remote-gone / unknown / alive decisions; remote omp resume + --continue fallback. Verified live: all three remote CLIs stay dead after exit. |
||
|
|
0a5bc1ac2e |
fix(omp,remote): pin remote conversations on respawn so ctrl-d/ctrl-c resumes instead of relaunching fresh
Two independent defects made ANY clean exit from a remote SSH session (user ctrl-d or ctrl-c, or a dropped pane) relaunch the agent as a NEW conversation: 1. SSH-remote claude was launched as a bare `claude --dangerously-skip-permissions`, so the remote-respawn path (COD-108 reattachRemote re-running the idempotent launch command) started a fresh conversation every time. Pin it to the deterministic Codeman session id, mirroring the docker-claude shape (claudeDockerPaneCommand): `--session-id <id>` to create, with the `|| --resume <id>` fallback so the idempotent re-run resumes instead of erroring with "already in use". A per-host commands.claude override still wins. 2. OMP --resume pinning silently degraded to ambiguous `--continue` whenever a case path ended in a trailing slash (e.g. remote `remotePath` stored verbatim as `/home/user/dotfiles/`): mangleOmpWorkingDir produced `-dotfiles-` while omp persists sessions under `-dotfiles`, readdirSync returned null for an existing dir, and findLatestOmpSessionId/resolveAndClaimOmpSessionId never matched. Normalize the trailing slash before mangling (new exported stripTrailingSlash) and compare the session header cwd against the same normalized value. Both were found live 2026-08-29 on a remote OMP/Claude node: ctrl-c and ctrl-d behaved identically, both relaunching a fresh session. |
||
|
|
e8a93ada1f |
fix(terminal): forward the orphaned input event instead of replaying a guessed key
The previous shape guessed the character from `event.key` on keydown, re-emitted it, and then tried to suppress a late canonical copy with a 250 ms character-keyed dedupe. Review found three defects in that, all reproducible: the dedupe matched on the character alone with nothing scoping a candidate to the keydown that created it, so the same character typed twice inside the window had its second, real byte swallowed; anything whose committed text differed from `event.key` (Enter, IME punctuation) was delivered twice, because the dedupe could never match it; and the trigger ignored `key === 'Unidentified'`, which is what a soft keyboard reports, so it may never have fired where it was needed. The input event already carries the committed text in `ev.data` — exactly what xterm itself would have forwarded — so nothing has to be guessed. The controller now only decides WHETHER to forward, by asking whether xterm produced canonical data since the keydown that began the keystroke. No character-keyed matching survives, so the first two defects are structurally impossible rather than defended against, and nothing reads `key`/`keyCode`, so the third cannot recur. Three details are load-bearing and each has a test that fails without it: - The "did xterm speak?" snapshot is taken at KEYDOWN, not at the input event. `_keyPress` emits and sets `_keyPressHandled` before `input` fires, so a snapshot read at input time already contains that emission, reads it as silence, and delivers the character twice. - Our `input` listener is registered with `capture: true`. The target is visited twice in the event path, so a capture listener calling `stopPropagation()` stops later BUBBLE listeners on that same target; xterm's `cancel()` runs exactly in the branch where it handled the input, so on bubble we would never observe handled events, and whether we observed them at all would hang off `options.cancelEvents`. Measured in jsdom and headless chromium; the table is in the module header. - Enter is deliberately no longer special-cased. That mapping is what made the committed text differ from the re-emitted value in the first place. The scope is also narrower than the old name suggests, and the browser test now proves it rather than assuming it. For a keydown that reports keyCode 229 xterm ALREADY self-rescues, via `CompositionHelper._handleAnyTextareaChanges()` diffing the helper textarea on a 0 ms timer. A test asserting "we recovered it" there passes while xterm does all the work, so the browser tests assert WHO delivered the byte: zero canonical emissions for the genuinely orphaned case, exactly one delivery for the case xterm rescues itself. Also addresses review notes: the module gains an `@fileoverview` with `@dependency`/`@loadorder` and an entry in the load-order list and module inventory, and the wiring test moves out of the Ctrl+C smart-copy file into its own. The keydown hook deliberately still runs for every key event rather than moving behind the 229 gate: gating it would reinstate exactly the blindness described above, and it is now a single counter assignment. |
||
|
|
4f2dfb4e6d |
fix(mobile): carry resumeId through the phone overview's past rows
#386 made Codex conversations resumable from Past Sessions, and resumeMobileOverviewSession() correctly passes row.resumeId on to resumeHistorySession(). The phone's own row projection never copied the field off the unified-list item though, so row.resumeId was always undefined there and a tapped Codex row started a FRESH session on a thread that was already on disk. The desktop path worked; only the phone was blind. The test fails without the projection line, and pins the other half too: a claude row must not grow a resumeId, since the field is what distinguishes "resume this conversation" from "start a new one". Docs: CLAUDE.md and architecture-invariants both still described the unified list as merging Claude transcript files. It has been three stores since this PR (Claude's ~/.claude/projects, omp's ~/.omp/agent/sessions, codex's ~/.codex/sessions), the alias field keeps its Claude-era name without being Claude-only, and the scanner-only rule behind resumeId was written down nowhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
344e93c824 |
Merge pull request #386 from irisitymichaelgrundberg/feat/codex-resume
Merging with the phone-overview resumeId fix and the two unified-list doc passages applied on master. |
||
|
|
f1b7283393 |
fix(cli-registry): guard workDetect.workingLine like every other config regex
#385 made the composer glyph and the working status line per-CLI registry data, which is right, but `workingLine` arrived as a config-supplied regex validated with a bare `new RegExp()`. That skips `compileVersionRegex()`, the helper the registry uses for exactly this: a `~/.codeman/clis.json` override can set the field, the compiled pattern is run against every accumulated PTY chunk and every pane capture, and a nested quantifier there backtracks on the event loop for the whole server rather than one session. Route it through the helper in both places, which are not redundant: the schema refine rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` compiles through the same helper so the runtime cannot hold a pattern the schema would have refused. The helper returns null instead of throwing, so the Claude-pattern fallback stops being a try/catch and becomes structural. Both shipped patterns compile unchanged, and Claude's is behaviourally identical to CLAUDE_WORKING_LINE_PATTERN. Also match the Codex footer case-insensitively on the E. It was characterised against codex-cli 0.152.1, which prints a lowercase `esc`; a version capitalising it would make the whole fix silently inert, since the pane would simply never look like it was working. Docs: CLAUDE.md, architecture-invariants and cli-registry.md all still stated the Claude-mode-only rule this PR retires, and none of them named the new capability or the regex guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a49be03f96 |
Merge pull request #385 from irisitymichaelgrundberg/fix/work-detection-external-clis
Merging with follow-up fixes applied on master: workingLine routed through compileVersionRegex() in both the schema refine and _workingLinePattern(), the Codex footer matched case-insensitively on the E, plus the doc passages that stated the retired Claude-mode-only rule. |
||
|
|
8ee7926e27 |
feat(agent-cases): tag agent-spawned case dirs and sweep their leftovers
A long orchestration creates one case directory per worker and deleting the sessions never removed them, so ~/codeman-cases accumulated scratch folders that were indistinguishable from real projects. They are now labelled and have a cleanup path. - src/agent-case-marker.ts: a case dir quick-start CREATES for an agent-driven spawn gets a .codeman-agent-case.json marker (when, by whom, parent session, mode). Only the create branch writes it, so a linked case, a cloned repo or any pre-existing path is never labelled; reading is total, so a malformed marker means "not agent-created" rather than a half-trusted entry. - The signal is the new X-Codeman-Agent-Origin header the skill preamble sets on its shared curl (preamble bumped to 1.22.0), or an agentOrigin body field, falling back to a resolved parentSessionId so a worker spawned by a stale skill copy is still labelled. - GET /api/cases publishes it as agentCreated; GET /api/cases/agent-created is a read-only cleanup listing adding inUse and modifiedAt; Add Case -> Manage badges each case and offers a review-then-delete sweep that names every directory in its confirm and skips any case a live session is working in. Removal stays on the existing DELETE /api/cases/:name. - Agent preamble caches are collected too: ~/.cache/codeman-agent-<id>.sh was written per claude session and never removed (236 leftovers measured on a working machine). Now deleted with the session and swept at boot, guarded by a live-session keep set plus a 7-day age floor. Verified end to end on an isolated instance: marker written for header, body and lineage-only spawns, absent with no agent signal and for a pre-existing directory; inUse flipping on session end; badge, sticky bar, confirm and sweep driven in a browser; preamble seeded on create, removed on delete, boot sweep taking only the aged orphans. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
82b090c74a |
fix(terminal): recover dropped keyCode 229 input
Android/GBoard-style keyboards fire keydown with keyCode 229 and, on some paths, never mutate xterm's helper textarea. xterm has nothing to diff, so it emits no data and the typed character is silently dropped: it never reaches the PTY and never appears on screen. terminal-keycode229-recovery.js is a standalone controller that re-emits exactly those keys, and only once. xterm stays authoritative throughout: - Only an explicit keyCode 229 keydown carrying a single printable key (or Enter) is eligible; Process/Unidentified/Dead, modifiers, AltGraph and a live composition are all left alone. - The re-emit is scheduled from a microtask and then a zero-delay timer, so xterm's own textarea diff always gets the first opportunity; canonical data for the same key cancels the pending fallback. - compositionstart and blur drop every pending candidate, so a real IME composition lifecycle is never second-guessed. - After a recovery, one late canonical value attributed to that key token (via beforeinput/input on the helper textarea) is suppressed so the character cannot be delivered twice; the record expires after 250ms and an unattributed byte is never suppressed. terminal-ui.js wires it at the two existing choke points — the custom key handler and the onData registration, the latter now a named handler so the recovery path can re-enter it — with both hooks wrapped so a failure in the fallback can never break canonical input. Unit coverage drives the module directly in a vm; the wiring itself is covered end-to-end in the (browser-only) terminal-copy-shortcut suite. |
||
|
|
2f9663e389 |
Merge branch 'master' into feat/codex-resume
master and this branch both rewrote the two `_claudeSessionId` resets inside
`start()`, so `src/session.ts` conflicted at both of them.
master's commit
|
||
|
|
92af855ce4 |
fix(base-path): keep the crash beacon under the mount, strip CODEMAN_BASE_URL in tests, add the wiring test
The merge-time items from the #381 review. navigator.sendBeacon is not fetch, so the base-aware wrapper never saw the two crash-diag beacons and a sub-path install posted them to the origin root every two seconds. The test suite now strips CODEMAN_BASE_URL like CODEMAN_GESTURE, since the constructor reads it as a fallback and an operator who exports it would see the root-install byte-identity assertions fail. test/base-path-server.test.ts boots a real WebServer under /codeman and checks the ingress strip, the base injection, the rebased redirects, the 404 envelope and a prefixed WebSocket upgrade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
cfc8fe7e41 |
Merge pull request #381 from mtiller/feat/reverse-proxy-base-url
feat(web): support a reverse-proxy base URL |
||
|
|
80397fe140 |
fix(hooks,mobile): the merge-time items from the #367 and #368 reviews
#367 (UserPromptSubmit hook): `hook:prompt_submitted` went on the wire unregistered; it is now in both SSE registries (158 = 158), and the hook only lands in the run summary when the conversation actually moved, since one row per prompt would evict useful rows from the 1000-event FIFO and clutter the Summary timeline and /api/search. #368 (Add Case header submit): the pending-state dimming targeted the footer button, which the <=860px layout hides, so on a phone the only visible submit control stayed at full brightness while a clone ran. The header button now dims too, and a static test pins the header-submit contract so it cannot silently disappear again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
bca1b764cc |
Merge pull request #367 from shenlvkang-collab/pr/claude-conversation-first-hand
fix(session): learn the live Claude conversation from the CLI's own hook |
||
|
|
1c1773278f |
Merge pull request #369 from shenlvkang-collab/pr/claude-response-viewer-per-message
fix(web): render one Claude response-viewer message per model message |
||
|
|
327e440607 |
fix(codex): fold a codex session into its own rollout row
Review fixes for #386. Duplicate rows. A codex conversation showed twice, once live and once as a past rollout row, because nothing aliased a codex session to its thread id. That is worse than cosmetic: the stale row still resumes, so clicking it starts a second `codex resume` on a thread already open in another pane. - A RESUMED session knows its thread id up front, so it folds from its own side: add `codexConfig.resumeSessionId` to the `claudeSessionId` chain. Not only in the constructor — `start()` recomputes that id at two further points (the mux branch, and the unconditional "third reset point" whose own comment already warned that omitting omp's fallback there stomps the mux branch's resolved alias). Both listed Claude's and omp's ids only, so for codex every mux reattach and boot recovery reset the alias back to the Codeman id and the duplicate returned. - A FRESH session has no thread id until codex writes the rollout, so it is folded from the other side. The scanner now reports `session_meta.originator`, which is `codeman_<sessionId>` for every pane Codeman spawns, and `gatherUnifiedInputs()` stamps the matching live and persisted rows, newest rollout winning (`/new` inside the TUI leaves several rollouts sharing one originator). - Persisted rows read `codexConfig.resumeSessionId` too. A resumed session demoted to a persisted-only record would otherwise lose its alias, and the originator fallback cannot rescue that one: a resumed rollout keeps its ORIGINAL session_meta, so it still names the pane that created the thread rather than the pane that resumed it. Identity cache. It was written as soon as the thread id was known, but codex writes the first user message only when the user submits, so any scan in that window pinned `firstPrompt: undefined` for the life of the process — and the home screen, the command palette and the search-index refresh all scan. `shouldCacheIdentity()` now keeps an identity only once the prompt is known or the head read filled its whole window. Also from review: both caps count emitted rows rather than file index, so a store of sub-agent threads no longer spends the `lastPrompt` budget before the first row that needed it; the cache is an `LRUMap` sized like the one beside it; the unreachable filename fallback is gone; a rollout recording no cwd is dropped rather than emitted with `workingDir: ''`; and the unified-session module header names all three transcript stores. Tests. The resume wiring now has cases for a row with a thread id, a row without one, and a `resumeId` on a non-codex row; the "no continuation is wired" case narrows to gemini/antigravity, which is no longer true of codex. `codex-resume-alias-survives-start.test.ts` drives a real Session through `start()` rather than asserting on pre-stamped inputs — that gap is why the reset points went unnoticed. Plus the maintainer's own cache repro, the tail-budget case, a no-cwd case, and merge cases for both folds. |
||
|
|
9f5010aa51 |
fix(pr-bot): announce a bot-made merge once, cap automatic retries, show why a review failed
Observed on the first live merge (#383 via the Telegram button): runConfirmed announced the merge and the scan five seconds later announced it again as a closed PR. The scan now stays quiet for PRs the bot itself merged or closed, and a merge of a `merge-with-fixes` verdict reminds that merging applies none of the listed fixes. A failed review used to be re-queued on every scan with no limit (two PRs failed once each and were retried fine, but a head that keeps failing would cost a session every ten minutes forever): three failures on one head now stop the automatic retries until /review N or a new push. The failure notice carries the reviewer's last message, so "finished without writing report.json" says what it wrote instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
f33b37c008 |
feat(pr-bot): review open PRs in Codeman sessions and report over Telegram
Maintainer tooling in scripts/pr-bot/: a daemon (systemd user unit
codeman-pr-bot) that lists open PRs with gh, reviews each head commit once in
a Codeman claude session (`prbot-<n>`) running in a private `git clone
--shared`, and sends the verdict, ranked findings, checks and a recommendation
to Telegram with action buttons. Merge, close, post-comment and approve-CI
happen only from a Telegram command or button plus a confirmation tap; the
bot never writes to GitHub on its own. The Telegram token and chat id come
from the existing notifier bot's env file.
Verified live: three PRs reviewed end to end (383, 363, 368), reports
delivered with buttons, reviewer sessions on the pinned model. Findings
along the way, each fixed and documented: a linked worktree inherits the
main checkout's model pin (hence the shared clone), undici's 5-minute header
timeout cut off the first review, gh was missing from the service PATH, and
the periodic scan orphaned an in-flight review's record.
typecheck/lint/format now cover scripts/pr-bot; tests in
test/pr-bot-{report,state,commands}.test.ts; guide in docs/pr-bot.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
097d585278 |
Merge pull request #383 from opticon454/fix/case-picker-default-root
fix(file-picker): default the case picker to Codeman Cases, not Home |
||
|
|
8285fff91c |
feat(codex): list codex conversations and resume them
Codex conversations never appeared in the session list, and the resume path skipped codex, so picking one back up meant finding its thread id by hand and POSTing codexConfig.resumeSessionId to /api/sessions. Two gaps caused it: - The unified list is built from ~/.claude/projects plus omp's own store. Codex writes to neither: its rollouts live in ~/.codex/sessions/<y>/<m>/<d>. - terminal-ui.js sends a continuation only for the CLIs with a "continue most recent" flag. Codex has no such flag — it names a thread by an exact id — and nothing supplied one. Add codex-transcript.ts, the codex analog of omp-transcript.ts, and wire it into gatherUnifiedInputs() beside the omp scan. A rollout row carries `resumeId`, the thread id `codex resume` takes, and the resume path sends it as codexConfig.resumeSessionId. `resumeId` is what keeps the two kinds of row apart: only a transcript scanner sets it, so a LIVE codex row — whose sessionId is Codeman's own uuid — can never ask codex for a thread that does not exist. Three things measured against a real store of 519 rollouts rather than assumed: - Rollouts are far too large to read whole (median 407 KiB, p90 1.3 MiB, max 25 MiB, 381 MiB total), so this reads a 128 KiB head for the identity and the opening prompt and a bounded tail for the most recent one. session_meta is written once and never rewritten, so per-path identity is cached; a warm rescan of that store costs ~75ms against ~470ms cold. - codex 0.152.1 emits no event_msg/user_message rows at all. It writes event_msg/item_completed carrying an item.type of UserMessage. Both shapes are read, plus response_item as a last resort. - That last resort sees injected context, and the first such row is the repo's AGENTS.md every time, so injections are dropped rather than used as titles. Sub-agent threads (thread_source: 'subagent') are left out; codex spawns them for itself and on a real store they outnumber the resumable threads. |
||
|
|
51957e2ed4 |
fix(session): let each CLI declare how its own pane shows work
A Codex session reported `isWorking: false` for its entire life, including
mid-turn. Codeman has four paths that mark a session working, and all four were
inert for Codex:
- The spinner fast path tests eight braille frames, and Codex animates none.
- The activity-streak fallback was wrapped in `!isExternalCliMode(mode)`.
- The pane probe inside `_confirmIdle` would have matched, since Codex prints
`esc to interrupt`, but arming it required the literal glyph `❯` and Codex
draws `›` on its composer row.
- The text detector sat inside `_processExpensiveParsers`, whose first statement
returns early for an external CLI.
Add an optional `workDetect: { promptGlyph, workingLine }` to CliCapabilities,
so the two strings that differ per CLI are registry data rather than constants
in the detector. Claude declares its existing pair and behaves as before. Codex
declares `›` and `esc to interrupt`. The text detector moves above the
external-CLI early return, guarded on the descriptor so a CLI without one still
skips the ANSI strip that the early return used to save it.
A CLI that declares no descriptor falls back to Claude's pair, and the
activity-streak gate now reads "has a descriptor, or is not external", so the
plain shell mode keeps the behaviour it had.
Rewrite the test that asserted the old premise in its own comment, so it makes
the same guarantee for a genuinely uncharacterised CLI, and add Codex coverage
built from verbatim pane captures on Codex CLI 0.152.1.
|
||
|
|
8ad2215118 |
fix(docker): close the three adoption gaps the negative guarantee missed
Review follow-ups to #357. Each is a path that still touched, or still hid, a container Codeman does not own. **Export still mutated it.** The four fail-closed layers cover create/start/ stop/remove, but `POST /api/docker-cases/:name/export` reaches the container twice through neither: a full export `docker commit`s it, and even a workspace-only export `docker pause`s it first for snapshot consistency. Pause freezes the owner's processes for as long as the tar takes, on a container we promised not to touch. Full export is refused for an adopted case (it packages someone else's container, with their logins, into a bundle Codeman hands out); workspace-only keeps working and no longer pauses, accepting a live filesystem the way `tar` does on any running host directory. **A freshly linked OWNED case became unusable.** The run menu now probes the container for its CLIs, and a failed probe hides every agent mode behind the reason. For an adopted case that is right. For an owned one the container does not exist until the first session launches it, so every newly linked Docker case answered `container "codeman-case-x" not found (adoption never creates a container — start it yourself first)` and offered nothing but Shell, for a container the launch chain was about to create itself. A failed probe is recorded only when the case is adopted; `CaseInfo.docker.owned` is on the wire so the frontend can tell them apart. Verified in a browser: owned-with-no- container offers all ten modes and no notice, adopted-but-stopped offers Shell and says why. **Multi-user gating.** Adoption is admin-only, unlike `docker-link` beside it. Linking creates OUR container, whose sole bind mount `isWorkingDirAllowed` has already confined to the caller's space; an adopted container's mounts are whatever its owner gave it, so one mounting `/` hands the adopter a shell over the whole host — exactly the workspace scoping multi-user mode exists to enforce. Listing the engine's containers and browsing directories inside an arbitrary one are machine-level reads and follow the docker-HOST policy for the same reason. The preflight is deliberately not admin-only: the run menu fires it for every docker case, so it admits a non-admin for a container already linked to a case they can access, and nothing else. Verified end to end against a real pre-existing root container (alpine + tmux, no bind mounts): adopt, claude session inside it, workspace export, session close and case unlink all left `StartedAt`, `RestartCount`, `Pid` and `Paused` untouched; the pane ran the CONTAINER's claude, without `--dangerously-skip-permissions`; a stopped container was refused at both preflight and launch and was never started. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TecFD9hvPYJ1mkkMtBQbT1 |
||
|
|
3d8ffcb9a2 |
Merge pull request #357 from dignfei/feat/docker-adopt-existing-container
feat(docker): attach a case to an already-running container Conflicts came from work that landed after the PR was opened, and each is resolved onto the newer abstraction rather than by keeping the older code: - `defaultDockerCommandForMode` is registry-driven since #347, so the PR's `runsAsRoot` arm became `overlays.docker.rootCommand` (claude only). Claude Code still refuses `--dangerously-skip-permissions` as root in 2.1.261 and the refusal is visible only inside the container, so an adopted root container otherwise just shows a dead pane. Which flag to drop is a per-CLI fact, and `test/cli-registry-no-id-branching.test.ts` forbids expressing it as a branch. - The probe's mode list and its mode -> binary table both duplicated the registry. They now read `enabledCliIds()` / `discovery.binaries[0]`, which is also what fixes the merge's silent regression: the hand-written list predates `omp`, and the run menu gates every docker case on this probe, so owned containers would have lost that mode. `shell` needs no arm — it declares no binary, so it is dropped from the lookup and reported available regardless. - The per-mode `mode === 'claude' && !cliDir` chain in `tmux-manager.ts` is one `missingCliMessage(mode)` gate since #347; the PR's docker exemption moved onto it. Its test now pins the single gate instead of counting seven arms. - The create arm keeps #349's swap-limit warning filter, which the adopted arm never reaches; the run-mode list gains `omp` from #353. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TecFD9hvPYJ1mkkMtBQbT1 |