Compare commits

...
Author SHA1 Message Date
Codeman maintainer f1f7eec4d6 chore: version packages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:10:31 +01:00
Codeman maintainer 1ab01bb7ef chore: one changeset for the release
Consolidate this release's notes into one changeset (Thanks, the tab
strip, the fixes, the fixes applied while landing) and drop #730's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:35:33 +01:00
Codeman maintainer 8074f67c6a fix: keep the soft-keyboard recovery on Android with a fine pointer (release review, #726)
#726 attaches the keyCode 229 recovery only when (pointer: coarse)
matches, which keeps a desktop dead key from arriving twice. Chromium
reports the PRIMARY pointer as fine once a mouse, a trackpad cover or a
stylus is attached, so on such an Android device the module was off
while the text still came from the soft keyboard, and the #441 dropped
last character and the #541 autocorrect duplication came back.

The gate is now a coarse pointer OR an Android user agent. Android ran
the recovery unconditionally before #726, so this only keeps that; a
desktop (fine pointer, not Android) and a touchscreen laptop stay off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:35:20 +01:00
Codeman maintainer ca0f891c2b fix: never read a remote fix-plan path from the local disk (release review, #722)
setRemoteWorkingDir() records the path for authority only, and today
nothing else reads it for a remote session (loadFixPlanFromDisk's only
callers are setWorkingDir and the local watcher). The watcher now
remembers the remote mode and refuses both the local read and the local
watch for it, so a future caller cannot import a same-named local twin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:34:05 +01:00
Codeman maintainer 9f7cba07ab fix: strip nested paste markers until none is left (release review, #726)
Ctrl+V's hand-built bracketed paste (#726) and the phone composer's
dropped CSI 200~/201~ markers from the text in ONE pass, so a clipboard
carrying ESC[20 ESC[201~ 1~ came out of it with an ESC[201~ that ended
the frame early: everything after it was typed as keys and each CR ran.
Both now strip until nothing matches. The composer's copy predates this
release; it is the same one-liner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:30:15 +01:00
Codeman maintainer 1ad9db7606 fix: keep a remote session's fix plan authoritative (release review, #722)
#722 stopped handing a remote session's workingDir to the Ralph tracker,
which also left isFileAuthoritative false for remote sessions only. That
flag is what keeps the output-based completion guessers off, and it is
true for every local session with a working directory, so a remote loop
could end, and mark every todo done, on a prose line such as "All tasks
completed" while a local one cannot.

setRemoteWorkingDir() records the path without reading or watching the
local disk, so the fix plan stays authoritative as for a local session;
the file still reaches the tracker only through fix-plan/read, over ssh.
The test pins both, and fails on the previous head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:25:50 +01:00
Codeman maintainer 593b2eb2ea Merge pull request #721: feat(tabs): L-shaped header strip, wrapped rows run under the toolbar 2026-10-28 09:10:09 +01:00
Codeman maintainer 5add56eaf6 Merge pull request #726: fix(terminal): don't double a desktop dead key, and bracket Ctrl+V after replay 2026-10-28 09:10:09 +01:00
Codeman maintainer 9ee6df027d Merge pull request #730: fix(file-preview): let rendered Markdown fill the resized window 2026-10-28 09:10:08 +01:00
Codeman maintainer f1df8f8c0a Merge pull request #731: fix(response-viewer): a middle click on a file path opens it instead of the dashboard root 2026-10-28 09:10:07 +01:00
Codeman maintainer ad3f6328e6 Merge pull request #725: fix(web): accept empty chunked API POST requests 2026-10-28 09:10:07 +01:00
Codeman maintainer 6d640b08ce Merge pull request #728: fix: read a remote session's @fix_plan.md on its host, refuse remote Ralph writes 2026-10-28 09:10:06 +01:00
Codeman maintainer 9fec26009f Merge pull request #727: fix: don't point the image watcher at a remote session's directory 2026-10-28 09:10:06 +01:00
Codeman maintainer 66528962ee Merge pull request #722: fix: don't point the Ralph @fix_plan.md watcher at a remote session's directory 2026-10-28 09:10:05 +01:00
Codeman maintainer 20cd0226a5 Merge pull request #729: fix(i18n): drop the duplicate ZH_CN "Idle" key 2026-10-28 09:10:05 +01:00
Codeman maintainer cdff4d4d98 fix: apply the review items for #721 at merge
Add the L-shaped strip to the Tab layouts section of
docs/architecture-invariants.md, the mechanism file src/web/CLAUDE.md
links to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:09:56 +01:00
Codeman maintainer c708312a3f fix: apply the review items for #730 at merge
Set --rv-content-max: none on the File Viewer's rendered document, so
ASCII-diagram blocks (capped by that variable with a 720px fallback the
Response Viewer sets) follow the window like the rest of the document,
and pin it with a third assertion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:09:37 +01:00
Codeman maintainer 79a7f494b1 fix: apply the review items for #728 at merge
Export isPathWithinRoot from file-routes.ts and use it for the remote
@fix_plan.md confinement, so the two checks cannot drift; rewrap the
readRemoteFixPlan JSDoc and say NOT_FOUND; list fix-plan/read and the two
write refusals in docs/remote-sessions.md and architecture-invariants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:09:12 +01:00
Codeman maintainer dd56674a1d fix: apply the review items for #727 at merge
toggleSessionImageWatcher checks the response, so any refusal puts the
switch back and toasts an error instead of a success; architecture-
invariants says the image watcher never watches a remote workingDir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:08:03 +01:00
Codeman maintainer 74fc3ea6ae fix: apply the review items for #722 at merge
Note in docs/remote-sessions.md that the @fix_plan.md auto-load does not
run for remote sessions, and rewrap the test's header comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:07:28 +01:00
Codeman maintainer ad2f06ce8b fix: apply the review items for #725 at merge
Add the @fileoverview the repo carries on every module: why the parser
exists, why it is POST + /api/ only, and that it owns the root '*'
content-type slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:06:52 +01:00
DDTandDDT 6f820ca990 fix: deliver Ctrl+V paste through terminal.input
Hand the bracketed frame to the target terminal with input() so onData
scrolls to the bottom, clears the selection, and delivers the bytes.
The paste trap no longer writes the frame itself.

Fixes #716

Co-authored-by: DDT <1786035110@qq.com>
2026-10-28 07:09:35 +00:00
JD b63935fa66 fix(response-viewer): a middle click on a file path opens it instead of the dashboard root
Path links and in-document links carry a placeholder href (#, #fragment) that the click delegate resolves itself, but a middle click fires auxclick, never click, so the browser followed the placeholder and opened the dashboard root in a new tab. A middle click on those links now does what a click does; real URLs keep the browser's own middle click.
2026-10-28 00:34:50 -04:00
timkjr 591eebe6e5 fix(i18n): drop the duplicate ZH_CN "Idle" key
The Approvals Inbox strings added a second `Idle: '空闲'` to the ZH_CN
table, beside the original in the status words. Same value, so nothing
changed on screen, but every build warned:

  [WARNING] Duplicate key "Idle" in object literal [duplicate-object-key]
2026-10-27 09:52:07 -05:00
timkjr 46122247e4 fix: read a remote session's @fix_plan.md on its host, refuse remote Ralph writes
A remote-SSH session's workingDir is a path on the remote host, but the
Ralph file routes used local fs on it: fix-plan/read imported a same-named
LOCAL @fix_plan.md (or 404'd), and fix-plan/write and ralph-prompt/write
wrote their files into the local twin of the remote folder. Same root
cause as the Ralph @fix_plan.md watcher fix.

- fix-plan/read reads the file on the remote host through
  src/remote-files.ts (the File Viewer's ssh path): one probe of the file
  and the workspace, the File Viewer's confinement (a file resolving
  outside the workspace is treated as missing), then a capped read (1 MB).
  A missing remote file is the same NOT_FOUND as a missing local one; a
  non-file, an oversized file or an unreachable host is a failed read,
  never a local fallback.
- fix-plan/write and ralph-prompt/write refuse a remote session
  (INVALID_INPUT): remote writes are out of scope by design, as for the
  File Viewer's remote edit. The Ralph panel already shows the error.
2026-10-27 09:29:18 -05:00
timkjr 2c2f69adba fix: don't point the image watcher at a remote session's directory
A remote-SSH session's workingDir is a path on the remote host, and the
image watcher watched it on the local disk: a folder that is not there,
or a same-named local folder whose images then popped up as the remote
session's. Same root cause as the Ralph @fix_plan.md watcher fix.

Three places start a watch, and all three now skip a remote session:
setupSessionListeners(), the app-wide toggle in PUT /api/settings (which
re-watches every enabled session), and POST /api/sessions/:id/image-watcher,
which refuses to turn it on (INVALID_INPUT, like the Git fetch settings
route) and still lets it be turned off. The Session Options row is hidden
for a remote session, the way the Git tab is, since the client's
_apiPost() does not throw on a refused request.
2026-10-27 09:26:17 -05:00
DDTandDDT dd09a383f1 fix: stop desktop dead-key doubling and bracket Ctrl+V paste
KeyCode 229 recovery forwarded a composed character that xterm had
already delivered, so a desktop dead-key backtick arrived twice.
create() now attaches only when the primary pointer is coarse.

Ctrl+V text paste went through terminal.paste(), which drops the
CSI 200~/201~ markers after a replay clears xterm's DECSET 2004
mirror. Plain text is now a hand-built bracketed frame on the
session input path.

Fixes #716

Co-authored-by: DDT <1786035110@qq.com>
2026-10-27 12:06:45 +00:00
kdxcxs b0487cc9af fix(web): accept empty chunked API POST requests
Handle untyped zero-byte POST streams at the shared API parser boundary.
Keep request.body undefined and preserve existing typed parsers, 404
fallbacks, authentication and webview proxy stream handling.

Reject nonempty untyped streams on the first byte, and cover parser
boundaries, raw HTTP framing and real WebServer routing with regressions.
2026-10-27 16:22:00 +08:00
timkjr 285aa0b2b2 fix(tabs): keep the plain wrap when the strip scrolls, add a browser test
Review follow-up for the L-shaped header strip:

- _syncTabStripCorner() keeps the plain wrap when the plain strip is
  already past --tab-strip-max-height, checked before .tabs-corner goes
  on: the float scrolled with the tabs while the toolbar stayed put, so a
  scrolled strip put tabs under #headerRight, and a re-render of a
  scrolled two-rows strip sized the float from the scrolled frame. The
  "- scrollTop" in contentTop is gone (always 0 now).
- A strip just under its max-height that passes it once the float is in
  falls back to the plain wrap as well.
- test/tab-strip-corner.browser.test.ts (real Chromium, real header and
  styles.css), listed in BROWSER_TEST_GLOBS.
- src/web/CLAUDE.md Tab layouts entry describes the L-shaped classic strip.
2026-10-26 21:35:04 -05:00
timkjr 0a56c1445f fix: don't point the Ralph @fix_plan.md watcher at a remote session's directory
A remote-SSH session's workingDir is a path on the remote host, but
setupSessionListeners() handed it to the Ralph tracker, whose
RalphFixPlanWatcher reads and fs.watch()es it on the LOCAL disk. Where the
path does not exist locally (the usual case), every server start logged

  [RalphFixPlanWatcher] Could not watch @fix_plan.md: Error: ENOENT ...

once per remote session; where a same-named local directory did exist, it
watched the wrong tree. Setting the path also made the tracker treat that
unreadable file as authoritative (isFileAuthoritative is "a path is set"),
which switched off its output-based todo completion for remote sessions.

Skip the tracker's working dir for remote sessions, next to the existing
external-CLI skip.
2026-10-26 19:53:57 -05:00
timkjr 3dafc89095 feat(tabs): L-shaped header strip, wrapped rows run under the toolbar
A wrapped classic tab strip stopped at the toolbar's left edge on every row,
so the space under the header chips stayed empty and long tabs took a row
each. Once the strip wraps, the toolbar now sits over the strip's top-right
corner and the strip lays its tabs out inline around a float sized to the
toolbar's box (_syncTabStripCorner), so only the rows beside the toolbar stop
short of it and every later row runs the header's full width.

- Wrap is still measured with the toolbar in flow (the corner is dropped
  before the measure), so the single-row strip is unchanged.
- A ResizeObserver on the toolbar re-decides wrap + corner and redraws the
  lineage routes when a reading or the divider changes its size.
- Falls back to the plain strip when the first tab cannot fit beside the
  toolbar (it would otherwise leave an empty band).
- Classic arrangement, desktop, header host only; state/case/ledger, the
  sidebar and the tab rail keep their own layouts.
2026-10-26 19:19:06 -05:00
DevvynandClaude Code 34f7766d43 fix(file-preview): let rendered markdown fill the resized window
The corner grip and Expand size the window correctly, but the document
kept a fixed 960px reading column from before the resize feature
existed, so text stopped reflowing once the window grew past it.
Pinned by new rule 13 in file-preview-window.test.ts.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-25 09:56:40 +08:00
42 changed files with 2269 additions and 71 deletions

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@
"name": "codeman",
"source": "./plugins/codeman",
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
"version": "1.46.0",
"version": "1.47.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+25
View File
@@ -1,5 +1,30 @@
# aicodeman
## 1.47.0
### Minor Changes
- 1ab01bb: ### Thanks
- @timkjr for five PRs: a wrapped tab strip that uses the space under the toolbar (#721), three fixes that stop Codeman reading and watching a remote session's folder on the local disk (#722, #727, #728), and a quieter build (#729).
- @kdxcxs for bodyless requests that work through Cloudflare Tunnel (#725), traced through a real Access + Tunnel hostname.
- @Hugo-DDT for a desktop dead key typed once and a Ctrl+V that stays a paste after a replay (#726), and @hedburgaren for reporting both in #716.
- @opticon454 for rendered Markdown that fills a resized File Viewer (#730).
- @JDProfresh for a middle click on a file path that opens the file (#731).
**The wrapped tab strip uses the room under the toolbar (#721).** When the classic header strip wraps onto more rows (automatically, or with the two-rows setting), only the rows beside the toolbar stop short of it now; every row below runs the full header width, so long tab names no longer end up one per row. A strip that fits on one row looks exactly as before, and a strip tall enough to scroll keeps the plain wrap.
**Fixes.**
- **Bodyless requests work through Cloudflare Tunnel (#725).** cloudflared forwards a POST with no body as an empty chunked request with no Content-Type, which the server refused with `415` before the route ran, so actions such as opening a saved web tab failed through a tunnel. An empty, untyped POST under `/api/` now reaches its route exactly like a `Content-Length: 0` one; anything with a body or another method is still refused as before.
- **A desktop dead key is typed once, and Ctrl+V stays a paste after a replay (#726, #716).** The soft-keyboard recovery for Android IMEs now stays off on a desktop (a fine pointer that is not Android), so a dead-key backtick no longer arrives twice. Plain-text Ctrl+V is now framed as a bracketed paste by Codeman itself, because xterm stops adding the markers once a terminal replay clears its paste mode, and a copied newline then submitted the prompt. The paste still scrolls to the bottom and clears the selection, in the main pane, grid tiles and the split pane.
- **Remote sessions no longer touch the local disk at their path (#722, #727, #728).** A remote (SSH) session's folder is a path on the remote host, but the Ralph `@fix_plan.md` watcher and the image watcher watched it on this machine (logging `ENOENT` at every start, or watching an unrelated local folder of the same name), and the Ralph file routes read and wrote it locally. Both watchers now skip remote sessions (the Session Options image-watcher switch is hidden there), the Ralph menu's **Import from File** reads `@fix_plan.md` on the remote host over the same ssh path the File Viewer uses, and **Write to File** (and the `@ralph_prompt.md` write) is refused for a remote session instead of landing in a local folder.
- **Rendered Markdown fills a resized File Viewer (#730).** The document kept a fixed 960px column from before the window could be resized, so it stopped reflowing once you dragged or expanded the window past it.
- **A middle click on a file path opens the file (#731).** In the Response Viewer and the File Viewer's rendered Markdown, a middle click on a file path or an in-document link opened the dashboard root in a new tab. It now does what a left click does; real web links keep the browser's own middle click.
- **No more duplicate-key warning on build (#729).** The Chinese translation table carried `Idle` twice.
**Fixes applied while landing.** A remote session's fix plan stays authoritative as it is for a local one, so a prose line such as "All tasks completed" cannot mark every todo done and end a remote Ralph loop when it cannot end a local one (#722). The Session Options image-watcher switch flips back and says so when the server refuses the change, instead of reporting success (#727). The remote `@fix_plan.md` read uses the File Viewer's own containment check rather than a copy of it (#728). ASCII-diagram blocks in the File Viewer's rendered Markdown follow the window instead of staying 720px wide (#730). A paste marker hidden inside another one (`ESC[20` + `ESC[201~` + `1~`) can no longer survive the strip and end the bracketed paste early, in Ctrl+V and in the phone composer alike, and an Android device with a mouse, a trackpad cover or a stylus keeps the soft-keyboard fixes, which turned off there because Chromium reports such a pointer as fine (#726).
**Docs and tests.** The L-shaped strip in the frontend notes and the architecture notes, the remote-session rows for the Ralph file routes and the two watchers in the remote-sessions guide and the architecture notes, and an overview of the empty-POST parser in its module.
## 1.46.0
### Minor Changes
+1 -1
View File
@@ -52,7 +52,7 @@ Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@chan
When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement.
**Version**: 1.46.0 (must match `package.json`)
**Version**: 1.47.0 (must match `package.json`)
## Project Overview
+2 -2
View File
@@ -3,7 +3,7 @@
"name": "io.github.Ark0N/codeman",
"title": "Codeman",
"description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman",
"version": "1.46.0",
"version": "1.47.0",
"websiteUrl": "https://getcodeman.com",
"repository": {
"url": "https://github.com/Ark0N/Codeman",
@@ -13,7 +13,7 @@
{
"registryType": "npm",
"identifier": "aicodeman",
"version": "1.46.0",
"version": "1.47.0",
"transport": {
"type": "stdio"
},
+1
View File
@@ -28,6 +28,7 @@ export const BROWSER_TEST_GLOBS = [
'test/tab-focus.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
'test/header-divider.browser.test.ts',
'test/tab-strip-corner.browser.test.ts',
'test/header-host-sensors.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts',
+5 -3
View File
@@ -81,13 +81,13 @@ OMP's registry entry declares `privilegedParams: []` and a launch spec that only
Further detail: ⚠️ **Auto-reconnect revives ONLY when the durable remote tmux session is verifiably still alive** (`remoteTmuxSessionAlive()`, a `has-session` probe over ssh, #355): a clean agent exit (Ctrl-C, Ctrl-D, `exit`) tears that session down, and `isPaneDead()` cannot tell it from a transport drop, so the watcher used to relaunch a FRESH agent after every clean exit (claude only looked fine because its `|| --resume` fallback masked it). An unreachable host answers `undefined`, which also means do not revive. ⚠️ `has-session` prints NOTHING on success, so the probe is classified by EXIT STATUS (`classifyRemoteAliveExit`: 0 alive, ssh's 255 or a timeout unknown, anything else gone); reading stdout classified every live session as gone and silently disabled transport-drop reconnects. The answer is cached per session and forgotten whenever the pane is seen alive again, or a stale `true` from one transport drop would revive the next clean exit.
File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk. ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links. The third surface is a remote session's full-history load, which reads the scrollback from the remote tmux (`src/remote-pane-history.ts`) through the same limiter, with a bounded slot wait (`maxWaitMs`) since the local capture is its fallback.
⚠️ The image watcher never watches a remote session's `workingDir` (a path on the remote host, which it would watch on the LOCAL disk): every call site that starts a watch skips `session.remote` (`setupSessionListeners()`, the app-wide toggle in `PUT /api/settings`, and `POST /api/sessions/:id/image-watcher`, which refuses `enabled: true` with `INVALID_INPUT`), so a new call site must too. File reads in a remote case are the second ssh surface (see Remote SSH cases below). The reason writes, office previews and thumbnails are deliberately unsupported over ssh is that no remote file is ever copied onto the server's disk. ssh children are bounded because terminal output in a remote session is written on the remote host, so a prompt-injected agent there can print hundreds of `codeman://attach` links. The third surface is a remote session's full-history load, which reads the scrollback from the remote tmux (`src/remote-pane-history.ts`) through the same limiter, with a bounded slot wait (`maxWaitMs`) since the local capture is its fallback.
### Remote SSH cases
**Remote host wake-on-LAN from user input**: an optional `RemoteHost.wakeMac` (magic packet built and broadcast by Codeman) or `RemoteHost.wakeCommand` (a single executable path, run WITHOUT a shell, and the explicit override) lets the input route — and an explicit `POST /api/sessions/:id/wake` — wake a SLEEPING host instead of writing into a stalled ssh pane; `tmux send-keys` succeeds against a stalled pane, so the bytes used to vanish silently. The wake flow lives in `src/remote-wake.ts` and is reachable **only** from an EXPLICIT user request: `POST /api/sessions/:id/input`, that explicit wake route, and the create/attach path (`POST /api/quick-start` for a remote case, `POST /api/sessions` with `attachRemoteSession`, via `ensureHostAwake`), because "the user pressed Run on a sleeping host" is the same kind of request and the tmux probe would otherwise fail with a misleading "needs tmux installed". Everything TIMER-driven must never wake a host: the COD-108 auto-reconnect watcher, `Server.handleRemoteSessionDropped` and boot recovery have no access to the registry, or a host would be re-woken seconds after each suspend and could never stay asleep (asserted by wiring guards in `test/remote-wake.test.ts`, not just documented — including that `ensureHostAwake` is called from the HTTP route only, since `cron-service.ts` builds sessions through the shared service with nobody waiting on the answer). `GET /api/sessions/:id/reachability` only ASKS — it never wakes — and feeds the amber "host unreachable" banner (`host-wake-ui.js`) whose action is either Wake or, with no target configured, "Configure WoL" → `#wakeConfigModal` (saved via `PUT /api/remote-hosts/:id`). Detection is a throttled bare TCP probe (no ssh, no `ServerAliveInterval` — keepalives would move bytes into an idle connection every interval; and a host behind a jump host/SOCKS proxy is reachability-UNKNOWN, never "asleep": `isProbeable()` keeps the registry from buffering, gating or bannering on a probe that cannot reach it), input is buffered and flushed in order after `reattachRemote()` (the send-and-wait path blocks instead, as does the create path, with a shorter request budget), and the wake fields are re-read from `remote-hosts.json` on recovery AND (throttled, cached) live for a running session, because the persisted `remote` snapshot would never see a field added later (`rehydrateRemoteHostFields` + `RemoteWakeDeps.resolveRemote`). Design + invariants: `docs/remote-sessions.md` §Wake-on-LAN from user input.
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: <ssh line>` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. Tests: `test/remote-files.test.ts`, `test/routes/file-routes-remote.test.ts`.
**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh <host>` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-<id>` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to an idempotent `claude --session-id <id> || claude --resume <id>` pair under a login shell, so a respawn or reattach continues the SAME conversation rather than starting a fresh one (remote omp gets the same treatment via `--continue`; ⚠️ because the claude arm is an `a || b` pair under `-c`, that pane's PID is the login shell, not the agent); per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`. ⚠️ **Reading a file in a remote case goes over ssh too** (#415): `src/remote-files.ts` is the single remote-READ layer (`buildRemoteFileCommand` = `buildSshConnectionArgs` + one shellescaped remote command; `remoteProbePaths` returns remote realpath + stat; `remoteCreateReadStream` streams a `Range` via `tail -c +N | head -c L` and its `close()` must be wired to the response's `close` or the ssh child outlives an aborted download). The guard order matches the local path exactly (`validateSessionFilePathLexical` → remote realpath of BOTH file and workspace root → containment → sensitive-path → size cap on the REMOTE size), a request path arrives from the browser and is only ever interpolated as a `shellescape`d token, and an unreachable host answers **502**, never a 404. ⚠️ The probe's symlink resolution FAILS CLOSED: `readlink -f` where it exists, otherwise a `cd -P`/`pwd -P` directory walk plus a bounded plain-`readlink` loop over the last component, and anything it cannot fully resolve is reported unresolvable (404), never as the unresolved string — the first version resolved the directory chain only, so on a host without `readlink -f` a `ws/notes.txt -> ~/.ssh/id_rsa` link passed containment under its own path while `cat` served the key. Records are NUL-separated and index-keyed so a newline in a filename cannot shift the mapping. ⚠️ ssh children are BOUNDED: probes and buffered reads go through `src/remote-ssh-limiter.ts` (a `document-conversion-limiter`-shaped semaphore, default 4), the attachment-history list probes its whole history in ONE batched call (`probeRemoteAttachmentHistory`, threaded into `registerExternalAttachment({remoteProbes})`), and probes chunk at 40 paths — a prompt-injected agent printing `codeman://attach` links in a remote session used to fork one `ssh` per link. `describeExecError` never returns Node's `Command failed: <ssh line>` message (identity path + probe script in a 502 body). The `PUT /file-content` guard sits AHEAD of `validateSessionFilePath`, which resolves LOCALLY, or a same-named local directory (an sshfs mount) takes the write. Under `VITEST` the three IO functions refuse rather than connect. This covers the ATTACHMENT routes too, which is the half a clicked path needs when the file is OUTSIDE the case directory (`_isExternalPreviewPath` sends it to `POST …/attachments`): registration, by-id `raw`, metadata and the history list all resolve over ssh (`registerExternalAttachment({remote})`, `resolveServableRemoteAttachment`), and what decides the host is the SESSION, never the path string — the same absolute path means a different file on each host. Deliberately NOT supported over ssh: writes (`edit=1`/`PUT` answer 400, `editable` is always false), office previews/thumbnails, the file tree/picker, `tail-file`. The Ralph file routes follow the same rules (`ralph-routes.ts`): `POST .../fix-plan/read` reads `@fix_plan.md` on the remote host through `remote-files.ts` (one probe of the file and the workspace, confined with the shared `isPathWithinRoot`, capped at 1 MB; missing or outside theLine truncated
**Remote tmux PATH**: every SSH command that invokes or locates tmux uses `withRemoteTmuxPath()` (`remote-hosts.ts`) before the complete remote script is shell-escaped: link preflight, launch/attach, alive probes, discovery, history/screen capture, and cleanup. Non-interactive SSH shells can omit Homebrew and per-user binaries even when the user's interactive shell finds them. The wrapper double-quotes the exported PATH value (including inherited PATH) so homes with spaces work under dash; it leaves the existing SSH options, socket/target and inner CLI login-shell handling intact. tmux gives a new pane the PATH of the client that created it (measured on tmux 3.4), so every new remote pane, agent or shell, now starts on the widened PATH before its login shell runs. Keep new remote tmux operations on this same path. `test/remote-tmux-path.test.ts` exercises all builders under real sh/dash with a sparse PATH and a home containing spaces, using offline SSH/tmux stubs. Local and Docker tmux commands keep their own setup.
@@ -817,7 +817,7 @@ Feedback is silent on success except ONCE per page load (a feature that works by
### Terminal paste (Ctrl+V)
**The Ctrl+V paste trap consumes exactly ONE paste event.** `_handleImagePaste()` (image-input.js) appends a hidden `contenteditable` div, focuses it, and reads the clipboard out of the paste event that lands there. An image becomes an upload whose saved path is typed into the session; text goes through `terminal.paste()`, so the bracketed-paste markers survive. Two independent routes deliver that event for one keypress, and a browser may fire both:
**The Ctrl+V paste trap consumes exactly ONE paste event.** `_handleImagePaste()` (image-input.js) appends a hidden `contenteditable` div, focuses it, and reads the clipboard out of the paste event that lands there. An image becomes an upload whose saved path is typed into the session; plain text is a Codeman-built bracketed paste (`\x1b[200~` … `\x1b[201~`, nested markers stripped, newlines as `\r`) handed to that pane's terminal via `terminal.input(framed)`. `terminal.paste()` only emits those markers while xterm's DECSET 2004 mirror is on, and a terminal replay clears that mirror (#716), so Ctrl+V builds the frame itself. `input()` fires onData as user input, which scrolls to the bottom and clears the selection. The main pane's onData bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, which is where the local-echo flush and the delayed write live (#219). A frame wrapped in CSI 200~/201~ does not match the query-reply or mouse-report filters. The terminal is refocused; the paste is not written into the xterm buffer. Two independent routes deliver that event for one keypress, and a browser may fire both:
1. **`document.execCommand('paste')`**, which the function calls itself. ⚠️ Its return value proves nothing about whether it fired. Firefox dispatches a trusted `paste` event carrying the real clipboard and still returns `false`, because the trap cancels the event and the command therefore never completes. Chromium refuses the command outright and dispatches nothing.
2. **The keydown's own default action.** The `Ctrl+V` branch in `attachCustomKeyEventHandler` returns `false`, which stops xterm from evaluating the key into `^V` but does not cancel the DOM event, for the same reason rule 1 of smart copy above spells out. `trap.focus()` has already run by then, so the browser sends its own paste to the trap as well.
@@ -1047,6 +1047,8 @@ Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` (
**Ledger.** CSS only, on `.tabs-ledger` inside `@media (min-width: 768px)` and the header host: the flat list on an auto-fill column grid with equal cells, mono type and a 3px status bar (`--ledger-bar`: yellow for a waiting alert, red for an action alert or an error, muted for stopped, exited and web tabs) instead of the dot. ⚠️ It must not change the markup (pinned: the ledger's innerHTML equals classic's); the rail, the sidebar and narrower strips show the plain list.
**Classic, wrapped: the L-shaped strip.** Once a classic desktop strip in the header host wraps (`tabs-auto-wrap`, or the manual `tabs-two-rows`), `_syncTabStripCorner()` (app.js) adds `.tabs-corner`: `#headerRight` goes `position: absolute` over the strip's top-right corner and the strip runs the header's full width, so only the row(s) beside the toolbar stop short of it and every later row uses the width under it. A flex line cannot be shortened on its own, so in that state the strip is `display: block` with `inline-flex` tabs flowing around a `::before` float sized from the toolbar's box (`--tab-corner-width` / `--tab-corner-height`); the old `row-gap` (lineage's `--lineage-row-gap` included) becomes the tabs' bottom margin and the bottom padding shrinks by the same amount, so row spacing and lineage routing room are unchanged. ⚠️ `updateTabOverflowMode()` removes `.tabs-corner` BEFORE it measures the wrap (the corner takes the toolbar out of the header's flow, which widens the strip), so the wrap is still judged against the one-row layout and an unwrapped strip looks exactly as before. ⚠️ Flex-only rules do not apply to the strip in that state. ⚠️ A strip that already scrolls (past `--tab-strip-max-height`) keeps the plain wrap, checked on the plain strip before the class goes on: the float scrolls with the tabs while the toolbar stays put, and toggling the class on a scrolled strip lets scroll anchoring move it; a strip pushed past the max-height by the float itself falls back too. A first tab too wide for the room beside the toolbar also falls back (the whole first line would drop below the toolbar). A `ResizeObserver` on the toolbar re-decides wrap and corner and redraws the connection lines when a reading or the header divider resizes it without a tab render. Scope: classic arrangement, desktop, header host only; state/case/ledger, the sidebar and the vertical rail never get the class (`applySessionListLayout()` drops it when the strip moves). Tests: `test/tab-strip-corner.browser.test.ts` (browser suite).
With `classic` nothing is left behind: no headings, no breaks, no inline order, no boxes, no name split, no arrangement class. A stale cached mobile-overview.js degrades every grouping to the flat strip. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`.
### Header stats styles
+10
View File
@@ -348,6 +348,8 @@ and it follows the same rule as the launch path: every ssh command line comes fr
| `POST /api/sessions/:id/folders` | Lists a folder (absolute or `~/`) on the **remote** host in one ssh round trip (`remoteListFolder`): resolved with `cd -P`, missing → `404`, a file → `400 Not a folder` |
| `GET /api/sessions/:id/folders/:folderId/files/:name/raw` | Streams one file of a granted remote folder over ssh after the same re-probe + guard as an attachment; `preview` (office) and `thumbnail` answer `400` |
| `GET /api/sessions/:id/attachments/:attachmentId`, `GET …/attachments` (history) | Size/mtime/existence resolved over ssh, so a remote entry is not reported `missing`; the history list resolves EVERY entry in one batched probe, never one connection per entry |
| `POST /api/sessions/:id/fix-plan/read` | Reads `@fix_plan.md` on the **remote** host (one probe of the file and the workspace, the same confinement, a 1 MB cap); missing or resolving outside the workspace → `NOT_FOUND`, not a regular file, over the cap or an ssh failure → `OPERATION_FAILED`; never a local fallback |
| `POST /api/sessions/:id/fix-plan/write`, `POST /api/sessions/:id/ralph-prompt/write` | `400` (`INVALID_INPUT`) before any disk is touched: remote writes are out of scope, and a local write would land in a same-named local folder |
⚠️ The attachment route is the one a clicked path takes when it is **outside** the case
directory (a remote `/tmp` scratchpad capture, a screenshot elsewhere in the home dir):
@@ -429,6 +431,14 @@ path but the SESSION (`session.remote`): a remote session never falls back to lo
`fs`, and a local session never opens an ssh connection — including for attachment
records, which are keyed to the session that registered them.
**Local watchers skip a remote session.** The Ralph tracker's `@fix_plan.md` auto-load
(and the `fs.watch` behind it) never runs for a remote session, because the watcher reads
the LOCAL disk, where that path is missing or a different tree: `setupSessionListeners()`
(`server.ts`) calls `setRemoteWorkingDir()` instead, which only records the path. The fix
plan stays authoritative exactly as for a local session, so the tracker's output-based
completion guessers stay off there too; the file reaches the tracker only through
`fix-plan/read`, over ssh. The image watcher skips remote sessions the same way.
## Wake-on-LAN from user input
A durable remote session survives an SSH drop (COD-104/108), but nothing brought the
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.46.0",
"version": "1.47.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.46.0",
"version": "1.47.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.46.0",
"version": "1.47.0",
"description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.",
"mcpName": "io.github.Ark0N/codeman",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codeman",
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts inside a Codeman-managed session; outside one it only reads saved Codex conversations offline.",
"version": "1.46.0",
"version": "1.47.0",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+23 -3
View File
@@ -203,6 +203,9 @@ export class RalphFixPlanWatcher extends EventEmitter {
/** Path to the @fix_plan.md file being watched */
private _fixPlanPath: string | null = null;
/** The path is on a REMOTE host (`setRemoteWorkingDir`): never read or watch it locally. */
private _remote = false;
/** File watcher for @fix_plan.md */
private _fixPlanWatcher: FSWatcher | null = null;
@@ -238,6 +241,7 @@ export class RalphFixPlanWatcher extends EventEmitter {
* @param workingDir - The session's working directory
*/
setWorkingDir(workingDir: string): void {
this._remote = false;
this._workingDir = workingDir;
this._fixPlanPath = join(workingDir, '@fix_plan.md');
@@ -248,12 +252,28 @@ export class RalphFixPlanWatcher extends EventEmitter {
this.startWatchingFixPlan();
}
/**
* Record a REMOTE session's working directory (a path on the remote host) without reading or
* watching it: the local disk holds nothing of it. The fix plan stays authoritative exactly as for a
* local session, so the output-based completion guessers stay off (they are off for every session
* with a working directory); the file reaches the tracker only through an explicit import
* (`POST .../fix-plan/read`, which reads it over ssh).
* @param workingDir - The session's working directory on the remote host
*/
setRemoteWorkingDir(workingDir: string): void {
this.stopWatchingFixPlan();
this._remote = true;
this._workingDir = workingDir;
this._fixPlanPath = join(workingDir, '@fix_plan.md');
}
/**
* Load @fix_plan.md from disk if it exists.
* Called on initialization and when file changes are detected.
* Called on initialization and when file changes are detected. A remote path is never read here:
* the local disk would hold a missing path or an unrelated same-named file.
*/
async loadFixPlanFromDisk(): Promise<number> {
if (!this._fixPlanPath) return 0;
if (!this._fixPlanPath || this._remote) return 0;
try {
if (!existsSync(this._fixPlanPath)) {
@@ -281,7 +301,7 @@ export class RalphFixPlanWatcher extends EventEmitter {
* Reloads todos when the file is modified.
*/
private startWatchingFixPlan(): void {
if (!this._fixPlanPath || !this._workingDir) return;
if (!this._fixPlanPath || !this._workingDir || this._remote) return;
// Stop existing watcher if any
this.stopWatchingFixPlan();
+9
View File
@@ -717,6 +717,15 @@ export class RalphTracker extends EventEmitter {
this.fixPlanWatcher.setWorkingDir(workingDir);
}
/**
* Record a remote session's working directory without touching the local disk (see
* `RalphFixPlanWatcher.setRemoteWorkingDir`): the fix plan stays authoritative, as for a local session.
* @param workingDir - The session's working directory on the remote host
*/
setRemoteWorkingDir(workingDir: string): void {
this.fixPlanWatcher.setRemoteWorkingDir(workingDir);
}
/**
* Load @fix_plan.md from disk if it exists.
*/
+3 -3
View File
@@ -20,7 +20,7 @@
**OSC 52 clipboard** (`_installOsc52Clipboard`, terminal-ui.js; main terminal + every `TerminalTile`): a program in the PTY (Claude Code's `/copy`, tmux yanks, vim) sets the browser clipboard with `ESC ] 52`. ⚠️ Writes only: a `52;?` read query is always refused, never answered. ⚠️ A replay must never copy: `chunkedTerminalWrite` strips every OSC 52 (`OSC52_STRIP_RE`, constants.js) because xterm parses after `_isLoadingBuffer` is cleared, so the flag alone does not hold; a tile gates on its own `_bufferLoading`, which does. ⚠️ One copy arrives up to three times (Claude's own OSC 52, `tmux load-buffer -w`, set-clipboard forwarding), so identical text within `OSC52_DEDUPE_MS` is dropped. ⚠️ Every tab on the session receives it; only the focused one opens the manual-copy fallback. ⚠️ Restore prior focus after `_copyText` (its execCommand fallback focuses a temp textarea). Tests: `test/terminal-osc52-clipboard.test.ts` (replays drive the real `chunkedTerminalWrite` over `@xterm/headless`).
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv)
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, plain text is a Codeman-built bracketed paste handed to that pane's terminal via `terminal.input(framed)` (strip nested 200/201 markers, newlines to `\r`, wrap CSI 200~/201~) because `terminal.paste()` drops those markers after a replay clears xterm's DECSET 2004 mirror (#716). `input()` fires onData as user input, so the main pane's bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, including the local-echo flush (#219). The terminal is only refocused. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv)
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md`/`markdown` are IN `FILE_PREVIEW_EXTENSIONS` (owner decision 2026-10-13): a markdown path clicked or tapped in the TERMINAL (main pane, grid tiles and split Pane B share `registerFilePathLinkProvider`) opens this rendered view like every other surface, never the streaming log viewer; other in-workspace text keeps the tail viewer (live follow). The terminal pattern links `.markdown` and `.htm` too. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](../../docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
@@ -30,7 +30,7 @@
## Frontend modules and UI subsystems
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `<script>` order in `index.html` (each module's `@loadorder` tag mirrors it). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `<script>` order in `index.html` (each module's `@loadorder` tag mirrors it). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module (`create()` returns null unless `matchMedia('(pointer: coarse)')` matches or the user agent is Android, so a desktop dead key is not forwarded twice, #716, while an Android device whose mouse, trackpad cover or stylus Chromium reports as the fine primary pointer keeps the soft-keyboard recovery): the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on `<html>`; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](../../docs/architecture-invariants.md#entrance-animations)
@@ -38,7 +38,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk and the phone swipe follow computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through `_paintedSessionTab()` (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched; drag stays on during a search (owner decision), a drop landing between the visible neighbours; `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](../../docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip. Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. ⚠️ Grouping is a render layer: `sessionOrder`, Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep the canonical order, and only the phone swipe walks the drawn order (`nextSession({ visual: true })` through `_stripSessionOrder()`, the same `_visibleTabRows()` walk the strip's arrow keys use); an active session the list does not draw steps in at the first or last row, and an empty drawn list goes nowhere rather than falling back to a hidden session (`test/session-swipe-gesture.test.ts`). Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](../../docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
**Tab layouts** (`tabArrangement`, per-device, default `classic`; `state`/`case`/`ledger` are opt-in; Discussion #426): `state` (option C) splits the list into needs you / waiting / working / idle (idle is `quiet`: its heading stays as the row's anchor but draws no label; `tabStateOrder: 'urgent-last'` flips the order, needs you at the bottom): rows in the desktop header strip with left-aligned labels in a measured column (the brand sits over the strip's corner, so the rows after the first start under "Codeman"), sections in the flat rail and the sidebar, inline dividers on the tablet strip, group order with no headings on phones. `case` (option A) wraps each case's tabs in one `.tab-cluster` box and hides the `-<case>` of a generated name (`.tab-name-case`). `ledger` (option B) is a CSS-only grid on the desktop header strip. `classic` is the old strip; once it wraps on desktop it is L-shaped via `_syncTabStripCorner()` (the toolbar sits over its top-right corner and later rows run the full width under it, a strip already past its max-height keeps the plain wrap): ⚠️ `.tabs-corner` comes off before the wrap is measured, and the strip is `display: block` in that state, so flex-only rules do not apply to it there (`test/tab-strip-corner.browser.test.ts`). Pure cores: `CodemanTabTriage` and `CodemanTabClusters` (constants.js). ⚠️ `state` is the flex `order` property plus `aria-hidden` heading/break elements reconciled in place by `_syncTabTriageChrome()` after BOTH render paths, never a DOM reorder (a state change is an incremental pass). ⚠️ `case` boxes ARE a DOM grouping, so the incremental path rebuilds whenever `_tabClusterLayout().key` changes; membership follows `_mobileOverviewCaseFor()` like the home screens. ⚠️ Drag only reorders within a group or box (`_isTabDropAcrossGroups()`). ⚠️ Named groups in the vertical rail win over both groupings. ⚠️ `classic` must leave no trace (no headings, no inline order, no boxes, no name split), and the ledger must not change the markup. ⚠️ Grouping is a render layer: `sessionOrder`, Alt+[ / Alt+], Ctrl+Tab and Alt+1..9 keep the canonical order, and only the phone swipe walks the drawn order (`nextSession({ visual: true })` through `_stripSessionOrder()`, the same `_visibleTabRows()` walk the strip's arrow keys use); an active session the list does not draw steps in at the first or last row, and an empty drawn list goes nowhere rather than falling back to a hidden session (`test/session-swipe-gesture.test.ts`). Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. → [architecture-invariants#tab-layouts-by-state-by-case-ledger](../../docs/architecture-invariants.md#tab-layouts-by-state-by-case-ledger)
**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): `tiles` / `compact` (two pills, a ring beside every value) / `classic` (as before), keyed on `data-header-stats` (pre-paint + `applyHeaderStatsStyle()`), forced to `classic` below 768px and in a solo window. ⚠️ The clustered styles MOVE `#connectionIndicator` into `#headerSystemStats` and `#planUsageChip` right after it; `classic` moves them back to comment anchors. ⚠️ WS joins the pill only while System Stats is shown. ⚠️ The extra parts (stat rings, tile words, plan rings, meters) are always rendered and hidden by default in CSS, which is what keeps `classic` unchanged; tile words are derived beside the connection descriptor, never added to it (`test/connection-indicator.test.ts` pins its shape). ⚠️ A tile is a three-row grid (label, value, bar) with pixel line-heights and the bundled JetBrains Mono; never lay the bar over a fixed-height tile (a taller system mono put the value on top of it). The WS tile's grid lives on the inner `.connection-tile`, because JS writes the indicator's `display` inline. Tests: `test/header-stats-style.test.ts`. → [architecture-invariants#header-stats-styles](../../docs/architecture-invariants.md#header-stats-styles)
+104 -4
View File
@@ -2857,7 +2857,9 @@ class CodemanApp {
_bindResponseViewerInteractions(body) {
if (!body || body.dataset.rvBound === '1') return;
body.dataset.rvBound = '1';
body.addEventListener('click', async (ev) => {
// The two link kinds whose href is a placeholder the browser cannot open
// (`#`, `#fragment`). Returns true when it took the event.
const openPlaceholderLink = (ev) => {
// File path (_linkifyFilePaths): open it in the preview overlay, which
// resolves workspace and out-of-workspace paths alike.
const pathLink = ev.target.closest('a.rv-path');
@@ -2874,7 +2876,7 @@ class CodemanApp {
const target = pathLink.dataset.linkified ? this._resolveLinkedFilePath?.(filePath, sessionId) : filePath;
if (filePath && pathLink.dataset.folder) this.openFolderGrid(target || filePath, sessionId);
else if (filePath) this.openFilePreview(target || filePath, sessionId);
return;
return true;
}
// An in-document link (`[Install](#installation)`). The browser must not follow it: with
@@ -2891,8 +2893,18 @@ class CodemanApp {
const calm = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
target.scrollIntoView({ block: 'start', behavior: calm ? 'auto' : 'smooth' });
}
return;
return true;
}
return false;
};
// A middle click fires auxclick, never click, so the browser followed the
// placeholder and opened the dashboard root in a new tab: do what a click
// does instead. A real URL keeps the browser's own middle click.
body.addEventListener('auxclick', (ev) => {
if (ev.button === 1) openPlaceholderLink(ev);
});
body.addEventListener('click', async (ev) => {
if (openPlaceholderLink(ev)) return;
// A `localhost` URL in the agent's answer: from another device that can
// only load through the server, so hand it to a proxied web tab
@@ -5938,7 +5950,7 @@ class CodemanApp {
// tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode()
// drops it in sidebar mode, but drop it here too so nothing paints wrapped
// for a frame before the next measure.
if (sidebar) tabsEl.classList.remove('tabs-auto-wrap');
if (sidebar) tabsEl.classList.remove('tabs-auto-wrap', 'tabs-corner');
// Collapse/expand changes whether the filter is reachable, so re-evaluate it
// here too — not only at the render tails.
this.applySidebarFilter(this._sidebarFilter);
@@ -6929,7 +6941,14 @@ class CodemanApp {
updateTabOverflowMode() {
const container = this.$('sessionTabs');
if (!container) return;
// The corner takes the toolbar out of the header's flow, which widens the
// strip, so it goes before the wrap is measured and comes back once decided.
container.classList.remove('tabs-corner');
this._decideTabOverflow(container);
this._syncTabStripCorner(container);
}
_decideTabOverflow(container) {
// Lineage routing room (session-lineage.js) changes the strip's padding and
// row gap, so it is decided before the wrap is measured below.
this._syncLineageGutter?.();
@@ -6991,6 +7010,87 @@ class CodemanApp {
container.classList.toggle('tabs-auto-wrap', shouldWrap);
}
/**
* L-shaped header strip: once the classic strip wraps, the toolbar
* (#headerRight) sits over the strip's top-right corner (styles.css,
* `.tabs-corner`) and the rows under the first one run the header's full
* width beneath it. The strip keeps the toolbar's corner clear with a float
* sized here, from the toolbar's own box, so a toolbar that wraps onto more
* lines (header-divider.js) or gains a reading keeps every tab out from
* under it. The grouped arrangements (state, case, ledger) lay their rows
* out themselves and keep the plain strip.
*/
_syncTabStripCorner(container) {
const right = this.$('headerRight');
const on =
!!right &&
container.offsetParent !== null &&
!!container.parentElement?.classList.contains('session-tabs-host') &&
(container.classList.contains('tabs-auto-wrap') || container.classList.contains('tabs-two-rows')) &&
!container.classList.contains('tabs-triage') &&
!container.classList.contains('tabs-clusters') &&
!container.classList.contains('tabs-ledger') &&
MobileDetection.getDeviceType() === 'desktop';
const off = () => {
container.classList.remove('tabs-corner');
container.style.removeProperty('--tab-corner-width');
container.style.removeProperty('--tab-corner-height');
};
// A strip past its max-height scrolls, and the float would scroll its tabs
// under the toolbar, which stays put: keep the plain wrap. The plain strip
// is measured here (updateTabOverflowMode took .tabs-corner off), before
// the class goes on; toggling it on a scrolled strip lets scroll anchoring
// move the strip.
if (!on || container.scrollHeight > container.clientHeight + 1) {
off();
return;
}
container.classList.add('tabs-corner');
if (!this._tabCornerObserver && typeof ResizeObserver !== 'undefined') {
let frame = 0;
// A toolbar that changes size (a reading arrives, the divider settles)
// moves the tabs without a render: re-decide the wrap and the corner, and
// redraw the lineage routes that follow the tabs.
this._tabCornerObserver = new ResizeObserver(() => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
const strip = this.$('sessionTabs');
if (!strip || !/\btabs-(corner|auto-wrap|two-rows)\b/.test(strip.className)) return;
this.updateTabOverflowMode();
this.updateConnectionLines?.();
});
});
this._tabCornerObserver.observe(right);
}
const style = getComputedStyle(container);
const strip = container.getBoundingClientRect();
const box = right.getBoundingClientRect();
// The float sits at the strip's content padding edge (the plain strip did
// not scroll, so scrollTop is 0).
const contentTop = strip.top + parseFloat(style.paddingTop);
const contentLeft = strip.left + container.clientLeft + parseFloat(style.paddingLeft);
const contentRight = strip.left + container.clientLeft + container.clientWidth - parseFloat(style.paddingRight);
// A first tab that cannot fit beside the toolbar would drop the whole first
// line below it and leave an empty band: keep the plain strip instead.
const firstTab = container.querySelector(':scope > .session-tab');
if (firstTab && box.left - 8 - contentLeft < firstTab.offsetWidth + 2) {
off();
return;
}
const width = `${Math.max(0, Math.ceil(contentRight - box.left + 8))}px`;
const height = `${Math.max(0, Math.ceil(box.bottom - contentTop))}px`;
if (container.style.getPropertyValue('--tab-corner-width') !== width) {
container.style.setProperty('--tab-corner-width', width);
}
if (container.style.getPropertyValue('--tab-corner-height') !== height) {
container.style.setProperty('--tab-corner-height', height);
}
// A plain strip just under its max-height can pass it once the float is in:
// fall back to the plain strip, which fit.
if (container.scrollHeight > container.clientHeight + 1) off();
}
/**
* True when a case cluster in the header strip wraps inside its own box: a case
* wider than the whole strip (styles.css caps a box at the strip's width). The
-1
View File
@@ -579,7 +579,6 @@
Send: '发送',
Permission: '权限',
Question: '问题',
Idle: '空闲',
'Read My Mind': '读心术',
'Read My Mind: predict your next prompt': '读心术:预测您的下一条提示',
'Predict my next prompt': '预测我的下一条提示',
+18 -9
View File
@@ -193,7 +193,10 @@ Object.assign(CodemanApp.prototype, {
// the result for images. Works on plain HTTP (no Clipboard API needed).
// `target` names the terminal the Ctrl+V came from and its session; both
// default to the primary pane. A second terminal (the split pane) passes its
// own, so text pastes into THAT xterm and images upload to THAT session.
// own. Images upload to THAT session. Plain text goes to that pane's session
// as a Codeman-built bracketed-paste frame: the terminal is only refocused
// and receives input(framed). onData delivers the frame; it is not written
// into the xterm buffer.
_handleImagePaste(target = {}) {
const self = this;
const terminal = target.terminal || this.terminal;
@@ -244,14 +247,20 @@ Object.assign(CodemanApp.prototype, {
if (imageFiles.length > 0) {
self._uploadAndInsertImages(imageFiles, { sessionId: sessionId });
} else {
// No image -- route text through xterm's paste() so bracketed-paste
// markers (CSI 200~ ... CSI 201~) survive when the inner application
// has enabled bracketed-paste mode (Claude Code does). Sending text
// via raw sendInput() strips those markers and makes pasted input
// indistinguishable from typed input, weakening the CLI's
// prompt-injection defenses.
if (text && terminal) terminal.paste(text);
} else if (text && terminal) {
// No image. xterm's paste() emits CSI 200~/201~ only while its DECSET
// 2004 mirror is on, and a terminal replay clears that mirror, so the
// markers never arrive and a copied newline submits (#716). Build the
// frame here and hand it to this terminal through input(). That fires
// onData as user input (scroll to the bottom, clear the selection).
// The main pane's bracketed-paste branch and a tile's _onTerminalData
// deliver it, including the local-echo flush (#219). Do not also write
// the frame from here. Markers are stripped until none is left: one pass over
// `ESC[20` + `ESC[201~` + `1~` leaves an `ESC[201~` that would end the frame early.
let body = text;
while (/\x1b\[20[01]~/.test(body)) body = body.replace(/\x1b\[20[01]~/g, '');
body = body.replace(/\r?\n/g, '\r');
terminal.input(`\x1b[200~${body}\x1b[201~`);
}
});
+5 -1
View File
@@ -1246,7 +1246,11 @@ const KeyboardAccessoryBar = {
// the single-line input transport and is what terminal.paste() emitted.
// Paste markers inside the text are dropped first: a clipboard carrying
// `ESC[201~` would end the bracket early and run the rest without Enter.
const pasteText = text.replace(/\x1b\[20[01]~/g, '').replace(/\r?\n/g, '\r');
// Strip until none is left: one pass over `ESC[20` + `ESC[201~` + `1~` leaves
// an `ESC[201~` behind.
let pasteText = text;
while (/\x1b\[20[01]~/.test(pasteText)) pasteText = pasteText.replace(/\x1b\[20[01]~/g, '');
pasteText = pasteText.replace(/\r?\n/g, '\r');
if (pasteText.length > this._composerMaxLength) {
app.showToast?.(`${label} is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
return false;
+14 -3
View File
@@ -2709,7 +2709,10 @@ Object.assign(CodemanApp.prototype, {
// Git: the session's own choice if it has one, otherwise the app setting. updateGitFetchStatus is also
// what SSE calls when a scheduled run lands, so the rows stay current while the modal is open.
this.updateGitFetchStatus(sessionId);
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
const imageWatcherToggle = document.getElementById('modalImageWatcherEnabled');
imageWatcherToggle.checked = session.imageWatcherEnabled ?? true;
// The server never watches a remote session's directory (it is on another host), so the row is hidden.
imageWatcherToggle.closest('.set-row').style.display = session.remote ? 'none' : '';
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
// Populate session name input with prefix/suffix split
@@ -3149,9 +3152,17 @@ Object.assign(CodemanApp.prototype, {
async toggleSessionImageWatcher() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
const toggle = document.getElementById('modalImageWatcherEnabled');
const enabled = toggle.checked;
try {
await this._apiPost(`/api/sessions/${this.editingSessionId}/image-watcher`, { enabled });
const res = await this._apiPost(`/api/sessions/${this.editingSessionId}/image-watcher`, { enabled });
// `_apiPost` hands back the Response without checking it, so a refused request (a 400) must not
// read as a save: put the switch back and say so.
if (!res?.ok) {
toggle.checked = !enabled;
this.showToast('Failed to toggle image watcher', 'error');
return;
}
// Update local session state
const session = this.sessions.get(this.editingSessionId);
if (session) {
+49 -2
View File
@@ -616,6 +616,45 @@ body {
padding-left: var(--lineage-spine-channel);
}
/* L-shaped strip (`_syncTabStripCorner()`, app.js): a wrapped classic strip
runs the header's full width, the toolbar sits over its top-right corner,
and only the rows beside the toolbar stop short of it. A flex line cannot
be shortened on its own, so the strip lays its tabs out inline, around a
float the size of the toolbar's box (measured into --tab-corner-width and
--tab-corner-height). The strip is a scroll container, so it contains the
float and is never shorter than the toolbar it now holds up; a strip that
would actually scroll keeps the plain wrap instead. Inline tabs space their
rows with a bottom margin (the old row-gap, lineage's included); the strip
carries no font size of its own, so the whitespace between tabs is 0 wide. */
.header:has(> .session-tabs-host > .session-tabs.tabs-corner) > .header-right {
position: absolute;
top: 0.35rem;
right: 0.75rem;
}
.session-tabs-host > .session-tabs.tabs-corner {
display: block;
font-size: 0;
padding-bottom: 0;
}
.session-tabs-host > .session-tabs.tabs-corner::before {
content: '';
float: right;
width: var(--tab-corner-width, 0px);
height: var(--tab-corner-height, 0px);
}
.session-tabs-host > .session-tabs.tabs-corner > .session-tab {
display: inline-flex;
vertical-align: top;
margin: 0 2px var(--lineage-row-gap, 4px) 0;
}
.session-tabs-host > .session-tabs.lineage-tree.tabs-corner {
padding-bottom: calc(var(--lineage-bottom-room) - var(--lineage-row-gap));
}
.session-tabs::-webkit-scrollbar {
width: 4px;
height: 0;
@@ -12571,13 +12610,21 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
/* ---- File Viewer rendered markdown ----
Styling comes from the Response Viewer's .rv-text rules; .rv-text itself
carries no padding or base font (the chat card supplies those). */
carries no padding or base font (the chat card supplies those).
No width cap. The window is whatever the user drags or expands it to, so the
document must reflow to fill it; the fixed 960px reading column this rule
shipped before the resize grip existed stops reflow once the window is wider
than the cap. --rv-content-max: none lets ASCII-diagram blocks
(.rv-text .rv-diagram-wrap, capped by that variable with a 720px fallback
meant for the Response Viewer) follow the rest of the document too. Pinned by
test/file-preview-window.test.ts. */
.file-preview-body > .file-preview-md {
padding: 1rem 1.25rem 2rem;
font-size: 15px;
line-height: 1.55;
max-width: 960px;
--rv-content-max: none;
}
/* Relative links in the document are rebased onto a.rv-path so the Response
@@ -7,6 +7,17 @@
* that guard, so xterm returns without emitting and the committed character is
* silently dropped.
*
* ⚠ Desktop fine pointers must not run this (#716). xterm already forwards the
* composed character, and a second forward doubles a dead-key backtick.
* `create()` returns null unless `matchMedia('(pointer: coarse)')` matches or
* the browser is Android, so soft keyboards keep the recovery and a desktop
* does not attach it. Android is checked by user agent because Chromium reports
* its PRIMARY pointer as fine once a mouse, a trackpad cover or a stylus is
* attached, while the text still comes from the soft keyboard (#441, #541 were
* measured there); Android always ran this before #716, so the check keeps it.
* A missing or throwing `matchMedia` stands down elsewhere: a missed recovery is
* acceptable, a duplicated keystroke is not.
*
* ⚠ The gap is NARROWER than "keyCode 229", and assuming otherwise produces a
* controller that looks useful while doing nothing. For a keydown that really
* does report `keyCode: 229`, xterm ALREADY self-rescues: `CompositionHelper
@@ -64,7 +75,22 @@
return { deleted: a.length - prefix, inserted: b.slice(prefix).join('') };
}
/**
* Touch / coarse pointer, or any Android browser. See the file header: on a
* desktop fine pointer this forward runs on top of xterm's own composition
* handling.
*/
function isSoftKeyboardPlatform() {
try {
if (/Android/i.test(global.navigator?.userAgent || '')) return true;
return global.matchMedia?.('(pointer: coarse)')?.matches === true;
} catch {
return false;
}
}
function create(options) {
if (!isSoftKeyboardPlatform()) return null;
const textarea = options?.textarea;
const emitRecovered = options?.emitRecovered;
if (!textarea?.addEventListener || !textarea?.removeEventListener || typeof emitRecovered !== 'function') {
+8 -6
View File
@@ -344,9 +344,10 @@
if (global.app?.tileShortcutFor?.(ev)) return false;
// Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so
// a pasted image uploads to this pane's session and its path is typed
// here, and pasted text goes into this xterm with its bracketed-paste
// markers intact. Mirrors terminal-ui.js's own Ctrl+V gate; without it
// xterm's default only ever pasted text.
// here. Plain text goes to this pane's session as a Codeman-built
// bracketed-paste frame; this terminal is only refocused and receives
// input(framed), which onData delivers. Mirrors terminal-ui.js's own
// Ctrl+V gate; without it xterm's default only ever pasted text.
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
global.app?._handleImagePaste?.({ terminal: this.terminal, sessionId: this.sessionId });
return false;
@@ -646,9 +647,10 @@
// Recovered text goes straight to _onTerminalData(), never through xterm's
// onData, so it is not counted as xterm's own (see connect()'s onData).
// Created after terminal.open(): xterm's capture `input` listener on the
// textarea is registered there, and must run before the controller's. No
// device or mode gate, as in the primary pane: with a hardware keyboard it
// costs one assignment per keydown. A failure leaves xterm's own handling.
// textarea is registered there, and must run before the controller's.
// create() returns null on a fine pointer (#716): the recovery is for soft
// keyboards, and on a desktop it delivers a dead-key character twice.
// A failure leaves xterm's own handling.
_createKeyCode229Recovery() {
this._destroyKeyCode229Recovery();
if (!this.terminal) return;
+1
View File
@@ -2108,6 +2108,7 @@ Object.assign(CodemanApp.prototype, {
// Twin: TerminalTile (terminal-tile.js _createKeyCode229Recovery and its
// connect() key handler and onData) wires its own controller the same way
// for every grid tile and the split's Pane B; keep the two in step.
// create() returns null unless the primary pointer is coarse (#716).
try {
this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
textarea: this.terminal.textarea,
+8 -9
View File
@@ -1,13 +1,12 @@
{
"version": "1.46.0",
"version": "1.47.0",
"highlights": [
"Fetch from the Git status window (#714)",
"Start a session beside any tab (#712, opt-in)",
"The phone keyboard no longer costs Claude its transcript (#711)",
"Typing stays smooth in a long prompt (#713)",
"Links survive wrapping and spaces (#686, #687)",
"`codeman agent spawn` in a project directory (#708)",
"Read saved Codex conversations offline (#707)",
"Experimental: bounded inspection and guarded submission for agents (#694, opt-in)"
"The wrapped tab strip uses the room under the toolbar (#721)",
"Bodyless requests work through Cloudflare Tunnel (#725)",
"A desktop dead key is typed once, and Ctrl+V stays a paste after a replay (#726, #716)",
"Remote sessions no longer touch the local disk at their path (#722, #727, #728)",
"Rendered Markdown fills a resized File Viewer (#730)",
"A middle click on a file path opens the file (#731)",
"No more duplicate-key warning on build (#729)"
]
}
+59
View File
@@ -0,0 +1,59 @@
/**
* @fileoverview Accept empty, untyped API POSTs even when a reverse proxy frames them as chunked.
*
* Cloudflare Tunnel (cloudflared, Go's HTTP client) forwards a bodyless POST as an HTTP/1.1
* chunked request with no Content-Type and a zero-length body. Fastify only skips its
* content-type check for a `Content-Length: 0` request, so this framing was refused 415
* (`FST_ERR_CTP_INVALID_MEDIA_TYPE`) before the route ran, breaking actions such as opening a
* saved web tab. This parser lets such a request reach its route with `request.body === undefined`,
* exactly as a `Content-Length: 0` request does.
*
* Scope is POST under `/api/` only: Go frames an empty body of unknown length as chunked for
* POST, PUT and PATCH and drops it for DELETE, and every PUT/PATCH the frontend sends carries a
* JSON body. Anything else (another method, a non-API route, a Content-Type, a non-zero
* Content-Length, or a single body byte) is still the 415 Fastify would have answered.
*
* ⚠️ It owns the ROOT `'*'` content-type slot. The JSON, `text/plain` (kept RAW, see the CSRF
* note in CLAUDE.md) and multipart parsers match first; the web-tab proxy registers its own
* `'*'` in an encapsulated scope and is unaffected. Fastify refuses a second root `'*'` parser at
* boot (`FST_ERR_CTP_ALREADY_PRESENT`), so extend this function instead of adding one.
*/
import { errorCodes, type FastifyInstance, type FastifyRequest } from 'fastify';
import type { Readable } from 'node:stream';
export function installEmptyApiPostParser(app: FastifyInstance): void {
// Existing JSON, text and multipart parsers take precedence. The webview
// proxy installs its own parser in an encapsulated scope.
app.addContentTypeParser('*', async (req: FastifyRequest, payload: Readable) => {
// Fastify normally skips an unsupported parser on its 404 path. Preserve
// that behavior, including the unread stream used by referer fallbacks.
if (req.is404) return undefined;
if (
req.method !== 'POST' ||
!req.routeOptions.url?.startsWith('/api/') ||
req.headers['content-type'] !== undefined ||
(req.headers['content-length'] !== undefined && req.headers['content-length'] !== '0')
) {
throw new errorCodes.FST_ERR_CTP_INVALID_MEDIA_TYPE();
}
// Wait for the terminating chunk to prove the body is empty. Reject on the
// first byte instead of buffering an unsupported body. Leave the socket
// alive so Fastify can send its 415 and close the connection on parser errors.
try {
for await (const chunk of payload.iterator({ destroyOnReturn: false })) {
if (Buffer.byteLength(chunk) > 0) throw new errorCodes.FST_ERR_CTP_INVALID_MEDIA_TYPE();
}
} catch (error) {
// Match Fastify's body reader: an interrupted request is a client error.
const err = error as Error & { statusCode?: number };
if (typeof err.statusCode !== 'number' || err.statusCode < 400) err.statusCode = 400;
throw err;
}
// No synthetic JSON or header rewriting: required-body validation still
// sees the same undefined value as a direct Content-Length: 0 request.
return undefined;
});
}
+5 -1
View File
@@ -767,7 +767,11 @@ const FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif'
const FILESYSTEM_TEXT_PREVIEW_EXTENSIONS = new Set(['md', 'txt', 'json']);
const FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS = new Set(['pdf', 'docx', 'pptx']);
function isPathWithinRoot(root: string, candidate: string): boolean {
/**
* Whether `candidate` is `root` itself or lies beneath it (both already resolved). The one lexical
* containment check for workspace confinement; ralph-routes.ts uses it for a remote `@fix_plan.md`.
*/
export function isPathWithinRoot(root: string, candidate: string): boolean {
const rel = relative(root, candidate);
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
}
+51 -2
View File
@@ -29,8 +29,14 @@ import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { buildRalphLoopPrompt } from '../../prompts/index.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import { remoteProbePaths, remoteReadFile, RemoteFileAccessError } from '../../remote-files.js';
import type { SessionRemote } from '../../types/session.js';
import { isPathWithinRoot } from './file-routes.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
/** Cap on a remote `@fix_plan.md` read over ssh (a task list, not a document). */
const MAX_REMOTE_FIX_PLAN_BYTES = 1024 * 1024;
export function registerRalphRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
@@ -191,6 +197,15 @@ export function registerRalphRoutes(
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
// A remote session's workingDir is a path on the remote host. Writing it with local fs would land
// the file in a same-named local folder (or fail), and remote writes are out of scope by design
// (src/remote-files.ts is read-only), so it is refused, like the File Viewer's remote edit.
if (session.remote) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Writing @fix_plan.md is not supported for a remote (SSH) session'
);
}
const workingDir = session.workingDir;
if (!workingDir) {
@@ -227,7 +242,14 @@ export function registerRalphRoutes(
const filePath = join(workingDir, '@fix_plan.md');
try {
const content = await fs.readFile(filePath, 'utf-8');
// A remote session's workingDir is a path on the remote host: read the file there, over the
// same ssh path the File Viewer uses, never a same-named local file.
const content = session.remote
? await readRemoteFixPlan(session.remote, workingDir, filePath)
: await fs.readFile(filePath, 'utf-8');
if (content === null) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory');
}
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
ctx.persistSessionState(session);
@@ -243,7 +265,8 @@ export function registerRalphRoutes(
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory');
}
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${error}`);
const reason = error instanceof RemoteFileAccessError ? error.message : String(error);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${reason}`);
}
});
@@ -257,6 +280,13 @@ export function registerRalphRoutes(
const { id } = req.params as { id: string };
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
// Same rule as fix-plan/write: never write a remote session's file on the local disk.
if (session.remote) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Writing @ralph_prompt.md is not supported for a remote (SSH) session'
);
}
const workingDir = session.workingDir;
if (!workingDir) {
@@ -511,3 +541,22 @@ export function registerRalphRoutes(
};
});
}
/**
* Read a remote session's `@fix_plan.md` on its host. `null` = no such file, or one that resolves outside
* the workspace (the caller's `NOT_FOUND`, as for a local ENOENT); anything that is not a regular file, a
* file over the cap, or an ssh failure throws `RemoteFileAccessError`, which the route reports as a failed
* read.
*/
async function readRemoteFixPlan(remote: SessionRemote, workingDir: string, filePath: string): Promise<string | null> {
// The file and the workspace in one round trip, so a symlinked @fix_plan.md that resolves outside the
// workspace is refused like any missing file (the File Viewer's confinement, file-routes.ts).
const [probe, rootProbe] = await remoteProbePaths(remote, [filePath, workingDir]);
if (!probe) return null;
if (!isPathWithinRoot(rootProbe?.realPath ?? resolve(workingDir), probe.realPath)) return null;
if (probe.kind !== 'file') throw new RemoteFileAccessError('@fix_plan.md is not a regular file');
if (probe.size > MAX_REMOTE_FIX_PLAN_BYTES) {
throw new RemoteFileAccessError(`@fix_plan.md is larger than ${MAX_REMOTE_FIX_PLAN_BYTES / 1024 / 1024}MB`);
}
return (await remoteReadFile(remote, probe.realPath, MAX_REMOTE_FIX_PLAN_BYTES)).toString('utf-8');
}
+5
View File
@@ -3780,6 +3780,11 @@ export function registerSessionRoutes(
const { id } = req.params as { id: string };
const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
// A remote session's workingDir is a path on the remote host; the watcher would watch it on the
// LOCAL disk (a missing folder, or a same-named local one whose images are not the session's).
if (body.enabled && session.remote) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Image watching does not run for remote sessions');
}
if (body.enabled) {
imageWatcher.watchSession(session.id, session.workingDir);
+3 -2
View File
@@ -1183,9 +1183,10 @@ export function registerSystemRoutes(
// Handle image watcher toggle dynamically
toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
// Re-watch all active sessions that have image watcher enabled
// Re-watch all active sessions that have image watcher enabled (never a remote session: its
// workingDir is a path on the remote host, not on this disk)
for (const session of ctx.sessions.values()) {
if (session.imageWatcherEnabled) {
if (session.imageWatcherEnabled && !session.remote) {
imageWatcher.watchSession(session.id, session.workingDir);
}
}
+18 -4
View File
@@ -200,6 +200,7 @@ import {
import { isMultiUserMode } from '../config/multiuser.js';
import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import { installEmptyApiPostParser } from './request-body-parser.js';
import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js';
import {
registerPushRoutes,
@@ -1014,6 +1015,9 @@ export class WebServer extends EventEmitter {
this.userIpFailures = authState.userIpFailures;
}
// Bodyless API POSTs may arrive as empty chunked streams through a proxy.
installEmptyApiPostParser(this.app);
// WebSocket support (terminal I/O — low-latency bidirectional channel)
await this.app.register(fastifyWebsocket);
@@ -2062,13 +2066,23 @@ export class WebServer extends EventEmitter {
this.runSummaryTrackers.set(session.id, summaryTracker);
summaryTracker.recordSessionStarted(session.mode, session.workingDir);
// Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs)
// Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs).
// A remote session's workingDir is a path on the remote host, and the watcher reads and
// fs.watch()es it on the LOCAL disk, so a remote session only records the path: the fix plan stays
// authoritative as for a local session (keeping the output-based completion guessers off), and its
// file is read over ssh only on an explicit import (fix-plan/read).
if (!isExternalCliMode(session.mode)) {
session.ralphTracker.setWorkingDir(session.workingDir);
if (session.remote) {
session.ralphTracker.setRemoteWorkingDir(session.workingDir);
} else {
session.ralphTracker.setWorkingDir(session.workingDir);
}
}
// Start watching for new images in this session's working directory (if enabled globally and per-session)
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) {
// Start watching for new images in this session's working directory (if enabled globally and per-session).
// A remote session's workingDir is a path on the remote host, and the watcher watches it on the LOCAL
// disk, so it is skipped there (the per-session and app-wide toggles skip it too).
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled && !session.remote) {
imageWatcher.watchSession(session.id, session.workingDir);
}
+50
View File
@@ -0,0 +1,50 @@
/** Verify parser installation through the real server, including API aliases and base paths. */
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { WebServer } from '../src/web/server.js';
describe('empty POST: production server wiring', () => {
let server: WebServer;
let app: FastifyInstance;
beforeAll(async () => {
server = new WebServer(0, false, true, '127.0.0.1', undefined, false, '/codeman');
await server.start();
app = (server as unknown as { app: FastifyInstance }).app;
});
afterAll(async () => {
await server?.stop();
});
it('accepts empty chunked POSTs to a real API through each supported prefix', async () => {
for (const prefix of ['/api', '/api/v1', '/codeman/api', '/codeman/api/v1']) {
const res = await app.inject({
method: 'POST',
url: `${prefix}/approvals/session/nonexistent/viewed`,
headers: { 'transfer-encoding': 'chunked' },
});
// The real handler ran and found no session, instead of the parser returning 415.
expect(res.statusCode, res.body).toBe(404);
expect(res.json().errorCode).toBe('NOT_FOUND');
}
});
it('still rejects a nonempty untyped POST before reaching the same handler', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/approvals/session/nonexistent/viewed',
headers: { 'transfer-encoding': 'chunked' },
payload: '{}',
});
expect(res.statusCode).toBe(415);
});
it('preserves the API 404 envelope for an unknown empty chunked POST', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/nonexistent',
headers: { 'transfer-encoding': 'chunked' },
});
expect(res.statusCode).toBe(404);
expect(res.json().errorCode).toBe('NOT_FOUND');
});
});
+22 -1
View File
@@ -14,7 +14,9 @@
* 9. stepping onto a folder-grid page moves the grid's own index with it,
* 10. Alt+←/→ steps, ignores other chords and text fields, and is swallowed at the ends,
* 11. the corner grip doubles the pointer delta and clamps the size,
* 12. phones ignore Expand and the grip (mobile.css).
* 12. phones ignore Expand and the grip (mobile.css),
* 13. the rendered document carries no width cap, so it fills the window the
* grip and Expand give it (styles.css).
*
* Loaded via `vm` against a stub app, same harness style as file-preview-detach.test.ts.
*/
@@ -27,6 +29,7 @@ import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
function btn() {
const attrs: Record<string, string> = {};
@@ -376,6 +379,24 @@ describe('file viewer resize grip', () => {
});
});
describe('file viewer rendered markdown width', () => {
it('carries no fixed reading column, so the document fills the window the grip and Expand give it', () => {
const mdRule = stylesCss.match(/\.file-preview-body > \.file-preview-md \{[^}]*\}/);
expect(mdRule, 'the rule the rendered document lives in').not.toBeNull();
// Any cap here reflows nothing once the window is wider than it, which is
// what the shipped 960px column did. The window width is the user's choice,
// so nothing in this rule may cap it.
expect(mdRule![0], 'no width cap on the document').not.toMatch(/max-width/);
// The shared .rv-text prose rule must not reintroduce one either.
const rvBase = stylesCss.match(/\.rv-text,\s*\.response-viewer-body > :not\(\.rv-message\) \{[^}]*\}/);
expect(rvBase, 'the shared prose rule').not.toBeNull();
expect(rvBase![0], 'no width cap on the shared prose rule').not.toMatch(/max-width/);
// ASCII-diagram blocks cap themselves at var(--rv-content-max, 720px), which only the
// Response Viewer sets; the document lifts it so a diagram follows the window too.
expect(mdRule![0], 'diagram blocks follow the window').toMatch(/--rv-content-max:\s*none;/);
});
});
describe('file viewer on phones', () => {
it('overrides Expand and any dragged size, and hides both controls, under 600px', () => {
const css = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8');
+161 -6
View File
@@ -33,11 +33,30 @@ interface FakeTrap {
addEventListener: (ev: string, fn: TrapListener) => void;
}
interface SentInput {
sessionId: string;
data: string;
}
interface TargetTerminal {
input: (data: string) => void;
paste?: (text: string) => void;
focus?: () => void;
}
interface Harness {
/** Fire a paste event on the trap the last _handleImagePaste() call created. */
firePaste: (payload: { text?: string; images?: string[] }) => void;
/** Text handed to xterm's terminal.paste(), one entry per call. */
pastedText: string[];
/** Frames handed to the primary terminal's input(), one entry per call. */
inputted: string[];
/** Frames handed to _sendInputAsync(), in order. The trap must not deliver text this way. */
sentInputs: SentInput[];
/** How many times predictive-echo predictions were cleared by the trap. */
predictionsCleared: () => number;
/** Unsent overlay text still held after the paste. Empty when local echo was not set up. */
pendingEcho: () => string;
/** Image batches handed to _uploadAndInsertImages(), one entry per call. */
uploadedBatches: Array<Array<{ type: string }>>;
/** Messages handed to showToast(), one entry per call. */
@@ -47,7 +66,16 @@ interface Harness {
runTimers: () => void;
}
function loadPasteHarness(): Harness {
interface PasteHarnessOptions {
/** Session the paste trap is aimed at. Defaults to the active session. */
sessionId?: string;
/** Unsent overlay text. The trap must leave this for onData to flush. */
localEchoPending?: string;
/** Terminal the Ctrl+V came from, when it is not the primary pane. */
terminal?: TargetTerminal;
}
function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
const traps: FakeTrap[] = [];
const listeners: TrapListener[] = [];
const attached = new Set<FakeTrap>();
@@ -99,20 +127,68 @@ function loadPasteHarness(): Harness {
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
const pastedText: string[] = [];
const inputted: string[] = [];
const sentInputs: SentInput[] = [];
const uploadedBatches: Array<Array<{ type: string }>> = [];
let predictionsCleared = 0;
let pendingEcho = '';
const app = new CodemanApp();
app.activeSessionId = 'session-1';
// Replay leaves xterm's DECSET 2004 mirror off. Text must still go out bracketed,
// and must not ask terminal.paste() to consult that mirror (#716). input() is
// what a real xterm fires onData from, with wasUserInput (scroll + clear selection).
app.terminal = {
paste: (text: string) => pastedText.push(text),
input: (data: string) => inputted.push(data),
focus: () => {},
modes: { bracketedPasteMode: false },
};
app._sendInputAsync = (sessionId: string, data: string) => {
sentInputs.push({ sessionId, data });
};
app._predictiveEcho = {
clearPredictions: () => {
predictionsCleared += 1;
},
};
app._uploadAndInsertImages = (files: Array<{ type: string }>) => {
uploadedBatches.push(Array.from(files));
};
if (options.localEchoPending !== undefined) {
pendingEcho = options.localEchoPending;
app._localEchoEnabled = true;
app._pendingInput = '';
app._localEchoOverlay = {
get pendingText() {
return pendingEcho;
},
set pendingText(value: string) {
pendingEcho = value;
},
clear() {
this.pendingText = '';
},
suppressBufferDetection() {},
};
app._flushedOffsets = new Map();
app._flushedTexts = new Map();
app._flushLocalEchoPending = function (this: Record<string, any>) {
const pending = this._localEchoOverlay?.pendingText || '';
this._localEchoOverlay.clear();
this._localEchoOverlay.suppressBufferDetection();
this._flushedOffsets?.delete(this.activeSessionId);
this._flushedTexts?.delete(this.activeSessionId);
if (pending) this._pendingInput += pending;
};
}
const toasts: string[] = [];
app.showToast = (message: string) => toasts.push(message);
(app._handleImagePaste as () => void).call(app);
const target =
options.sessionId || options.terminal
? { terminal: options.terminal ?? app.terminal, sessionId: options.sessionId || 'session-1' }
: undefined;
(app._handleImagePaste as (t?: object) => void).call(app, target);
return {
firePaste({ text = '', images = [] }) {
@@ -128,6 +204,10 @@ function loadPasteHarness(): Harness {
for (const fn of listeners) fn(event);
},
pastedText,
inputted,
sentInputs,
predictionsCleared: () => predictionsCleared,
pendingEcho: () => pendingEcho,
uploadedBatches,
toasts,
attachedTraps: () => attached.size,
@@ -154,12 +234,82 @@ function loadImageInputApp() {
}
describe('Ctrl+V paste trap', () => {
it('sends clipboard text to the terminal once for a single paste event', () => {
it('hands clipboard text to terminal.input as one bracketed paste, not through paste() or the session path', () => {
const h = loadPasteHarness();
h.firePaste({ text: 'hello world' });
expect(h.pastedText).toEqual(['hello world']);
// terminal.paste() would drop the markers: the harness mirror is off, which
// is what a replay leaves behind. input() is the public entry that fires
// onData with wasUserInput, so the pane scrolls and the selection clears.
// Delivery belongs to that onData handler, not a second write from here.
expect(h.pastedText).toEqual([]);
expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']);
expect(h.sentInputs).toEqual([]);
expect(h.predictionsCleared()).toBe(0);
});
it('brackets a multi-line paste and strips nested paste markers', () => {
const h = loadPasteHarness();
h.firePaste({ text: 'line one\nline two\r\n\x1b[200~nested\x1b[201~\n' });
expect(h.pastedText).toEqual([]);
expect(h.inputted).toEqual(['\x1b[200~line one\rline two\rnested\r\x1b[201~']);
expect(h.sentInputs).toEqual([]);
});
it('strips a marker hidden inside another, so the clipboard cannot end the frame early', () => {
const h = loadPasteHarness();
// One pass over this leaves `ESC[201~` between `ok` and the command.
h.firePaste({ text: 'ok\x1b[20\x1b[201~1~\nrm -rf ~/x\n' });
expect(h.inputted).toEqual(['\x1b[200~ok\rrm -rf ~/x\r\x1b[201~']);
});
it('addresses the bracketed paste to the pane that received Ctrl+V', () => {
const paneInputs: string[] = [];
const pane: TargetTerminal = {
input: (data) => paneInputs.push(data),
paste: () => {
throw new Error('paste() must not run');
},
focus: () => {},
};
const h = loadPasteHarness({ sessionId: 'pane-b', terminal: pane });
h.firePaste({ text: 'into pane b' });
expect(paneInputs).toEqual(['\x1b[200~into pane b\x1b[201~']);
expect(h.inputted).toEqual([]);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
it('leaves unsent local-echo text for onData, instead of flushing it itself', () => {
const h = loadPasteHarness({ localEchoPending: 'hi' });
h.firePaste({ text: 'world\n' });
h.runTimers();
// terminal.input() fires the main pane's onData, whose bracketed-paste
// branch flushes the overlay and delays the frame (#219). Flushing here
// as well would put the prefix on the wire twice.
expect(h.pendingEcho()).toBe('hi');
expect(h.inputted).toEqual(['\x1b[200~world\r\x1b[201~']);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
it('keeps a clipboard that looks like a query reply or a mouse report inside the frame', () => {
const h = loadPasteHarness();
h.firePaste({ text: '\x1b[?1;2c\n\x1b[<0;10;5M\n\x1b[I' });
expect(h.inputted).toEqual(['\x1b[200~\x1b[?1;2c\r\x1b[<0;10;5M\r\x1b[I\x1b[201~']);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
it('ignores a second paste event for the same Ctrl+V', () => {
@@ -170,7 +320,9 @@ describe('Ctrl+V paste trap', () => {
h.firePaste({ text: 'hello world' });
h.firePaste({ text: 'hello world' });
expect(h.pastedText).toEqual(['hello world']);
expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
it('uploads a pasted image once when the trap sees two paste events', () => {
@@ -182,6 +334,7 @@ describe('Ctrl+V paste trap', () => {
expect(h.uploadedBatches).toHaveLength(1);
expect(h.uploadedBatches[0]).toEqual([{ type: 'image/png' }]);
expect(h.pastedText).toEqual([]);
expect(h.sentInputs).toEqual([]);
});
it('a paste mixing an image with a refused file uploads the image and says what it skipped', () => {
@@ -199,7 +352,9 @@ describe('Ctrl+V paste trap', () => {
// Copying from a document can carry both: the text is what the user gets.
h.firePaste({ text: 'echo hi', images: ['application/x-sh'] });
expect(h.pastedText).toEqual(['echo hi']);
expect(h.inputted).toEqual(['\x1b[200~echo hi\x1b[201~']);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
expect(h.uploadedBatches).toEqual([]);
expect(h.toasts).toEqual([]);
});
+190
View File
@@ -0,0 +1,190 @@
/**
* The image watcher must never watch a REMOTE session's working directory.
*
* A remote-SSH session's `workingDir` is a path on the remote host. The watcher
* (chokidar) watches it on the LOCAL disk: where nothing exists there it watches
* a missing folder, and where a same-named local folder does exist it reports that
* folder's images as the remote session's. Three places start a watch, and all
* three skip a remote session:
* - `WebServer.setupSessionListeners()` (create, respawn, restore, recovery);
* - `POST /api/sessions/:id/image-watcher` (the per-session toggle), which refuses
* to turn it on, the way the Git fetch settings route refuses a remote session;
* - `PUT /api/settings` turning the app-wide watcher on, which re-watches every
* session that has it enabled.
*
* Spies on the real `imageWatcher` singleton, so all three modules see the spy.
* Port: none (app.inject(), and a WebServer that is never started).
*/
import { mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { safeRmHomeTree } from './mocks/test-helpers.js';
import { createRouteTestHarness, type RouteTestHarness } from './routes/_route-test-utils.js';
import { imageWatcher } from '../src/image-watcher.js';
import { WebServer } from '../src/web/server.js';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { registerSessionRoutes } from '../src/web/routes/session-routes.js';
import { registerSystemRoutes } from '../src/web/routes/system-routes.js';
const WORKSPACE = join(homedir(), '.codeman-test-image-watcher-remote');
// A path on the remote host: nothing exists there locally.
const REMOTE_PATH = '/nonexistent-remote-host-path/homelab-infrastructure';
const REMOTE = {
hostId: 'proxx',
label: 'proxx',
host: 'proxx.example.lan',
username: 'user',
remotePath: REMOTE_PATH,
owned: true,
};
let watchSession: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
watchSession = vi.spyOn(imageWatcher, 'watchSession').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
imageWatcher.stop();
});
describe('setupSessionListeners()', () => {
interface ServerInternals {
registerSessionWithLayout(session: Session): Promise<void>;
setupSessionListeners(session: Session): Promise<void>;
discardPartiallyBuiltSession(sessionId: string): Promise<void>;
isImageWatcherEnabled(): Promise<boolean>;
}
const SESSION_ID = 'a1b2c3d4e5f60718';
let server: WebServer;
let internals: ServerInternals;
let mux: TmuxManager;
beforeAll(() => {
mkdirSync(WORKSPACE, { recursive: true });
server = new WebServer(0, false, true);
internals = server as unknown as ServerInternals;
mux = new TmuxManager();
});
afterEach(async () => {
await internals.discardPartiallyBuiltSession(SESSION_ID).catch(() => {});
});
afterAll(async () => {
await server.stop().catch(() => {});
safeRmHomeTree(WORKSPACE);
});
async function wire(remote: boolean) {
const session = new Session({
id: SESSION_ID,
workingDir: remote ? REMOTE_PATH : WORKSPACE,
mode: 'claude',
name: remote ? 'remote session' : 'local session',
mux,
useMux: true,
...(remote ? { remote: REMOTE } : {}),
});
session.imageWatcherEnabled = true;
vi.spyOn(internals, 'isImageWatcherEnabled').mockResolvedValue(true);
await internals.registerSessionWithLayout(session);
await internals.setupSessionListeners(session);
}
it('does not watch a remote session working directory', async () => {
await wire(true);
expect(watchSession).not.toHaveBeenCalled();
});
it('still watches a local session working directory', async () => {
await wire(false);
expect(watchSession).toHaveBeenCalledWith(SESSION_ID, WORKSPACE);
});
});
describe('POST /api/sessions/:id/image-watcher', () => {
let harness: RouteTestHarness;
beforeEach(async () => {
harness = await createRouteTestHarness(registerSessionRoutes);
});
afterEach(async () => {
await harness.app.close();
});
const post = (enabled: boolean) =>
harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/image-watcher`,
payload: { enabled },
});
it('refuses to turn it on for a remote session, storing nothing', async () => {
const session = harness.ctx._session as unknown as Record<string, unknown>;
session.remote = REMOTE;
session.workingDir = REMOTE_PATH;
const res = await post(true);
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(watchSession).not.toHaveBeenCalled();
expect(session.imageWatcherEnabled).not.toBe(true);
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
});
it('still turns it off for a remote session', async () => {
(harness.ctx._session as unknown as Record<string, unknown>).remote = REMOTE;
const unwatchSession = vi.spyOn(imageWatcher, 'unwatchSession');
const res = await post(false);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toMatchObject({ success: true, data: { imageWatcherEnabled: false } });
expect(unwatchSession).toHaveBeenCalledWith(harness.ctx._sessionId);
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
});
it('still turns it on for a local session', async () => {
const res = await post(true);
expect(res.statusCode).toBe(200);
expect(watchSession).toHaveBeenCalledWith(harness.ctx._sessionId, harness.ctx._session.workingDir);
});
});
describe('PUT /api/settings turning the app-wide watcher on', () => {
let harness: RouteTestHarness;
beforeEach(async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
});
afterEach(async () => {
await harness.app.close();
});
it('re-watches the local sessions that have it enabled, never a remote one', async () => {
const local = harness.ctx._session as unknown as Record<string, unknown>;
local.imageWatcherEnabled = true;
harness.ctx.sessions.set('remote-session', {
...harness.ctx._session,
id: 'remote-session',
workingDir: REMOTE_PATH,
imageWatcherEnabled: true,
remote: REMOTE,
} as unknown as typeof harness.ctx._session);
expect(imageWatcher.isRunning()).toBe(false);
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { imageWatcherEnabled: true },
});
expect(res.statusCode).toBe(200);
expect(imageWatcher.isRunning()).toBe(true);
expect(watchSession).toHaveBeenCalledTimes(1);
expect(watchSession).toHaveBeenCalledWith(harness.ctx._sessionId, harness.ctx._session.workingDir);
});
});
+10
View File
@@ -725,6 +725,16 @@ describe('mobile shell Paste', () => {
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x1b[200~echo safe\rrm -rf ~/x\x1b[201~');
});
it('drops a paste marker hidden inside another one', () => {
const { app, input, send } = openShellPaste();
// One pass over this leaves `ESC[201~` between `ok` and the command.
input.value = 'ok\x1b[20\x1b[201~1~\nrm -rf ~/x';
send.click();
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x1b[200~ok\rrm -rf ~/x\x1b[201~');
});
it('refuses a paste longer than one input frame and keeps the text for trimming', () => {
const { app, bar, document, input, send } = openShellPaste();
input.value = 'x'.repeat(bar._composerMaxLength + 1);
+168
View File
@@ -0,0 +1,168 @@
/**
* The Ralph tracker's `@fix_plan.md` watcher must never be pointed at a REMOTE
* session's working directory.
*
* A remote-SSH session's `workingDir` is a path on the remote host. The watcher
* reads and `fs.watch`es it on the LOCAL disk, where it usually does not exist,
* so every server start logged
* `[RalphFixPlanWatcher] Could not watch @fix_plan.md: Error: ENOENT ...` once per
* remote session (and where a same-named local directory did exist, it watched
* the wrong tree). A remote session therefore only RECORDS the path
* (`setRemoteWorkingDir`): nothing is read or watched locally, and the fix
* plan stays authoritative (`isFileAuthoritative`) exactly as for a local
* session, so the tracker's output-based completion guessers stay off. They
* are off for every session with a working directory; turning them on for
* remote sessions alone would let a prose line such as "All tasks completed"
* mark every todo done and end the loop there and nowhere else.
*
* Drives the real `WebServer.setupSessionListeners()`, like
* discard-partially-built-session.test.ts.
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { safeRmHomeTree } from './mocks/test-helpers.js';
import { WebServer } from '../src/web/server.js';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
interface ServerInternals {
registerSessionWithLayout(session: Session): Promise<void>;
setupSessionListeners(session: Session): Promise<void>;
discardPartiallyBuiltSession(sessionId: string): Promise<void>;
}
const WORKSPACE = join(homedir(), '.codeman-test-ralph-remote');
// A path on the remote host: nothing exists there locally.
const REMOTE_PATH = '/nonexistent-remote-host-path/homelab-infrastructure';
const SESSION_ID = 'f0e1d2c3b4a59687';
let server: WebServer;
let internals: ServerInternals;
let mux: TmuxManager;
function buildSession(remote: boolean): Session {
return new Session({
id: SESSION_ID,
workingDir: remote ? REMOTE_PATH : WORKSPACE,
mode: 'claude',
name: remote ? 'remote session' : 'local session',
mux,
useMux: true,
...(remote
? {
remote: {
hostId: 'proxx',
label: 'proxx',
host: 'proxx.example.lan',
username: 'user',
remotePath: REMOTE_PATH,
owned: true,
},
}
: {}),
});
}
beforeAll(() => {
mkdirSync(WORKSPACE, { recursive: true });
server = new WebServer(0, false, true);
internals = server as unknown as ServerInternals;
mux = new TmuxManager();
});
afterEach(async () => {
vi.restoreAllMocks();
await internals.discardPartiallyBuiltSession(SESSION_ID).catch(() => {});
});
afterAll(async () => {
await server.stop().catch(() => {});
safeRmHomeTree(WORKSPACE);
});
describe('Ralph @fix_plan.md watcher and the session location', () => {
it('does not watch a remote session working directory on the local disk', async () => {
const session = buildSession(true);
const setWorkingDir = vi.spyOn(session.ralphTracker, 'setWorkingDir');
const setRemoteWorkingDir = vi.spyOn(session.ralphTracker, 'setRemoteWorkingDir');
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
await internals.registerSessionWithLayout(session);
await internals.setupSessionListeners(session);
expect(setWorkingDir).not.toHaveBeenCalled();
expect(setRemoteWorkingDir).toHaveBeenCalledWith(REMOTE_PATH);
expect(log.mock.calls.some((args) => String(args[0]).includes('Could not watch @fix_plan.md'))).toBe(false);
// Same authority as a local session: the output-based completion guessers stay off.
expect(session.ralphTracker.isFileAuthoritative).toBe(true);
});
it('never reads a same-named local file for a remote session', async () => {
// The remote path exists locally too (an sshfs mount, a leftover checkout) and holds a plan.
writeFileSync(join(WORKSPACE, '@fix_plan.md'), '- [ ] a local task the remote session never had\n');
try {
const session = new Session({
id: SESSION_ID,
workingDir: WORKSPACE,
mode: 'claude',
name: 'remote session over a local twin',
mux,
useMux: true,
remote: {
hostId: 'proxx',
label: 'proxx',
host: 'proxx.example.lan',
username: 'user',
remotePath: WORKSPACE,
owned: true,
},
});
vi.spyOn(console, 'log').mockImplementation(() => {});
await internals.registerSessionWithLayout(session);
await internals.setupSessionListeners(session);
expect(await session.ralphTracker.loadFixPlanFromDisk()).toBe(0);
expect(session.ralphTracker.todos).toEqual([]);
} finally {
rmSync(join(WORKSPACE, '@fix_plan.md'), { force: true });
}
});
it('keeps a remote session todo list out of the prose completion guessers', async () => {
const session = buildSession(true);
vi.spyOn(console, 'log').mockImplementation(() => {});
await internals.registerSessionWithLayout(session);
await internals.setupSessionListeners(session);
const tracker = session.ralphTracker;
tracker.enable();
tracker.startLoop('ALLDONE');
const completions: string[] = [];
tracker.on('completionDetected', (phrase: string) => completions.push(phrase));
tracker.processTerminalData('- [ ] Write the parser\n- [ ] Add tests\n');
const pending = () => tracker.todos.filter((t) => t.status !== 'completed').length;
expect(pending()).toBe(2);
// Prose, not the configured completion phrase: it must not end the loop.
tracker.processTerminalData('All tasks completed\n');
expect(pending()).toBe(2);
expect(tracker.loopState.active).toBe(true);
expect(completions).toEqual([]);
});
it('still watches a local session working directory', async () => {
const session = buildSession(false);
const setWorkingDir = vi.spyOn(session.ralphTracker, 'setWorkingDir');
await internals.registerSessionWithLayout(session);
await internals.setupSessionListeners(session);
expect(setWorkingDir).toHaveBeenCalledWith(WORKSPACE);
});
});
+393
View File
@@ -0,0 +1,393 @@
/** Regression coverage for empty API POSTs reframed as chunked by a reverse proxy. */
import { afterEach, describe, expect, it, vi } from 'vitest';
import Fastify, { type FastifyInstance, type FastifyRequest, type FastifyServerOptions } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyMultipart from '@fastify/multipart';
import { connect } from 'node:net';
import { Readable } from 'node:stream';
import { installEmptyApiPostParser } from '../src/web/request-body-parser.js';
import { registerAuthMiddleware, registerHostGuard } from '../src/web/middleware/auth.js';
import { registerMuxRoutes } from '../src/web/routes/mux-routes.js';
import { registerWebviewRoutes } from '../src/web/routes/webview-routes.js';
import { createMockRouteContext } from './mocks/index.js';
import { writeWebviews } from '../src/webview-store.js';
import { webviewCapabilities } from '../src/webview-capabilities.js';
import { getDataDir } from '../src/config/instance.js';
const apps: FastifyInstance[] = [];
function createApp(options: FastifyServerOptions = {}): FastifyInstance {
const app = Fastify({ logger: false, ...options });
installEmptyApiPostParser(app);
apps.push(app);
return app;
}
afterEach(async () => {
await Promise.all(apps.splice(0).map((app) => app.close()));
webviewCapabilities.revokeWebview('empty-post-preview');
});
const emptyChunked = { 'transfer-encoding': 'chunked' };
describe('empty API POST parser', () => {
it.each([{}, { 'content-length': '0' }, emptyChunked])(
'preserves an absent body and the original headers (%j)',
async (headers) => {
const app = createApp();
app.post('/api/action', async (req) => ({
absent: req.body === undefined,
contentType: req.headers['content-type'] ?? null,
contentLength: req.headers['content-length'] ?? null,
transferEncoding: req.headers['transfer-encoding'] ?? null,
}));
const res = await app.inject({ method: 'POST', url: '/api/action', headers });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({
absent: true,
contentType: null,
contentLength: 'content-length' in headers ? '0' : null,
transferEncoding: 'transfer-encoding' in headers ? 'chunked' : null,
});
}
);
it.each([' ', '{}', '{"prompt":"hello"}', 'x'.repeat(1024)])(
'rejects nonempty untyped chunked input (%#)',
async (payload) => {
const app = createApp();
const handler = vi.fn(async () => ({}));
app.post('/api/action', handler);
const res = await app.inject({ method: 'POST', url: '/api/action', headers: emptyChunked, payload });
expect(res.statusCode).toBe(415);
expect(res.json().code).toBe('FST_ERR_CTP_INVALID_MEDIA_TYPE');
expect(handler).not.toHaveBeenCalled();
}
);
it('rejects a positive Content-Length without reading the body', async () => {
const app = createApp();
let streamRead = false;
app.addHook(
'preParsing',
async () =>
new Readable({
read() {
streamRead = true;
},
})
);
app.post('/api/action', async () => ({}));
const res = await app.inject({ method: 'POST', url: '/api/action', headers: { 'content-length': '1' } });
expect(res.statusCode).toBe(415);
expect(streamRead).toBe(false);
});
it('waits for the end of an empty stream before executing the handler', async () => {
const app = createApp();
let started!: () => void;
const reading = new Promise<void>((resolve) => {
started = resolve;
});
const stream = new Readable({
read() {
started();
},
});
app.addHook('preParsing', async () => stream);
const handler = vi.fn(async (req: FastifyRequest) => ({ absent: req.body === undefined }));
app.post('/api/action', handler);
const response = app.inject({ method: 'POST', url: '/api/action', headers: emptyChunked });
await reading;
expect(handler).not.toHaveBeenCalled();
stream.push(null);
const res = await response;
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ absent: true });
});
it('rejects on the first byte without waiting for an unsupported stream to end', async () => {
const app = createApp({ bodyLimit: 16 });
const stream = new Readable({
read() {
this.push(Buffer.alloc(32));
this._read = () => {};
},
});
app.addHook('preParsing', async () => stream);
app.post('/api/action', async () => ({}));
try {
const res = await app.inject({ method: 'POST', url: '/api/action', headers: emptyChunked });
expect(res.statusCode).toBe(415);
expect(res.headers.connection).toBe('close');
expect(stream.readableEnded).toBe(false);
} finally {
stream.destroy();
}
});
it.each(['application/octet-stream', 'application/xml', 'invalid/type/extra', ''])(
'preserves rejection of an explicit type %j',
async (contentType) => {
const app = createApp();
app.post('/api/action', async () => ({}));
const res = await app.inject({ method: 'POST', url: '/api/action', headers: { 'content-type': contentType } });
expect(res.statusCode).toBe(415);
}
);
it('preserves JSON parsing, required-body validation, text and body limits', async () => {
const app = createApp({ bodyLimit: 64 });
app.post('/api/json', async (req) => ({ body: req.body }));
app.post(
'/api/required',
{ schema: { body: { type: 'object', required: ['prompt'], properties: { prompt: { type: 'string' } } } } },
async (req) => req.body
);
const missing = await app.inject({ method: 'POST', url: '/api/required', headers: emptyChunked });
expect(missing.statusCode).toBe(400);
expect(missing.json().code).toBe('FST_ERR_VALIDATION');
const valid = await app.inject({ method: 'POST', url: '/api/required', payload: { prompt: 'hello' } });
expect(valid.statusCode).toBe(200);
expect(valid.json()).toEqual({ prompt: 'hello' });
for (const [payload, code] of [
['', 'FST_ERR_CTP_EMPTY_JSON_BODY'],
['{broken', 'FST_ERR_CTP_INVALID_JSON_BODY'],
]) {
const res = await app.inject({
method: 'POST',
url: '/api/json',
headers: { 'content-type': 'application/json' },
payload,
});
expect(res.statusCode).toBe(400);
expect(res.json().code).toBe(code);
}
const text = await app.inject({
method: 'POST',
url: '/api/json',
headers: { 'content-type': 'text/plain' },
payload: '{"admin":true}',
});
expect(text.json()).toEqual({ body: '{"admin":true}' });
const oversized = await app.inject({ method: 'POST', url: '/api/json', payload: { value: 'x'.repeat(64) } });
expect(oversized.statusCode).toBe(413);
});
it('leaves other methods and non-API routes outside the compatibility rule', async () => {
const app = createApp();
app.put('/api/action', async () => ({}));
app.patch('/api/action', async () => ({}));
app.post('/outside-api', async () => ({}));
for (const [method, url] of [
['PUT', '/api/action'],
['PATCH', '/api/action'],
['POST', '/outside-api'],
] as const) {
const res = await app.inject({ method, url, headers: emptyChunked });
expect(res.statusCode).toBe(415);
}
});
it('preserves unknown routes and leaves their body stream unread for fallback handlers', async () => {
const app = createApp();
app.setNotFoundHandler(async (req, reply) => {
const chunks: Buffer[] = [];
for await (const chunk of req.raw) chunks.push(chunk);
return reply.code(404).send({ body: Buffer.concat(chunks).toString(), absent: req.body === undefined });
});
const res = await app.inject({
method: 'POST',
url: '/api/unknown',
headers: emptyChunked,
payload: 'untyped-data',
});
expect(res.statusCode).toBe(404);
expect(res.json()).toEqual({ body: 'untyped-data', absent: true });
});
it('keeps multipart uploads and an encapsulated raw-stream proxy parser intact', async () => {
const app = createApp();
await app.register(fastifyMultipart);
app.post('/api/upload', async (req) => {
const file = await req.file();
return { name: file?.filename, data: (await file?.toBuffer())?.toString('base64') };
});
app.register(async (scope) => {
scope.removeAllContentTypeParsers();
scope.addContentTypeParser('*', (_req, stream, done) => done(null, stream));
scope.post('/webview/test/*', async (req) => {
const chunks: Buffer[] = [];
for await (const chunk of req.body as Readable) chunks.push(chunk);
return { data: Buffer.concat(chunks).toString('base64') };
});
});
const bytes = Buffer.from([0, 255, 128, 13, 10, 65]);
const boundary = 'empty-post-boundary';
const multipart = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="sample.bin"\r\nContent-Type: application/octet-stream\r\n\r\n`
),
bytes,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
const upload = await app.inject({
method: 'POST',
url: '/api/upload',
headers: { 'content-type': `multipart/form-data; boundary=${boundary}` },
payload: multipart,
});
expect(upload.statusCode).toBe(200);
expect(upload.json()).toEqual({ name: 'sample.bin', data: bytes.toString('base64') });
const proxy = await app.inject({
method: 'POST',
url: '/webview/test/upload',
headers: { 'content-type': 'application/octet-stream' },
payload: bytes,
});
expect(proxy.statusCode).toBe(200);
expect(proxy.json()).toEqual({ data: bytes.toString('base64') });
});
it('exercises real mux operations and the real webview open route without a synthetic JSON body', async () => {
const app = createApp();
const ctx = createMockRouteContext();
registerMuxRoutes(app, ctx as never);
registerWebviewRoutes(app, ctx as never);
await writeWebviews(getDataDir(), [
{
id: 'empty-post-preview',
name: 'Preview',
url: 'http://127.0.0.1:18716/',
embedMode: 'proxy',
trusted: false,
createdAt: 1,
},
]);
const stats = await app.inject({ method: 'POST', url: '/api/mux-sessions/stats/start', headers: emptyChunked });
expect(stats.statusCode).toBe(200);
expect(ctx.mux.startStatsCollection).toHaveBeenCalledOnce();
const preview = await app.inject({
method: 'POST',
url: '/api/webviews/empty-post-preview/open',
headers: emptyChunked,
});
expect(preview.statusCode).toBe(200);
expect(preview.json().data.embedUrl).toMatch(/^\/webview\/.+\/$/);
});
it('preserves Host/Origin guards and authentication before parsing', async () => {
vi.stubEnv('CODEMAN_USERNAME', 'bodyless-test-user');
vi.stubEnv('CODEMAN_PASSWORD', 'isolated-test-password');
const app = createApp();
await app.register(fastifyCookie);
registerHostGuard(app, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null }));
const auth = registerAuthMiddleware(app, false);
app.post('/api/action', async () => ({ ok: true }));
const authorization = 'Basic ' + Buffer.from('bodyless-test-user:isolated-test-password').toString('base64');
const headers = { host: '127.0.0.1', ...emptyChunked };
try {
expect((await app.inject({ method: 'POST', url: '/api/action', headers })).statusCode).toBe(401);
expect(
(
await app.inject({
method: 'POST',
url: '/api/action',
headers: { ...headers, authorization, origin: 'https://evil.example' },
})
).statusCode
).toBe(403);
expect(
(
await app.inject({
method: 'POST',
url: '/api/action',
headers: { ...headers, authorization, host: 'evil.example' },
})
).statusCode
).toBe(403);
expect(
(await app.inject({ method: 'POST', url: '/api/action', headers: { ...headers, authorization } })).statusCode
).toBe(200);
} finally {
for (const map of Object.values(auth)) map?.dispose();
vi.unstubAllEnvs();
}
});
});
function sendRaw(port: number, frames: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const socket = connect({ host: '127.0.0.1', port });
const chunks: Buffer[] = [];
socket.setTimeout(2000, () => socket.destroy(new Error('Raw request timed out')));
socket.on('error', reject);
socket.on('data', (chunk) => chunks.push(chunk));
socket.on('end', () => resolve(Buffer.concat(chunks).toString()));
socket.on('connect', () => socket.write(frames.join('')));
});
}
describe('real HTTP/1.1 framing', () => {
it('consumes the complete empty chunk before a second request on the same connection', async () => {
const app = createApp();
const seen: unknown[] = [];
app.post('/api/action', async (req) => {
seen.push(req.body);
return { ok: true };
});
await app.listen({ host: '127.0.0.1', port: 0 });
const port = (app.server.address() as { port: number }).port;
const res = await sendRaw(port, [
'POST /api/action HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n0\r\nX-Test-Trailer: done\r\n\r\n',
'POST /api/action HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n',
]);
expect(res.match(/HTTP\/1.1 200 OK/g)).toHaveLength(2);
expect(seen).toEqual([undefined, undefined]);
});
it('returns 415 after the first nonempty chunk, without needing a terminating chunk', async () => {
const app = createApp();
const handler = vi.fn(async () => ({}));
app.post('/api/action', handler);
await app.listen({ host: '127.0.0.1', port: 0 });
const port = (app.server.address() as { port: number }).port;
const res = await sendRaw(port, [
'POST /api/action HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nx\r\n',
]);
expect(res).toMatch(/^HTTP\/1.1 415 /);
expect(handler).not.toHaveBeenCalled();
});
it('leaves conflicting length and transfer headers to the HTTP parser', async () => {
const app = createApp();
const handler = vi.fn(async () => ({}));
app.post('/api/action', handler);
await app.listen({ host: '127.0.0.1', port: 0 });
const port = (app.server.address() as { port: number }).port;
const res = await sendRaw(port, [
'POST /api/action HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n',
]);
expect(res).toMatch(/^HTTP\/1.1 400 /);
expect(handler).not.toHaveBeenCalled();
});
it('does not execute the handler after a client aborts an unfinished chunked request', async () => {
const app = createApp();
const handler = vi.fn(async () => ({}));
const onError = vi.fn();
app.addHook('onError', async (_req, _reply, error) => {
onError(error.statusCode);
});
app.post('/api/action', handler);
await app.listen({ host: '127.0.0.1', port: 0 });
const port = (app.server.address() as { port: number }).port;
await new Promise<void>((resolve) => {
const socket = connect({ host: '127.0.0.1', port });
socket.on('connect', () =>
socket.end('POST /api/action HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n')
);
socket.resume();
socket.on('close', () => resolve());
});
await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(400));
expect(handler).not.toHaveBeenCalled();
});
});
+262
View File
@@ -0,0 +1,262 @@
/**
* @fileoverview A middle click on a placeholder link in rendered markdown
* (`CodemanApp._bindResponseViewerInteractions`).
*
* File-path links (`a.rv-path`) and in-document links carry `href="#"` /
* `href="#frag"`: placeholders the click delegate resolves itself. A middle
* click fires `auxclick`, never `click`, so the browser used to follow the
* placeholder and open the dashboard root (`<base href="/">` + `#`) in a new
* tab. A middle click on a placeholder now does exactly what a click does; a
* real URL keeps the browser's own middle-click behaviour.
*
* Both surfaces are covered: the Response Viewer's messages
* (`_buildResponseViewerMessage` + `_linkifyFilePaths`) and the File Viewer's
* rendered markdown, whose rebased links name their own session.
*
* Drives the shipping `constants.js`, `app.js` and `terminal-ui.js` (for
* `_resolveLinkedFilePath`) in a `vm` with a jsdom window injected (the
* technique from response-viewer-file-links.test.ts; a per-file jsdom
* environment would externalize node:fs under vite).
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
const dom = new JSDOM('<!DOCTYPE html><html><head><base href="/"></head><body></body></html>', {
url: 'http://localhost:3000/',
});
const win = dom.window;
const { document } = win;
// app.js instantiates the whole app on DOMContentLoaded; let it pass before app.js is loaded.
if (document.readyState === 'loading') await new Promise((r) => document.addEventListener('DOMContentLoaded', r));
type App = {
activeSessionId: string;
sessions: Map<string, { workingDir: string }>;
openFilePreview: ReturnType<typeof vi.fn>;
openFolderGrid: ReturnType<typeof vi.fn>;
openLinkThroughWebTabIfLoopback: ReturnType<typeof vi.fn>;
_linkifyFilePaths(root: Element): void;
_bindResponseViewerInteractions(body: Element): void;
};
function loadCodemanAppClass(): { prototype: App } {
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
fetch: vi.fn(),
document,
NodeFilter: win.NodeFilter,
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
// The real window, so constants.js publishes CodemanMarkdownAnchors where the delegate reads it.
window: win,
MobileDetection: {},
});
vm.runInContext(
`${publicFile('constants.js')}\n${publicFile('app.js')}\n${publicFile('terminal-ui.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
context
);
return (context as unknown as { __CodemanApp: { prototype: App } }).__CodemanApp;
}
const CodemanApp = loadCodemanAppClass();
let app: App;
beforeEach(() => {
app = Object.create(CodemanApp.prototype) as App;
app.activeSessionId = 'sess-1';
app.sessions = new Map([
['sess-1', { workingDir: '/home/me/proj' }],
['sess-2', { workingDir: '/srv/other' }],
]);
app.openFilePreview = vi.fn();
app.openFolderGrid = vi.fn();
// A loopback URL would be handed to a web tab on a left click; a middle click must not reach it.
app.openLinkThroughWebTabIfLoopback = vi.fn(() => true);
});
/** A Response Viewer message body: rendered `.rv-text` inside the bound container. */
function responseViewer(html: string): HTMLElement {
const body = document.createElement('div');
const text = document.createElement('div');
text.className = 'rv-text';
text.innerHTML = html;
app._linkifyFilePaths(text);
body.appendChild(text);
document.body.replaceChildren(body);
app._bindResponseViewerInteractions(body);
return body;
}
function press(el: Element, type: 'auxclick' | 'click', init: MouseEventInit = {}): MouseEvent {
const ev = new win.MouseEvent(type, { bubbles: true, cancelable: true, ...init });
el.dispatchEvent(ev);
return ev;
}
function noneCalled(): void {
expect(app.openFilePreview).not.toHaveBeenCalled();
expect(app.openFolderGrid).not.toHaveBeenCalled();
expect(app.openLinkThroughWebTabIfLoopback).not.toHaveBeenCalled();
}
describe('middle click on a Response Viewer path link', () => {
it('opens a relative file path in the preview, resolved against the active session', () => {
const body = responseViewer('<p>wrote src/app.ts</p>');
const link = body.querySelector('a.rv-path')!;
expect(link.getAttribute('href')).toBe('#');
const ev = press(link, 'auxclick', { button: 1 });
expect(ev.defaultPrevented).toBe(true);
expect(app.openFilePreview.mock.calls).toEqual([['/home/me/proj/src/app.ts', 'sess-1']]);
expect(app.openFolderGrid).not.toHaveBeenCalled();
expect(app.openLinkThroughWebTabIfLoopback).not.toHaveBeenCalled();
});
it('opens a markdown link whose destination is a path, like a click does', () => {
const body = responseViewer('<p><a href="/home/me/run/summary.json" target="_blank">record</a></p>');
const ev = press(body.querySelector('a.rv-path')!, 'auxclick', { button: 1 });
expect(ev.defaultPrevented).toBe(true);
expect(app.openFilePreview.mock.calls).toEqual([['/home/me/run/summary.json', 'sess-1']]);
});
it('opens a folder path in the folder grid', () => {
const body = responseViewer('<p><a href="/tmp/renders/">renders</a></p>');
const ev = press(body.querySelector('a.rv-path')!, 'auxclick', { button: 1 });
expect(ev.defaultPrevented).toBe(true);
expect(app.openFolderGrid.mock.calls).toEqual([['/tmp/renders/', 'sess-1']]);
expect(app.openFilePreview).not.toHaveBeenCalled();
});
it('does the same with Ctrl held, as Ctrl+click does', () => {
const body = responseViewer('<p>wrote src/app.ts</p>');
const ev = press(body.querySelector('a.rv-path')!, 'auxclick', { button: 1, ctrlKey: true });
expect(ev.defaultPrevented).toBe(true);
expect(app.openFilePreview.mock.calls).toEqual([['/home/me/proj/src/app.ts', 'sess-1']]);
});
it.each([
['right', 2],
['back', 3],
['forward', 4],
])('ignores the %s button', (_name, button) => {
const body = responseViewer('<p>wrote src/app.ts</p>');
const ev = press(body.querySelector('a.rv-path')!, 'auxclick', { button });
expect(ev.defaultPrevented).toBe(false);
noneCalled();
});
it('opens once per gesture: a left click still goes through click alone', () => {
const body = responseViewer('<p>wrote src/app.ts</p>');
// Binding is idempotent: a second bind must not add a second auxclick listener.
app._bindResponseViewerInteractions(body);
const link = body.querySelector('a.rv-path')!;
press(link, 'auxclick', { button: 1 });
expect(app.openFilePreview).toHaveBeenCalledTimes(1);
app.openFilePreview.mockClear();
const click = press(link, 'click', { button: 0 });
expect(click.defaultPrevented).toBe(true);
expect(app.openFilePreview.mock.calls).toEqual([['/home/me/proj/src/app.ts', 'sess-1']]);
});
});
describe('middle click on an in-document link', () => {
it('scrolls to the heading in place instead of following the fragment', () => {
const body = responseViewer('<h2>Install</h2><p>see <a href="#install">install</a></p>');
const heading = body.querySelector('h2')! as HTMLElement & { scrollIntoView: ReturnType<typeof vi.fn> };
heading.scrollIntoView = vi.fn();
const ev = press(body.querySelector('a[href="#install"]')!, 'auxclick', { button: 1 });
expect(ev.defaultPrevented).toBe(true);
// jsdom has no matchMedia, so reduced motion reads as off.
expect(heading.scrollIntoView.mock.calls).toEqual([[{ block: 'start', behavior: 'smooth' }]]);
noneCalled();
});
it('ignores a right click on it', () => {
const body = responseViewer('<h2>Install</h2><p>see <a href="#install">install</a></p>');
const heading = body.querySelector('h2')! as HTMLElement & { scrollIntoView: ReturnType<typeof vi.fn> };
heading.scrollIntoView = vi.fn();
const ev = press(body.querySelector('a[href="#install"]')!, 'auxclick', { button: 2 });
expect(ev.defaultPrevented).toBe(false);
expect(heading.scrollIntoView).not.toHaveBeenCalled();
});
});
describe('middle click on a real URL', () => {
it.each(['https://example.com/a.json', 'http://localhost:5173/'])('leaves %s to the browser', (href) => {
const body = responseViewer(`<p><a href="${href}" target="_blank" rel="noopener noreferrer">x</a></p>`);
expect(body.querySelector('a.rv-path')).toBeNull();
const ev = press(body.querySelector('a')!, 'auxclick', { button: 1 });
expect(ev.defaultPrevented).toBe(false);
noneCalled();
});
});
describe('middle click in the File Viewer rendered markdown', () => {
it('opens a rebased link in the session the preview was opened for, not the active one', () => {
// The shape _rebaseFilePreviewMarkdownRefs gives a workspace link (panels-ui.js).
const bodyEl = document.createElement('div');
bodyEl.innerHTML =
'<div class="rv-text file-preview-md"><a class="rv-path" href="#" data-path="/srv/other/notes.md" data-session-id="sess-2">notes</a></div>';
document.body.replaceChildren(bodyEl);
app._bindResponseViewerInteractions(bodyEl);
const ev = press(bodyEl.querySelector('a.rv-path')!, 'auxclick', { button: 1 });
expect(ev.defaultPrevented).toBe(true);
expect(app.openFilePreview.mock.calls).toEqual([['/srv/other/notes.md', 'sess-2']]);
});
});
describe('a left click keeps its behaviour', () => {
it('opens a path link in the preview', () => {
const body = responseViewer('<p>wrote src/app.ts</p>');
const ev = press(body.querySelector('a.rv-path')!, 'click', { button: 0 });
expect(ev.defaultPrevented).toBe(true);
expect(app.openFilePreview.mock.calls).toEqual([['/home/me/proj/src/app.ts', 'sess-1']]);
expect(app.openLinkThroughWebTabIfLoopback).not.toHaveBeenCalled();
});
it('hands a loopback URL to the web-tab hook', () => {
const body = responseViewer(
'<p><a href="http://localhost:5173/" target="_blank" rel="noopener noreferrer">dev server</a></p>'
);
const ev = press(body.querySelector('a')!, 'click', { button: 0 });
expect(ev.defaultPrevented).toBe(true);
expect(app.openLinkThroughWebTabIfLoopback.mock.calls).toEqual([['http://localhost:5173/']]);
expect(app.openFilePreview).not.toHaveBeenCalled();
});
});
+184
View File
@@ -0,0 +1,184 @@
/**
* @fileoverview Ralph file routes for a REMOTE (SSH) session.
*
* A remote session's `workingDir` is a path on the remote host. `fix-plan/read`
* used to read `@fix_plan.md` with local `fs`, and `fix-plan/write` and
* `ralph-prompt/write` wrote their files with it, so with a same-named local folder
* (an sshfs mount, a shared home layout) they read and wrote the LOCAL twin. Now:
* - `fix-plan/read` reads the file on the remote host through `src/remote-files.ts`
* (the File Viewer's ssh path), and a missing remote file is the same 404 as a
* missing local one;
* - both writes are refused (remote writes are out of scope, like the File Viewer's
* remote edit).
*
* The workspace path EXISTS locally here, holding a different `@fix_plan.md`, so a
* route that fell back to local `fs` would visibly import or overwrite the wrong file.
* The ssh layer is mocked (no connection is opened); the pure builders and
* `RemoteFileAccessError` stay real.
*
* Port: N/A (app.inject doesn't open ports)
*/
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { safeRmHomeTree } from '../mocks/test-helpers.js';
import { registerRalphRoutes } from '../../src/web/routes/ralph-routes.js';
import type { RemoteProbe } from '../../src/remote-files.js';
import type { SessionRemote } from '../../src/types/session.js';
vi.mock('../../src/remote-files.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/remote-files.js')>();
return { ...actual, remoteProbePaths: vi.fn(), remoteReadFile: vi.fn() };
});
import { remoteProbePaths, remoteReadFile, RemoteFileAccessError } from '../../src/remote-files.js';
const mockedProbePaths = vi.mocked(remoteProbePaths);
const mockedReadFile = vi.mocked(remoteReadFile);
// The local twin of the remote workspace: same absolute path, different file.
const TWIN = join(homedir(), '.codeman-test-ralph-remote-twin');
const LOCAL_PLAN = '# Fix Plan\n\n- [ ] LOCAL task\n';
const REMOTE_PLAN = '# Fix Plan\n\n- [ ] REMOTE task\n';
const remote: SessionRemote = {
hostId: 'host-1',
label: 'testhost',
host: '192.0.2.10',
username: 'j',
remotePath: TWIN,
};
function fileProbe(size = REMOTE_PLAN.length): RemoteProbe {
return { realPath: join(TWIN, '@fix_plan.md'), kind: 'file', size, mtimeMs: 1_700_000_000_000 };
}
const rootProbe: RemoteProbe = { realPath: TWIN, kind: 'directory', size: 0, mtimeMs: 0 };
describe('ralph file routes for a remote (SSH) session', () => {
let harness: RouteTestHarness;
let importFixPlanMarkdown: ReturnType<typeof vi.fn>;
beforeAll(() => {
mkdirSync(TWIN, { recursive: true });
});
afterAll(() => {
safeRmHomeTree(TWIN);
});
beforeEach(async () => {
writeFileSync(join(TWIN, '@fix_plan.md'), LOCAL_PLAN);
harness = await createRouteTestHarness(registerRalphRoutes);
importFixPlanMarkdown = vi.fn(() => 1);
const session = harness.ctx._session as unknown as Record<string, unknown>;
session.workingDir = TWIN;
session.remote = { ...remote };
session.ralphTracker = {
generateFixPlanMarkdown: vi.fn(() => '# Fix Plan\n\n- [ ] generated\n'),
importFixPlanMarkdown,
todos: [],
};
mockedProbePaths.mockResolvedValue([fileProbe(), rootProbe]);
mockedReadFile.mockResolvedValue(Buffer.from(REMOTE_PLAN));
});
afterEach(async () => {
await harness.app.close();
vi.clearAllMocks();
});
const post = (route: string, payload?: unknown) =>
harness.app.inject({ method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/${route}`, payload });
describe('POST fix-plan/read', () => {
it('imports the file from the remote host, not the local twin', async () => {
const res = await post('fix-plan/read');
expect(JSON.parse(res.body)).toMatchObject({ success: true, data: { importedCount: 1 } });
expect(mockedProbePaths).toHaveBeenCalledWith(remote, [join(TWIN, '@fix_plan.md'), TWIN]);
expect(mockedReadFile).toHaveBeenCalledWith(remote, join(TWIN, '@fix_plan.md'), expect.any(Number));
expect(importFixPlanMarkdown).toHaveBeenCalledWith(REMOTE_PLAN);
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
});
it('answers the same not-found as a local read when the remote file is missing', async () => {
mockedProbePaths.mockResolvedValue([null, rootProbe]);
const res = await post('fix-plan/read');
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'NOT_FOUND' });
expect(mockedReadFile).not.toHaveBeenCalled();
expect(importFixPlanMarkdown).not.toHaveBeenCalled();
});
it('treats a @fix_plan.md that resolves outside the workspace as missing', async () => {
// e.g. a symlink to ~/.ssh/id_rsa on the remote host: the File Viewer's confinement applies.
mockedProbePaths.mockResolvedValue([{ ...fileProbe(), realPath: '/home/j/.ssh/id_rsa' }, rootProbe]);
const res = await post('fix-plan/read');
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'NOT_FOUND' });
expect(mockedReadFile).not.toHaveBeenCalled();
expect(importFixPlanMarkdown).not.toHaveBeenCalled();
});
it('refuses a remote path that is not a regular file, or one over the cap, without reading it', async () => {
for (const probe of [
{ ...fileProbe(), kind: 'directory' as const },
{ ...fileProbe(), kind: 'other' as const },
fileProbe(64 * 1024 * 1024),
]) {
mockedProbePaths.mockResolvedValue([probe, rootProbe]);
const res = await post('fix-plan/read');
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'OPERATION_FAILED' });
}
expect(mockedReadFile).not.toHaveBeenCalled();
expect(importFixPlanMarkdown).not.toHaveBeenCalled();
});
it('reports an unreachable host as a failed read, never a local fallback', async () => {
mockedProbePaths.mockRejectedValue(new RemoteFileAccessError('remote host testhost unreachable: timeout'));
const res = await post('fix-plan/read');
const body = JSON.parse(res.body);
expect(body).toMatchObject({ success: false, errorCode: 'OPERATION_FAILED' });
expect(body.error).toContain('unreachable');
expect(body.error).not.toContain('RemoteFileAccessError');
expect(importFixPlanMarkdown).not.toHaveBeenCalled();
});
});
describe('writes', () => {
it('refuses fix-plan/write and leaves the local twin alone', async () => {
const res = await post('fix-plan/write');
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(readFileSync(join(TWIN, '@fix_plan.md'), 'utf-8')).toBe(LOCAL_PLAN);
});
it('refuses ralph-prompt/write and creates nothing locally', async () => {
const res = await post('ralph-prompt/write', { content: 'do the thing' });
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(existsSync(join(TWIN, '@ralph_prompt.md'))).toBe(false);
});
});
describe('a local session is unchanged', () => {
beforeEach(() => {
delete (harness.ctx._session as unknown as Record<string, unknown>).remote;
});
it('reads the local file without touching ssh', async () => {
const res = await post('fix-plan/read');
expect(JSON.parse(res.body)).toMatchObject({ success: true });
expect(importFixPlanMarkdown).toHaveBeenCalledWith(LOCAL_PLAN);
expect(mockedProbePaths).not.toHaveBeenCalled();
expect(mockedReadFile).not.toHaveBeenCalled();
});
it('writes the local files', async () => {
expect(JSON.parse((await post('fix-plan/write')).body)).toMatchObject({ success: true });
expect(readFileSync(join(TWIN, '@fix_plan.md'), 'utf-8')).toContain('generated');
expect(JSON.parse((await post('ralph-prompt/write', { content: 'do the thing' })).body)).toMatchObject({
success: true,
});
expect(readFileSync(join(TWIN, '@ralph_prompt.md'), 'utf-8')).toBe('do the thing');
});
});
});
+227
View File
@@ -0,0 +1,227 @@
/**
* @fileoverview Real-Chromium coverage for the L-shaped header strip (`_syncTabStripCorner()`, app.js).
*
* What DOM emulation cannot answer: whether the float the strip measures really keeps every tab out from under
* the toolbar, whether the rows below it really run the header's full width, and when the strip falls back to
* the plain wrap. The real `<header>` from index.html, the shipping styles.css, constants.js and app.js are
* loaded into a page; `updateTabOverflowMode()` runs for real on a bare app instance.
*
* Port: none (page.route on a fake origin, no server).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const dir = resolve(import.meta.dirname, '../src/web/public');
const read = (name: string) => readFileSync(resolve(dir, name), 'utf8');
const styles = read('styles.css');
const header = /<header class="header">[\s\S]*?<\/header>/.exec(read('index.html'))![0];
const ORIGIN = 'http://app.test/';
type Rect = { left: number; right: number; top: number; bottom: number };
describe('L-shaped header tab strip (real Chromium)', () => {
let browser: Browser;
let p: Page;
beforeAll(async () => {
browser = await chromium.launch();
});
afterAll(async () => {
await browser.close();
});
/** Open the real header with `tabs` classic tabs; `twoRows` is the manual two-rows setting. */
async function open(options: { tabs: number; width?: number; height?: number; twoRows?: boolean }) {
p = await browser.newPage({
viewport: { width: options.width ?? 1400, height: options.height ?? 700 },
deviceScaleFactor: 1,
});
await p.route(ORIGIN, (route) =>
route.fulfill({
contentType: 'text/html',
body:
'<!doctype html><html data-header-stats="compact" data-tab-orientation="horizontal" data-session-list="header">' +
`<head><style>${styles}</style></head><body>${header}</body></html>`,
})
);
await p.goto(ORIGIN);
await p.addScriptTag({
content:
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
read('constants.js') +
'\n' +
read('app.js') +
'\nwindow.CodemanApp = CodemanApp;',
});
await p.evaluate(
({ tabs, twoRows }) => {
const w = window as any;
const app = Object.create(w.CodemanApp.prototype);
app.$ = (id: string) => document.getElementById(id);
app.sessions = new Map();
app.loadAppSettingsFromStorage = () => ({ tabTwoRows: twoRows });
app.getDefaultSettings = () => ({});
app.isSessionSidebarActive = () => false;
app.updateConnectionLines = () => undefined;
const strip = document.getElementById('sessionTabs')!;
if (twoRows) strip.classList.add('tabs-two-rows');
for (let i = 1; i <= tabs; i++) {
const id = `s${i}`;
app.sessions.set(id, { id, name: `session-${i}` });
strip.insertAdjacentHTML(
'beforeend',
`<div class="session-tab" data-id="${id}" role="tab"><span class="tab-name">session-${i}</span></div>`
);
}
w.__app = app;
},
{ tabs: options.tabs, twoRows: !!options.twoRows }
);
await relayout();
}
const settled = () => p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
const relayout = async () => {
await p.evaluate(() => (window as any).__app.updateTabOverflowMode());
await settled();
};
const cornered = () => p.evaluate(() => document.getElementById('sessionTabs')!.classList.contains('tabs-corner'));
const toolbar = () =>
p.evaluate(() => document.getElementById('headerRight')!.getBoundingClientRect().toJSON()) as Promise<Rect>;
/** The visible part of each tab: the strip clips what it scrolls or overflows. */
const visibleTabs = () =>
p.evaluate(() => {
const strip = document.getElementById('sessionTabs')!.getBoundingClientRect();
return [...document.querySelectorAll('#sessionTabs > .session-tab')]
.map((el) => {
const r = el.getBoundingClientRect();
return {
left: Math.max(r.left, strip.left),
right: Math.min(r.right, strip.right),
top: Math.max(r.top, strip.top),
bottom: Math.min(r.bottom, strip.bottom),
};
})
.filter((r) => r.right > r.left && r.bottom > r.top);
}) as Promise<Rect[]>;
const overlapping = async () => {
const b = await toolbar();
return (await visibleTabs()).filter(
(r) => r.left < b.right && r.right > b.left && r.top < b.bottom && r.bottom > b.top
);
};
it('puts a wrapped classic desktop strip around the toolbar, with no tab under it', async () => {
await open({ tabs: 40 });
expect(await p.evaluate(() => document.getElementById('sessionTabs')!.classList.contains('tabs-auto-wrap'))).toBe(
true
);
expect(await cornered()).toBe(true);
expect(await overlapping()).toEqual([]);
await p.close();
});
it('runs a row below the toolbar past its left edge', async () => {
await open({ tabs: 40 });
const b = await toolbar();
const below = (await visibleTabs()).filter((r) => r.top >= b.bottom);
expect(below.length).toBeGreaterThan(0);
expect(Math.max(...below.map((r) => r.right))).toBeGreaterThan(b.left);
await p.close();
});
it('keeps the plain wrap when the first tab is too wide for the room beside the toolbar', async () => {
await open({ tabs: 40 });
await p.evaluate(() => {
const head = document.querySelector('.header')!.getBoundingClientRect();
const right = document.getElementById('headerRight')!.getBoundingClientRect();
const first = document.querySelector<HTMLElement>('#sessionTabs > .session-tab')!;
first.style.minWidth = first.style.width = `${Math.round(head.width - right.width / 2)}px`;
});
await relayout();
expect(await cornered()).toBe(false);
expect(await overlapping()).toEqual([]);
await p.close();
});
it('leaves a strip that fits on one row unwrapped and plain', async () => {
await open({ tabs: 3 });
expect(await p.evaluate(() => document.getElementById('sessionTabs')!.classList.contains('tabs-auto-wrap'))).toBe(
false
);
expect(await cornered()).toBe(false);
await p.close();
});
for (const arrangement of ['tabs-triage', 'tabs-clusters', 'tabs-ledger']) {
it(`never corners the ${arrangement} arrangement`, async () => {
await open({ tabs: 40 });
await p.evaluate((cls) => document.getElementById('sessionTabs')!.classList.add(cls), arrangement);
await relayout();
expect(await cornered()).toBe(false);
await p.close();
});
}
it('never corners a strip outside #sessionTabsHost', async () => {
await open({ tabs: 40 });
await p.evaluate(() => {
const elsewhere = document.createElement('div');
elsewhere.style.width = '600px';
document.body.appendChild(elsewhere);
elsewhere.appendChild(document.getElementById('sessionTabs')!);
document.getElementById('sessionTabs')!.classList.add('tabs-two-rows');
});
await relayout();
expect(await cornered()).toBe(false);
await p.close();
});
it('keeps the plain wrap for a wrapped strip past its max-height, so scrolling never puts tabs under the toolbar', async () => {
await open({ tabs: 50, width: 1100, height: 500 });
expect(await p.evaluate(() => document.getElementById('sessionTabs')!.classList.contains('tabs-auto-wrap'))).toBe(
true
);
expect(await cornered()).toBe(false);
await p.evaluate(() => (document.getElementById('sessionTabs')!.scrollTop = 60));
await settled();
expect(await overlapping()).toEqual([]);
await p.close();
});
it('keeps the plain wrap for a scrolled two-rows strip across a re-render', async () => {
await open({ tabs: 50, width: 1100, height: 500, twoRows: true });
await p.evaluate(() => (document.getElementById('sessionTabs')!.scrollTop = 60));
await relayout();
expect(await cornered()).toBe(false);
expect(await overlapping()).toEqual([]);
await p.evaluate(() => (document.getElementById('sessionTabs')!.scrollTop = 0));
await settled();
expect(await overlapping()).toEqual([]);
await p.close();
});
it('re-sizes the corner when the toolbar is narrowed with --header-right-width', async () => {
await open({ tabs: 40 });
const width = () =>
p.evaluate(() =>
parseFloat(document.getElementById('sessionTabs')!.style.getPropertyValue('--tab-corner-width'))
);
const before = await width();
await p.evaluate(() => {
const root = document.documentElement;
root.setAttribute('data-header-right-width', 'set');
root.style.setProperty('--header-right-width', '300px');
});
await relayout();
expect(await cornered()).toBe(true);
const after = await width();
expect(after).toBeLessThan(before);
expect(Math.abs(after - ((await toolbar()).width + 8))).toBeLessThan(16);
expect(await overlapping()).toEqual([]);
await p.close();
});
});
@@ -40,6 +40,29 @@ describe('orphaned terminal input recovery wiring', () => {
baseUrl = `http://localhost:${server.boundPort}`;
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
// Headless Chromium is a fine pointer, and create() then returns null (#716).
// The recovery cases below are the soft-keyboard path, so this page reports
// a coarse pointer. Other media queries stay native.
await page.addInitScript(() => {
const native = window.matchMedia.bind(window);
window.matchMedia = (query: string) => {
if (query === '(pointer: coarse)') {
return {
matches: true,
media: query,
onchange: null,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return false;
},
};
}
return native(query);
};
});
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
await page.waitForFunction(() => (window as any).app?._keyCode229Recovery, null, { timeout: 30000 });
@@ -50,6 +73,18 @@ describe('orphaned terminal input recovery wiring', () => {
if (server) await server.stop();
}, 60000);
it('does not install recovery when the primary pointer is fine', async () => {
const fine = await browser.newPage();
try {
await fine.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await fine.waitForFunction(() => (window as any).app?.terminal?.textarea, null, { timeout: 30000 });
const installed = await fine.evaluate(() => (window as any).app?._keyCode229Recovery ?? null);
expect(installed).toBeNull();
} finally {
await fine.close();
}
});
/**
* Drive one keystroke through the real textarea and report what reached the
* PTY send path. `dispatchInput` mirrors GBoard: a keydown with no usable key
+69 -3
View File
@@ -46,9 +46,19 @@ function inputEvent(data: string, overrides: Record<string, unknown> = {}) {
return { data, inputType: 'insertText', isComposing: false, ...overrides };
}
function harness({ screenReader = false } = {}) {
function harness({ screenReader = false, coarse = true, matchMedia = true, userAgent = '' } = {}) {
const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8');
const exposed: Record<string, any> = {};
const exposed: Record<string, any> = { navigator: { userAgent } };
if (matchMedia) {
// create() attaches only for a coarse pointer (#716). The recovery cases
// below are the soft-keyboard behaviour, so they opt in. A fine pointer
// (or no matchMedia at all) must get null and no listeners.
exposed.matchMedia = (query: string) => ({
matches: query === '(pointer: coarse)' && coarse,
addEventListener() {},
removeEventListener() {},
});
}
vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' });
const textarea = makeTextarea();
@@ -111,6 +121,55 @@ function loadTerminalInput() {
}
describe('orphaned terminal input recovery', () => {
it('does not attach on a fine pointer, so a desktop dead key is not forwarded twice', () => {
const h = harness({ coarse: false });
expect(h.controller).toBeNull();
expect(h.textarea.listenerCount()).toBe(0);
expect(h.textarea.registrations()).toEqual([]);
});
it('does not attach when matchMedia is missing', () => {
// Fail closed. Treating "unknown" as coarse would put the recovery back on
// a desktop whose matchMedia threw, which is the doubled backtick.
const h = harness({ matchMedia: false });
expect(h.controller).toBeNull();
expect(h.textarea.listenerCount()).toBe(0);
});
it('attaches on Android even when the primary pointer reports fine (a mouse or stylus attached)', () => {
// Chromium makes a mouse, a trackpad cover or an S Pen the primary pointer, but the text still comes
// from the soft keyboard, so the #441/#541 recovery must stay on there.
const h = harness({
coarse: false,
userAgent:
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36',
});
expect(h.controller).not.toBeNull();
h.keydown();
h.input('`');
h.flushTimers();
expect(h.emitted).toEqual(['`']);
});
it('stays off on a desktop fine pointer that is not Android', () => {
const h = harness({
coarse: false,
userAgent:
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
});
expect(h.controller).toBeNull();
});
it('still recovers a dropped insertText on a coarse pointer', () => {
const h = harness({ coarse: true });
h.keydown();
h.input('`');
expect(h.emitted).toEqual([]);
h.flushTimers();
expect(h.emitted).toEqual(['`']);
expect(h.textarea.listenerCount()).toBeGreaterThan(0);
});
it('forwards the committed text when xterm stayed silent', () => {
const h = harness();
h.keydown();
@@ -432,7 +491,14 @@ describe('terminal-ui wiring: what counts as "xterm spoke for this keystroke"',
*/
function editSyncHarness() {
const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8');
const exposed: Record<string, any> = {};
const exposed: Record<string, any> = {
// Autocorrect recovery is the coarse-pointer (soft keyboard) path.
matchMedia: (query: string) => ({
matches: query === '(pointer: coarse)',
addEventListener() {},
removeEventListener() {},
}),
};
vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' });
const textarea = Object.assign(makeTextarea(), { value: '' });
+40
View File
@@ -48,6 +48,17 @@ function loadContext() {
// would run against no controller at all.
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
// The real controller attaches only for a coarse pointer (#716). Tile tests
// that exercise soft-keyboard recovery opt in; a test sets this false to
// prove a desktop fine pointer installs nothing.
__coarsePointer: true,
matchMedia(query: string) {
return {
matches: query === '(pointer: coarse)' && windowStub.__coarsePointer !== false,
addEventListener() {},
removeEventListener() {},
};
},
};
const context = vm.createContext({
console: { ...console, log: vi.fn(), debug: vi.fn() },
@@ -151,6 +162,7 @@ afterEach(() => {
});
beforeEach(() => {
windowStub.__coarsePointer = true;
FakeTerminal.coreFactory = null;
FakeFit.proposed = { cols: 80, rows: 24 };
FakeSocket.instances = [];
@@ -270,6 +282,27 @@ describe('what xterm generates never enters the durable queue', () => {
expect(ws.inputFrames()).toEqual([{ t: 'i', d: '\x1b[I' }]);
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
});
it('queues a Ctrl+V bracketed-paste frame on the durable path', async () => {
// image-input.js hands this shape to terminal.input(). onData must not
// drop it as a query reply or send it once as a mouse/focus report: the
// same two predicates gate the primary pane before its bracketed-paste branch.
const app = makeApp();
const { ws, term } = await connectTile(app);
ws.open();
const input = windowStub.CodemanTerminalInput as {
shouldSuppressTerminalQueryResponse(data: string): boolean;
isTerminalFocusOrMouseReport(data: string): boolean;
};
const framed = '\x1b[200~line one\r\x1b[?1;2c\r\x1b[<0;10;5M\r\x1b[I\x1b[201~';
expect(input.shouldSuppressTerminalQueryResponse(framed)).toBe(false);
expect(input.isTerminalFocusOrMouseReport(framed)).toBe(false);
term.type(framed);
expect(ws.inputFrames().map((f) => [f.d, f.seq, f.cid])).toEqual([[framed, 1, 'c-test']]);
expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual([framed]);
});
});
describe('TerminalTile leaves the input-socket map', () => {
@@ -1183,6 +1216,13 @@ function lineOf(frames: Array<{ d?: string }>) {
type ControllerTile = Tile & { _keyCode229Recovery: unknown };
describe("TerminalTile wires the primary pane's soft-keyboard controller (#441, #541)", () => {
it('does not install the controller on a fine pointer', async () => {
windowStub.__coarsePointer = false;
const { tile, term } = await connectTile(makeApp());
expect((tile as ControllerTile)._keyCode229Recovery).toBeNull();
expect(term.textareaListeners).toEqual([]);
});
it("installs on THIS tile's composition helper and textarea, and destroy() restores xterm's own", async () => {
const helpers = withCompositionHelpers();
const { tile, term } = await connectTile(makeApp());