applyPaneExits() only applied the manager's directory when it returned a
value, so after a successful read a session that was split into several
panes, or was no longer on the socket, kept its stale cwd. The manager
already keeps its map across a failed or empty read, so the server now
applies its answer as given, undefined included, and setCwd() broadcasts
the clear once.
A blank #{pane_current_path} on a single-pane session (tmux could not read
the process cwd that tick) now carries the previous entry forward in
derivePaneCurrentPaths(), so only a split or unlisted session clears.
The server tests now drive the real TmuxManager with only the tmux read
faked. docs/api-reference.md documents SessionState.cwd.
A relative path printed in the terminal was resolved against the session's
workingDir, which is fixed at create time and never follows a cd. In a shell
that moved into a subdirectory or a sibling repo, the link opened the wrong
file or reported "File not found".
The pane-exit watcher's batched list-panes read already carries
#{pane_current_path}. derivePaneCurrentPaths() turns those rows into one
directory per single-pane session, TmuxManager.getPaneCurrentPath() serves it
from memory, and the server applies it on the existing paneExitsUpdated tick
as SessionState.cwd, broadcasting only on a change. No new tmux call, no new
SSE event. A failed read keeps the previous answers.
Session.setCwd() uses the paneExit scoping rule: remote and Docker sessions
never get a cwd (their local pane runs the ssh or docker exec client), nor do
direct-PTY or rediscovered sessions. The value is runtime-only.
In the browser, relativeResolutionBase() picks the live cwd when known and
workingDir otherwise. It only changes the base a relative path joins: the
result is routed as before, so a target outside the workspace still goes
through the attachment routes, and file-content/file-raw are unchanged.
#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>
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>
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>
#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>
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>
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>
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>
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.
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.
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.
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>
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.
The merged PRs (mostly #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>
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.
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>
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>
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>
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>
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>
Conflicts resolved by keeping both sides:
- CLAUDE.md (Agent CLI): #694's opening (an HTTP client, the experimental
agent-session-routes behind agentCoordinationEnabled) with #708's clause
on the directory spawn and its two refusals.
- docs/api-reference.md (codeman agent): #708's spawn paragraph and --json
shape, with #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): #708's quick-start / directory spawn
routes and #694's experimental routes in one endpoint list; the two
node:fs/promises imports (#708's realpath+stat, #694's open+stat) merged
into one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflict in CLAUDE.md (Adding Features, App setting) resolved by keeping
both sides: #714's sentence that a setting the server acts on must be
synced, after the `.strict()` clause, and #719's mutateSettingsFile() rule
at the end of the bullet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflict in src/web/routes/system-routes.ts (PUT /api/settings) resolved by
keeping both sides: #718's line that never stores an unauthorized
tunnelEnabled:true runs first, then #719's serialized mutateSettingsFile()
write replaces the inline read and plain writeFile.
test/routes/system-routes-tunnel-guard.test.ts: #718's new tests asserted
plain writeFile calls on settings.json. With #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>
- 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>
- Session update no longer resets "How often" while typing: updateGitFetchStatus
(session-ui.js) leaves a control alone while it is document.activeElement, and
autoSaveGitFetch writes the clamped interval back itself (Enter saves with the
field still focused).
- Partial override shows the app values: enabled = session ?? app switch,
interval = session ?? app interval ?? 300 (session-ui.js updateGitFetchStatus).
- runFetch (git-fetch.ts) arms the spawn with what the core.sshCommand read left
of timeoutMs, and answers ETIMEDOUT without spawning when nothing is left, so a
git that never answers costs one timeout, not two.
- fetchEnvFor keeps a GIT_SSH from the server's environment (repoSshCommand ||
base.GIT_SSH), fileoverview and CLAUDE.md line updated to say so.
- i18n.js: the zh-CN "Git fetch on an interval" description scopes the only
writing git command to the Git window.
- styles.css: the disabled/busy rules are scoped to .git-status-actions
.btn-icon-sm (moved beside .git-status-actions), and the busy pulse stops
under prefers-reduced-motion.
- Comments that called the override per case now say per session: session.ts
(field, restore and getter docs, setGitFetchSettings), git-fetch-scheduler.ts:2,
server-timing.ts:78, server.ts (cleanup and restore), app.js _onSessionUpdated,
session-ui.js (openSessionOptions and updateGitFetchStatus).
- Tests: test/git-fetch-session-options-ui.test.ts (new, JSDOM: typing survives
an SSE update, the Enter write-back, both partial cases); test/git-fetch.test.ts
(GIT_SSH kept; one bound for the read and the fetch, stub git on PATH).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
- test/cli-agent-project-spawn.test.ts:345: the doc-sync test no longer reads .changeset/project-worker-directories.md (changeset version deletes it at release); README, API reference and wiki stay.
- CLAUDE.md:132 + src/cli-agent.ts @fileoverview (invariant 5): the directory spawn (POST /api/v1/sessions plus /interactive or /shell, no Agent-Origin header, so no marker) and its refusals (empty --working-dir, bare spawn from a HOME cwd); the invariants now name test/cli-agent-project-spawn.test.ts too.
- test/cli-agent-project-spawn.test.ts:322: "allows a cwd below HOME" mocks the cwd to <temp HOME>/src/app, so it covers the case the guard must leave alone.
- docs/wiki/Driving-Codeman-From-An-Agent.md:59: split the sentence so exit 4 covers only the empty --working-dir; spawn "" reaches quick-start and exits 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- constants.js:2296,2301 spacedFilePathOnLine: refuse a first word ending in `$`
and an earlier word with no letter or digit (other than a bare `-`), so a
shell prompt showing an absolute working directory (`/mnt/c/proj ❯ code
notes.md`, `/home/me/proj$ cat notes.md`) keeps master's links; the six rows
from the review are pinned, plus OneDrive, `Program Files (x86)` and
`Docs - Copy` still linking whole.
- constants.js:2312: the leading strip is now `/^(?:~?\/|(?:\.\.?\/)+)/`, so
`Saved to: ../My Docs/plan.md` links whole; an interior `..` still links
nothing. Tests for the detector and the terminal provider.
- app.js:3032 _linkifyFilePaths: carry any leading bold label and let
spacedFilePathOnLine() judge it, and give a text node that follows another
inline element a placeholder word, so `**Note:** /home/me/proj/out
report.pdf` and ``Use `--config` /home/me/proj/cfg data.json`` split as in
the terminal; a spaced path inside inline code still links whole. The
now-unused label-pattern guard is gone.
- JSDoc (findFilePathLinks, SPACED_FILE_PATH_LABEL_PATTERN,
spacedFilePathOnLine) and the spaced-path paragraph in
docs/architecture-invariants.md updated to match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- session-ui.js _reloadCasesAfterRun: reload onto the toolbar's own case after every launch, not only one that names its case; comment updated
- test/tab-new-session.test.ts: a worktree tab's "+" Claude launch, the toolbar Run and a group's "New session" each keep the toolbar's case after a "+" moved lastUsedCase (fail without the fix); the reload setup is shared with the existing lastUsedCase test
- docs/architecture-invariants.md:349: the _reloadCasesAfterRun sentence covers every launch
- app.js _openTabLayoutMenu focusout: focus landing on the trigger is not a departure, so pressing the "+" again closes its picker in a real browser
- tab-rail-resize.js openTabRailActionMenu: close the group menu too, so the rail's ... pressed again does not leave the picker beside the row menu
- tab-rail-resize.js:329-331: the "New session here…" comment no longer says Enter on the "+" does nothing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- test/command-palette-agent-arrows.browser.test.ts: drop the page.screenshot()
that wrote /tmp/codeman-21-palette.png on every browser-suite run (nothing read it)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- src/web/settings-writer.ts:110: export assertSettingsWritable(), the writer's own read-for-write rule run on its own
- src/web/routes/case-routes.ts:1860: an admin's rename checks settings.json before it ends any session or moves the folder, and answers 409 CONFLICT with the writer's message (placed before the session-starting check, so nothing is awaited between it and the lock)
- test/routes/case-rename-routes.test.ts:314: the torn-file pin now expects 409 CONFLICT (no stoppedSessions, like the other pre-checks)
- test/routes/case-rename-routes.test.ts:331: new test, a live session with stopSessions and a torn file: 409 CONFLICT, cleanupSession not called
- docs/api-reference.md: the Settings paragraph says an admin's rename answers this refusal as 409 CONFLICT before ending sessions
- src/hooks-config.ts:1161, src/prompt-cache-keepwarm.ts:341: comments describe the atomic writer, not an in-place rewrite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- constants.js:2618 terminalUrlContinuationExtent: trailingColumns now needs a
one-row first line (first.startRow === first.endRow), so a URL row that
soft-wraps with a column on its wrapped part no longer glues the next row
onto that column (`/tmp/report.txtccc/x.html` at 41 columns); master's links
come back. Negative tests for the extent and the link provider.
- constants.js:2847 terminalCellWrappedPaths: comment reworded to match the
JSDoc (the exception is a left-column PATH), plus the wrapped-prose case.
- docs/architecture-invariants.md, CLAUDE.md: one clause that a left column of
wrapped prose (a Description | Path table) also fills the continuation row,
so the path beside it keeps its per-row fragment links, the safe direction.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>