Repository navigation
Conversation
…able cells
A CLI that word-wraps inside a markdown table cell or an indented paragraph
breaks a long URL or path short of the right edge and indents the rest, so
nothing is flagged isWrapped and no row is full width. Each row linked only
a fragment, and the fragment opened the wrong target.
- findTerminalUrls() (constants.js) is the URL grammar for the link provider:
a ")" that closes a "(" inside the URL is kept, an unmatched one still ends
it, so (https://...) and [text](https://...) stay clean.
- terminalUrlContinuationExtent() joins rows the CLI wrapped a URL across,
only when the fragment ends on a wrap-break character, the next row is
indented no further right than the URL started, and its first token looks
like URL material. Table columns after the first fragment are left out.
- terminalLinkLine() widens terminalPathLine() to that extent; a URL that
spans rows is emitted as one link per row, each opening the whole URL, so
the indent and other columns between the pieces are not underlined.
- terminalCellWrappedPaths() follows an absolute path down its own table
cell (space-separated or box-drawn), so two-column tables link both paths.
- A full-width table divider no longer counts as a hard-wrapped line.
- OSC 8 hyperlinks open through the same opener, web URLs only.
Every path pattern ended a path at whitespace, so "/mnt/c/OneDrive - Contoso/a.docx" linked "/mnt/c/OneDrive" plus a relative "Contoso/a.docx", two links to the wrong files, in the terminal and the response viewer alike. findFilePathLinks() now takes a spaced path whole where the line bounds it: the whole line is the path, or it follows a list marker and/or a short "Label:". A relative spaced path needs the marker or label. The candidate must read as one path (no earlier word ends on an extension, starts a new "/" path or is an arrow). A spaced line that is one path but not linkable (unknown root, "." or ".." segment) links nothing instead of a fragment.
…t frame The frame-walking test asserted that zlib.zstdDecompressSync returns only the first of 40 concatenated frames. That was true when the walker was written, but nodejs/node#65865 (shipped in 26.10.0 and in 24.22.0 LTS) makes Node decode every frame, so the assertion fails on current Node 24 LTS and 26 while the walker itself is still correct. The assertion tested Node, not Codeman: the walker's own 40-frame checks stay, and the comments now say which Node versions stop at the first frame, since the supported range (>=22) still includes them.
A URL ending a row on `/` took the next row's first word whenever it carried URL punctuation, so a shell prompt, a home path or an HTTP status line could be appended to it. Refuse those tokens, and refuse a column-0 row as a hard continuation (real continuations are indented), which also keeps `status=200`, `24/7` and `really?` out. A divider row no longer continues the row above it, so a URL reaching the right edge does not absorb a full-width rule below it. Pin the new negatives at detector and provider level, re-indent the positive continuation fixtures, and pin the `self-hosted.` and `and/or` trade-offs.
A spaced path is no longer taken across a `path:line` suffix (grep -n, rg -n and compiler output) and an absolute path after a generic label such as `Error:` or `Note:` goes back to the ordinary detectors: any label must now be a path label. `Saved the report to: /mnt/c/My Docs/a.docx` splits as on master, pinned as a documented trade-off. Also: - a rooted candidate needs a directory before its first space, and an unlinkable one reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` and `/review the changes in src/foo.ts` keep their links while `/etc/secret files/passwords.txt` still links nothing - a run of spaces is a column gap, not part of a name - a leading `./` is not read as a segment - the response viewer carries a bold path label that starts a later line of a paragraph (after a `<br>`), and degrades if the label pattern is missing - response viewer tests render with the options chat uses
Every keyboard open and close resized the PTY, and Claude Code's inline renderer answers each height change by clearing the screen and reprinting only the transcript tail that fits, so rows above that tail were lost from tmux for good. Measured on Claude Code 2.1.306 with tmux 3.2a: five keyboard cycles (62x38 to 62x15 and back) left 75 of 100 numbered lines in the pane's history; with the rows held the PTY stays 62x38, no SIGWINCH reaches the pane and all 100 remain (Ark0N#672). While the keyboard is up the rows captured at the show transition are held through getTerminalDimensions, columns still follow the box, and the screen, now taller than its box, is translated up by --kb-clip so the last content row sits at the bottom of the visible area. This is the one documented exception to the columns-only geometry rule: there the extra rows came from another device and nothing kept the composer in view, here they are this device's own rows held on purpose and the clip keeps the composer visible. The local-echo overlay's setRowLimit narrows the renderer's existing totalRows bound, so one place decides where a long draft stops growing downward. Only inline renderers hold, through a new registry capability keyboardHold: 'inline' (Claude alone) published to the page as window.__codemanKeyboardHoldModes. Full-height layouts such as codex, Claude's fullscreen mode (mouse tracking on, unmeasured), and remote or docker panes resize as before. The hold is released on keyboard close, a shape change, a tab switch, a session update, a reconnect, or the setting turned off. Per-device setting Keep Size Under Keyboard, default on.
…tions Opening another session in the case you are already working in meant re-picking the case and the mode in the toolbar. An opt-in + now sits with each tab's gear and close icons (App Settings, Tabs, New Session Button on Tabs: per device, off by default everywhere). It opens a menu of the CLIs that tab's case can start, each shown with its logo and name as in the Run menus, and a pick starts one session in that tab's case, or the managed worktree it runs in, and in its named group, without changing the Run button's mode or case. The CLIs offered follow the Run menu's own rule for the case, so a Docker case lists what its container has rather than what the host has. A tab outside any named group sends no group, so the case's default session group places the new session the way it places a toolbar Run. Wherever a tab has a row menu (the vertical rail, the sidebar) that menu gains "New session here…", the keyboard route to the same picker, since Enter on the + itself does nothing. On a phone the + joins gear and close on the active tab only, and the label reserve grows to 68px while the setting is on, so the tab's centre stays off the icons. The shared tab layout menu no longer closes when focus leaves an item for nowhere: Safari never focuses a clicked button, so pressing an item blurred the focused one with no related target and the click was lost. This is the tile count menu's rule, and it fixes the named-group menu as well.
A URL in the last column of a space-aligned table ended on a slash, so the first cell of the next record (billing-api, CHANGELOG.md, ...) passed every continuation test and was joined onto it. The alignment check that refused this only ran when other columns followed the URL. A continuation row must now start inside the URL's own cell: a gap of two or more spaces between its indent and where the URL starts on the first row means it opens an earlier column. An indented token that is a whole email address is also refused. Adds the six table shapes and the email case as negative tests at detector and provider level, and documents the rule.
A path label says a path follows, not that the words before it belong to the path. Restore the guard that defers a relative candidate whose only slash word is its last, so `Wrote: 120 lines to src/foo.ts` links src/foo.ts as on master. `Saved to: My Docs/plan.md` and `Saved to: 03 Attachments/plan.pptx` split again as a documented trade-off; `Files: src/components and utils/x.ts` is pinned as the residual the guard does not catch. Refuse command lines whose executable sits under a bin/ or .bin/ directory, or that carry a flag word, so they keep master's links. Update the JSDoc example, the CLAUDE.md pointer and the architecture-invariants paragraph.
…ment the directory launch
`codeman agent spawn ""` (for example `spawn "$CASE"` with CASE unset)
used to launch a worker in the caller's cwd. Both case checks now test
`caseName !== undefined`, so an empty name reaches quick-start and is
refused by its schema as before.
Drop the `{ id }` fallback on the create answer: `POST /api/sessions`
answers `{ session: { id } }`, and the test fakes now use that shape.
Tests: an empty case name (unit and through the real argument parser
and HTTP transport), the claude `--ready` order create, /interactive,
wait-output on the created id, and a non-JSON failed start (id alone on
stdout, reason on stderr, exit 1).
Docs: the spawn mapping and directory-launch JSON shape in
docs/api-reference.md, the README and wiki `codeman agent` sections, the
cli-agent @fileoverview and the skill reference tables; reflow the new
SKILL.md paragraph (plugin mirror synced); the changeset notes the
local exit-4 refusal of an unknown mode for named cases.
Node 22 rejects a refused connection to a host that resolves to both ::1 and 127.0.0.1 with an AggregateError whose message is empty and whose code is ECONNREFUSED. The readiness fallback dropped that code, and the agent CLI's top-level error line and the MCP server's "Cannot reach Codeman" error printed nothing at all for it. Add describeRequestError() (message, else code, else a generic line) and use it at all three sites.
A side-by-side sweep of master and this branch over about 590 realistic lines found more shapes where a spaced candidate swallowed prose or a command: - An earlier word that is a lowercase connective or verb (and, or, to, in, the, is, has, contains, then, as and a few more) now makes the candidate a sentence, for own-line, list-marker and labelled forms alike: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. `at` and `of` stay out so screenshot names and `Copy of report.docx` still link whole; a name such as `Pros and Cons.md` splits as on master. - A bare `=` word is refused like the arrows. - An executable under `scripts/` or `tools/` is refused like one under `bin/` (`/home/me/proj/scripts/deploy prod.yml`). - An earlier word holding an extension anywhere (`a.ts#L12`) ends the path. The two residuals pinned earlier (`/home/me/proj contains a.md` and `Files: src/components and utils/x.ts`) now return master's links.
A URL ending in `/` still took the next indented row when that row was code, a label, a log or config field, a measurement or the end of a sentence. A side-by-side sweep against master turned up shapes such as `ready.` under a codex `Listening on` line, `expect(x).toBe(y)` under a jest title, `Failure/Error:` under an rspec title, `API_KEY=...` under `BASE_URL=...`, `level=info` log fields, `45%|####` tqdm bars, `12.3MB/45.0MB`, `12.3ms` and `10:00:00.123`. A continuation is now refused when it ends with a colon, when it is a measurement or clock time, or when it is a `name=value` token under a URL ending in `/` (a query needs `?` first). A token with only `-`, `_` or `.` is also refused when it carries a call paren or ends a sentence. Wiki titles such as `Mercury_(planet)`, a file name before its period, query parameters after `?`, `&` or `_`, and percent-encoding that starts with digits still join. `self-hosted.` moves from the pinned trade-offs to the negatives; the trade-off is now a lone word without sentence punctuation.
Gate inspect, stage and submit behind the synced agentCoordinationEnabled setting (default OFF, App Settings > Cross-session features, zh-CN strings). While off, all three routes answer 403 FORBIDDEN naming the setting, checked before session lookup; the CLI verbs print it verbatim. Remember the attributed Codex rollout per session and re-verify only its head on later calls, rescanning only date directories not older than it for a /new conflict. The first walk reads 1 KiB prefixes first, so a store of 2,100+ unrelated rollouts still attributes. Answer unexpected inspection errors with 422 OPERATION_FAILED and log the session id and error code only. Read the guarded composer glyph and footer from the CLI registry: codex workDetect.promptGlyph and a new workDetect.composerFooterLine capability, compiled through compileVersionRegex. Codex footer effort words are one shared list with modelDetect.screenLine. Fix the wiki API reference link, frame the versioning policy entry as a case of the experimental rule, join the CLAUDE.md Agent CLI paragraph, and update the changeset, skill reference and architecture notes.
…ry readers The Past Sessions scanner and the offline history reader each parsed Codex rollouts with their own shape helpers, their own CODEX_HOME resolution and different injected-context rules. Move the store location, the three message shapes and the injected-context rule into codex-rollout-format.ts. The scanner's behaviour is unchanged; the offline reader adopts its rule in the next commit.
A forked or spawned thread's rollout carries its own session_meta followed by its parent's and a copy of the parent's history. A later session_meta for a declared ancestor now opens an inherited prefix that is dropped: by ordinal below subagent_history_start_ordinal in paginated history, and up to the thread_settings_applied event Codex appends after the copy in legacy history, checked against the parent's own messages when the copy holds more than one candidate. An unrelated id still refuses the file. Store attribution now has its own budget (--metadata-bytes) separate from the transcript budget (--scan-bytes), reads each head in 32 KiB steps, and --max-entries is a flag, so id lookups work on stores of thousands of rollouts. An unattributable store file or unreadable directory is counted instead of aborting the lookup, and a rollout that a running Codex appends to is read as a snapshot up to its last complete line. User records carry no phase, date bounds are UTC calendar dates, the injected-context rule is the shared one, a symlinked sessions root is followed as the Past Sessions scanner follows it, and --json with --format text is refused.
Replace docs/offline-history.md, which repeated the skill reference and was linked from nowhere, with a README section and a wiki section beside the codeman agent commands. Bring the skill reference, its plugin mirror, the Codeman Skill wiki page and the changeset in line with the reader: forks, the separate budgets and their flags, UTC dates, phases and complete:false.
… per line The local echo overlay rebuilt every character of the prompt on each keystroke, one absolutely positioned span per character, so a keystroke cost O(prompt length) in DOM work, style, layout and paint. Every positioned span was also its own paint layer, so the browser re-layerized and repainted thousands of them per frame. In headless Chromium with a 6x CPU slowdown, a ~4,100-character prompt ran at a median 813 ms per frame, and ~300 characters already missed 60 fps (54 ms). renderOverlay() now keeps a per-container record of the line elements it drew and rebuilds only lines whose text, position or composition range changed. It starts over when the font, colour, cell size or Unicode version changes, or when something else emptied or edited the container. makeLine() lays the spans out in a flex row instead of positioning each one, and sets the shared font styles once on the line. Cell edges are snapped to a quarter pixel, which Blink, WebKit and Gecko all store exactly, so the widths add up to the grid without drift and every edge stays within 1/8 px of col * cellW. Same prompt: 17 ms per frame. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…or it app-wide and per case Fetch is the only git command Codeman runs that writes, and the only one that runs a repository's own fetch-post hook, so it stays behind a click and off by default. The Git status window's header row gains Fetch; App Settings -> Bottom bar gains the switch and the interval, synced because the scheduler acts on them, and Session Options -> Git overrides both per case. The scheduler reads settings fresh each tick, times the interval from the start of a run, never overlaps runs, and re-checks the Docker and remote rules every tick before any git runs. Nothing but remote-tracking refs moves: HEAD, the index and the working tree are proven unchanged against real git while origin/main advances. Tests: src/git-fetch.ts and the scheduler, the per-case settings route, the synced/display setting split, the two new i18n rows, and the Fetch block of the Git status browser suite.
- src/web/routes/session-routes.ts:2102: the guarded-submit 409 now applies to tagged input (clientId + seq) only; untagged input is held like a WebSocket frame. The raw write (:2228) goes through holdForGuardedLease and answers 200 once held (a send-and-wait reads `delivered` from the held write; a full queue releases the waiter and answers 409); a mux write takes its own turn in writeViaMux. - test/guarded-lease-held-input.test.ts: real-Session route tests for an untagged raw POST during the lease (written after the Enter) and a held send-and-wait (delivered: true); both fail without the fix. - test/routes/session-routes.test.ts + test/mocks/mock-session.ts: the 409 test now covers tagged input only; new tests for untagged raw (held), untagged mux (own turn) and a full hold queue (409); MockSession gains holdForGuardedLease/releaseGuardedLease and a lease-aware writeViaMux. - src/session.ts:4787 (guardedInputHeld JSDoc) + write() + docs/architecture-invariants.md: write() refuses during the lease only, so cron's paste mode can land ahead of input still draining. - docs/api-reference.md: the input route's 409 is for tagged input; untagged input is held. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unnel its own acknowledgement
…writes so a quick-start cannot tear the file Conflict in src/web/routes/system-routes.ts (PUT /api/settings) resolved by keeping both sides: Ark0N#718's line that never stores an unauthorized tunnelEnabled:true runs first, then Ark0N#719's serialized mutateSettingsFile() write replaces the inline read and plain writeFile. test/routes/system-routes-tunnel-guard.test.ts: Ark0N#718's new tests asserted plain writeFile calls on settings.json. With Ark0N#719 every write goes to a temp file renamed over it, so the assertions read writes through a settingsWrites() helper (writeFile contents keyed by their rename), and the concurrent-stop harness applies a write and the stop at the rename. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t wrap across rows or sit in table cells Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntain spaces as one path Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… overlay line, one paint layer per line Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… conversations offline Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… after readiness failures Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ys at query boundaries Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…atus header, and an interval for it app-wide and per case Conflict in CLAUDE.md (Adding Features, App setting) resolved by keeping both sides: Ark0N#714's sentence that a setting the server acts on must be synced, after the `.strict()` clause, and Ark0N#719's mutateSettingsFile() rule at the end of the bullet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…existing project directories
…tion and guarded submission Conflicts resolved by keeping both sides: - CLAUDE.md (Agent CLI): Ark0N#694's opening (an HTTP client, the experimental agent-session-routes behind agentCoordinationEnabled) with Ark0N#708's clause on the directory spawn and its two refusals. - docs/api-reference.md (codeman agent): Ark0N#708's spawn paragraph and --json shape, with Ark0N#694's inspect/stage/submit in the verb list and the guarded endpoints named before "adds no route for the other verbs". - src/cli-agent.ts (@fileoverview): Ark0N#708's quick-start / directory spawn routes and Ark0N#694's experimental routes in one endpoint list; the two node:fs/promises imports (Ark0N#708's realpath+stat, Ark0N#694's open+stat) merged into one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-shot zstd decoder to the first frame
…e on-screen keyboard is up
…tab from an opt-in + in its actions
… log an unreadable file once (release review) The settings readers for git fetch and keep-warm no longer repeat V8's JSON.parse message, which quotes the text around the fault and could print voiceSettings.apiKey. The 15 s scheduler tick now logs an unreadable settings.json once, and again only after a successful read in between. Stale comments that said settings.json is rewritten in place now say writes are atomic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ips the temp file and rename (release review) settingsWrites() now requires every writeFile to be a settings.json temp file that a rename put in place, with one rename per write, and the concurrent-stop harness stores a direct write to settings.json at once. A stray fs.writeFile(SETTINGS_PATH) in the PUT handler passed all 34 tests before and now fails 20 of them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…elease review) readDockerCases() reads an unreadable or half-written docker-cases.json as [], so a fetch (which runs repository hooks, and runs unattended on the interval) could run inside a Docker workspace. The fetch paths now read it through a strict reader: the Fetch button answers a failed run (state 'error') and the scheduler skips the tick, undoes its picks and logs once. The status and diff routes are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… of a spaced path link (release review) spacedFilePathOnLine() refused only a FIRST word ending in `$`, so `/mnt/c/My Docs$ ls notes.md` and `/mnt/c/Users/me/OneDrive - Contoso/proj$ code notes.md` linked the whole line. Any earlier word ending in `$` now refuses the candidate, and both lines link what master links. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rged agent behaviour (release review) The self-gate now says the skill reads saved Codex conversations offline outside a session; the staging reference says only tagged input gets 409 during a guarded submit (the rest is held), and the API reference notes a managed write past a full hold queue falls back to a direct write. Also: missing reference rows, agent --help naming inspect/stage/submit, the tunnel opt-in in Remote Access, MCP wording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lease review) terminalLinkLine() cut the URL's first row at its cell to stitch the URL, so a path in a later column of that row was never scanned. The cut-off columns now follow the stitched rows as a segment of their own, mapped back to their cells. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merged PRs (mostly Ark0N#714's git fetch) added about 120 em-dashes in comments, docs, test names and three settings strings. Each one is now a comma, colon, semicolon or parentheses. The English settings strings in index.html and their i18n.js keys change together, and the zh-CN value for the fetch interval uses full-width parentheses instead of a double dash. The one left is the regex in constants.js that matches a dash typed as a word, which is behaviour, not punctuation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK and the new CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL (Ark0N#718), with their CLI flags, join the documented environment variables the versioning policy covers, from 1.46.0, the release that gave each its own meaning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 Ark0N#716 Co-authored-by: DDT <1786035110@qq.com>
Owner
Author
|
Superseded by upstream Ark0N#726 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes Ark0N#716.
Two desktop input bugs, both measured against a raw tty (Alacritty on the same machine was the control).
Dead-key backtick arrives twice
CodemanKeyCode229Recoveryis for soft keyboards: xterm drops acomposed: trueinsertTextthat follows a keydown, and the controller forwards that character when xterm stayed silent. On a desktop fine pointer xterm already delivers the composed character (a Swedish dead-key backtick), and the recovery forwarded it again.create()now returns null unlessmatchMedia('(pointer: coarse)')matches. A missing or throwingmatchMediastands down as well (a missed recovery is acceptable; a duplicated keystroke is not). Coarse pointers keep the current recovery, including the edit-based autocorrect sync. The primary pane and everyTerminalTilealready go throughcreate(), so both install paths pick this up.Ctrl+V paste loses bracketed-paste markers after replay
_handleImagePastesent plain text withterminal.paste(). xterm only emits CSI200~/201~while its DECSET 2004 mirror is on, and a terminal replay clears that mirror, so a copied newline was submitted and multi-line pastes into a TUI were merged.Plain text (no admitted image or file) is now a hand-built bracketed frame on the session input path, the same framing
keyboard-accessory.jsalready uses: nested200~/201~markers stripped, newlines normalized to\r, wrapped with the paste start/end markers. It does not use the mux fallback (that path strips every\r). Image and file upload is unchanged. When the local-echo overlay owns that session's prompt, the unsent prefix is flushed first and the frame follows after a short delay, which is the existing bracketed-paste ordering.Item 3 in the issue (first key after a kitty keyboard push) is not part of this change.
Tests
Fail before this change, pass after:
test/terminal-keycode229-recovery.test.ts—create()returns null (no listeners) on a fine pointer and whenmatchMediais missing; a coarse pointer still recovers a droppedinsertText.test/terminal-tile-input.test.ts— a fine pointer installs no tile controller.test/image-paste-trap.test.ts— text Ctrl+V is one bracketed frame via_sendInputAsync(multiline, nested markers, the targeted pane, local-echo prefix), andterminal.paste()is not used. Image upload is unchanged.test/terminal-keycode229-recovery.browser.test.ts— fine pointer leaves recovery off; the existing wiring cases emulate a coarse pointer so they still exercise the soft-keyboard path. That file stays in the browser suite (not the CI gate).Also ran:
npm run typecheck,npm run lint,node scripts/check-frontend-syntax.mjs, and the image-input consumer suites (mobile-prompt-composer,toolbar-attach,upload-document-list-parity,tile-grid-file-drop).