Compare commits

...
Author SHA1 Message Date
github-actions[bot]Claude Opus 5.5github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
120d780267 chore: version packages (#474)
* chore: version packages

* docs: sync CLAUDE.md version to 1.32.1

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Codeman maintainer <noreply@anthropic.com>
2026-09-23 12:34:33 +02:00
Codeman maintainer 0af925fe82 Merge remote-tracking branch 'origin/master' into land/1.32.1
# Conflicts:
#	CLAUDE.md
#	docs/architecture-invariants.md
2026-09-23 12:25:58 +02:00
Codeman maintainer b404dacfde chore: changeset for the merge-time fixes and thanks
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:41:11 +02:00
Codeman maintainer cbd1fa639d fix(tmux): merge-time fixes for the exited-agent report (#466)
- docs/wiki/The-Dashboard.md: the tab-appearance table gains the exited
  state (muted dot plus an `exited (137)` badge) and explains the bare
  `exited` variant.
- The detailed sidebar and rail no longer pair the muted dot with an "idle"
  pill: an exited session's pill reads "exited" (neutral styling) and its
  since stamp measures from the observed exit. This is a label override on
  the row model, not a new state, so SESSION_ACTIVITY_RANK and the home
  screen order are untouched, and a pending alert still keeps its own pill.
  The row signature includes the flag so the incremental path repaints it.
- The exited badge is aria-hidden like its sibling badges, and the exit is
  appended to the tab's aria-label in both render paths through one helper.
- test/tmux-manager.test.ts re-adds the junk-trailing-field parser case
  against parsePaneRows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:27 +02:00
Codeman maintainer 43d4be8eeb fix(session): merge-time fixes for the dead-pane resume pin (#467)
- test/setup.ts strips CLAUDE_CONFIG_DIR (pinned in test-env-isolation), so
  transcript-fixture tests such as session-custom-model-restart no longer go
  red on a machine that exports it for a separate Claude account (#255).
- The vanished-tmux-session branch of _setupOrAttachMuxSession() relaunches
  the CLI through createSession() just like a failed respawn, so it now takes
  the same resume pin. A genuinely new session is unaffected.
- After a dead-pane respawn of a fallback-chain CLI, _claudeSessionId names
  the conversation the walk actually pinned instead of the chain tail, which
  the walk may have passed over for lack of a transcript.
- _claudeConfigDir() trims the override like claudeProjectsDir() does.
- The remote-reattach test is labelled as documentation, since the pin
  builder's own remote guard would make it pass either way.
- CLAUDE.md: the create-path pin persists through toState() as
  resumeSessionId, and the end of the walk adds no pin rather than clearing
  the launch seed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:27 +02:00
Codeman maintainer f4d1ee8027 fix(terminal): merge-time fixes for dropped-output recovery (#470)
- The TERMINAL DROP crash-trail line moves behind the scheduler's debounce
  guard, so it is written once per window rather than once per dropped
  frame. At the server's 8ms batching, one second of drops evicted the whole
  50-entry trail, including the recovery lines that explain it.
- A refresh that failed at the capture fetch deadline now returns
  'deadline', and the scheduler does not retry it: that is a stalled link,
  not contention, and each retry was another ?full=1 capture waiting out a
  deadline of up to two minutes. The early-return retries are unchanged.
  CLAUDE.md and the code comments no longer claim every skip reason is
  transient contention.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:03 +02:00
Codeman maintainer 7a30a31430 fix(terminal): merge-time fixes for the silent-failure paths (#431)
- While another device holds the pane width (_paneWidthRefused), a resize
  now asks for the container's width without applying it locally
  (_geometryForResizeRequest: rows follow the container, columns stay at
  the PTY's). Fitting first re-wrapped the whole buffer to the container
  and back on every 30s mobile retry, and throttledResize ran the
  scrollback clear for a resize that brings no redraw. selectSession
  clears the flag, since it belongs to the previous pane. New unit tests
  run the real mixin against a fake terminal and fail without the fix.
- Session seeds _ptyCols/_ptyRows at spawn (_notePtySpawnGeometry), so a
  reattached pane reports its tmux window's real size through ptyGeometry.
- Session.resize's declined-branch comment names ptyGeometry, not the
  deleted ptyCols/ptyRows getters.
- Delete the dead terminalGeometryAgrees() and its window export.
- test/xterm-private-api.test.ts header: it pins the exact locked version,
  so any bump fails, not only a major.
- The main-terminal fit sweep also matches fitAddon?.fit?.(), and
  CLAUDE.md names the modules it actually covers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:03 +02:00
Codeman maintainer fdfcc15c10 docs(docker): merge-time note for the gh/az sign-in in multi-user mode (#472)
Clone Repo clears the credential helpers for a non-admin, but a non-admin's
Docker case with credential seeding on still receives a copy of the server
account's gh/az sign-in when the agent-image switches are on, the same as
the Claude and Codex credentials. Say so in the multi-user notes so the docs
do not read as a stronger guarantee than they are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:03 +02:00
Codeman maintainer 697b05b118 fix(docker): merge-time fixes for Update-Codeman.sh (#465)
- Remove exactly the codeman-node-modules/codeman-dist volumes by Compose
  label after a plain `down`, instead of `down --volumes` (which also takes
  any volume an override file declares while the message named two).
  `down --volumes` remains only as a warned fallback when the project name
  cannot be resolved.
- Report a failing first `docker compose config --format json` call with a
  clear error instead of exiting silently under `set -e`.
- Filter empty label lines in the collision guard so an unlabelled container
  cannot hide a real collision; name the moved-checkout exit in its error.
- Comments no longer cite a guard or incident in Start-Codeman.sh that does
  not exist; the README states the real gap (a Node base-image bump leaves
  codeman-node-modules stale because the lockfile did not move).
- docs: Update-Codeman.sh in the docker-self-update.md short-version table
  and a mention in docker-compose.md; "Major updates" moved under "Updating"
  in docker/README.md.
- test: smoke test covers the new sequence, the config failure and the
  empty-line case; quiet stdio; @fileoverview names the fourth concern.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:03 +02:00
Codeman maintainer 0462a5d5a0 fix(approvals): merge-time fixes for the watching badge (#473)
- session.ts: a pane capture that fails now CLEARS the watching label
  (and emits watchingChanged so pages drop the badge) instead of keeping
  the last one, so a failed capture degrades toward an alert rather than
  pre-acknowledging the next real idle prompt. Test updated; invariant
  noted in architecture-invariants.
- approvals-ui.js: the header bell counts only unacknowledged items
  (pendingApprovalsCount), matching codeman tui's pendingApprovalCount();
  pinned in watching-no-alert.test.ts.
- mobile-overview.js: move the orphaned "Pill copy per state" JSDoc back
  onto MOBILE_OVERVIEW_PILL_LABEL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:03 +02:00
Codeman maintainer da6fa663e7 fix(terminal): merge-time fixes for the copy gutter strip (#469)
- stock.ts: claude is no longer the only entry declaring transcriptGutter;
  codex declares it too.
- architecture-invariants: the strip applies when the session's CLI declares
  a margin (not detection), and a note that it keys on the session's launch
  mode, not on what is running in the pane (a claude pane dropped to a shell
  still loses up to two columns; copyStripMargin is the escape hatch).
- render-index-html test: the gutter map is injected for a solo
  /session/:id render as well.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:03 +02:00
Codeman maintainer 10f87428c3 fix(cli-registry): merge-time fixes for the run-button accents (#463)
- mobile.css: gemini and antigravity run/gear rules get `!important` like
  pi/omp/grok/deepseek, so the gear half no longer keeps the skin accent
  while the body takes the mode colour (two-tone button on the default skin).
- test/skin-themes.test.ts: static guard that every run mode with a base
  `.btn-toolbar.btn-run.mode-<id>` rule also has a resting rule inside the
  `html:not([data-skin="og"])` block; ids are derived from the stylesheet.
- stock.ts: grok's accent comment names zinc-300 (border/badge colour);
  gemini's accent is #8ab4f8 to match its tab badge and run-mode dot, noted
  as the one exception to the border-colour method.
- types.ts: "(below)" -> "(above)".
- docs/cli-registry.md, CLAUDE.md: `accent` is now measured, not transcribed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:03 +02:00
Codeman maintainer 13e652e43f chore: changesets for the 1.32.1 batch
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:40:03 +02:00
Codeman maintainer 2afb1c2c2e docs: trim CLAUDE.md from 265 KB to 142 KB, detail moved to architecture-invariants
CLAUDE.md loads into every session, and its Architecture section had grown
feature write-ups (history, measurements, rationale) that belong in
docs/architecture-invariants.md per the file's own header. Each long block
now keeps what the feature is, where it lives, its setting/default and the
rules that prevent real bugs, and links to its invariants section. Everything
removed was moved there: 29 new sections, extra facts appended to the
existing ones.

Also: hard-coded counts (SSE events, route handlers, module/file counts,
device profiles) replaced by pointers to the source of truth, and the
Debugging commands fixed to use the codeman tmux socket and HTTPS for prod.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 11:32:34 +02:00
Codeman maintainer 2fb744f865 Merge pull request #470 from rounakdatta/fix/dropped-output-recovery
fix(terminal): recover a dropped output frame, do not merely schedule it
2026-09-23 11:32:14 +02:00
Codeman maintainer de4b1db490 Merge pull request #431 from rounakdatta/feat/mobile-terminal-resilience
fix(terminal): four silent-failure paths — renderer freeze, replay race, reconnect gap, unbounded fetches
2026-09-23 11:32:14 +02:00
Codeman maintainer 8536aaef7b Merge pull request #473 from irisitymichaelgrundberg/feat/session-watching-badge
feat(approvals): let a session watching its own background work keep quiet (#468)

# Conflicts:
#	src/config/cli-registry/stock.ts
2026-09-23 11:32:11 +02:00
Codeman maintainer 94b093b617 Merge pull request #469 from irisitymichaelgrundberg/feat/copy-dedent-pane-margin
feat(terminal): take the transcript gutter off a copy, at the width the CLI declares
2026-09-23 11:32:02 +02:00
Codeman maintainer 6a01412af9 Merge pull request #466 from irisitymichaelgrundberg/feat/pane-exit-reporting
feat(tmux): report that a pane's agent has exited (#446, part 1)
2026-09-23 11:32:02 +02:00
Codeman maintainer bc04b6457e Merge pull request #467 from irisitymichaelgrundberg/fix/respawn-session-id-collision
fix(session): resume the conversation when respawning a dead pane
2026-09-23 11:32:01 +02:00
Codeman maintainer 5b5e932ec4 Merge pull request #465 from opticon454/chore/docker-major-update-script
chore(docker): add Update-Codeman.sh for scripted major-update rebuilds

# Conflicts:
#	docker/README.md
2026-09-23 11:32:00 +02:00
Codeman maintainer f1dfbcdd65 Merge pull request #472 from opticon454/feature/git-host-auth-clis
feat(docker): opt-in gh + az CLIs with git credential helpers so Clone Repo and Docker cases can reach private repos
2026-09-23 11:31:53 +02:00
Codeman maintainer 233af33dac Merge pull request #463 from opticon454/fix/cli-accent-colours
fix(cli-registry): correct accent colours, and a real gemini/antigravity/omp rendering bug
2026-09-23 11:31:52 +02:00
Codeman maintainer 993e5e021c Merge pull request #471 from DodgyBadger/fix/mobile-blank-long-press
fix(mobile): swallow blank-space terminal long presses
2026-09-23 11:31:52 +02:00
DevvynandClaude Opus 5.5 02e40f506b fix(docker): gate gh/az seeding on its switch; no shared git sign-in for non-admin clones
Addresses the review on #472.

- CRED_STORES: `.config/gh` and `.azure` now carry `enabledByEnv`
  (CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ), and resolveDockerCredentialArtifacts
  skips a store unless that variable is exactly `1`, read at container
  create. A host that merely has ~/.config/gh/hosts.yml or a plaintext MSAL
  cache no longer copies them into every case container. Tests: the default
  environment seeds neither even with the files present, and each store
  follows only its own switch.
- Multi-user mode: a non-admin's Clone Repo clone and preflight run with
  `git -c credential.helper=` (GIT_NO_CREDENTIAL_HELPERS, placed before the
  subcommand), so the server account's helpers are never lent to them.
  Verified against a real private repo that it also clears the URL-scoped
  credential.<url>.helper entries, and that public clones still work.
  Tests: the argv in test/git-clone.test.ts, and the route decision
  (non-admin cleared; admin and single-user kept) in
  test/routes/case-clone-credential-helpers.test.ts.
- Docs: recreate the case container to pick up seeds (docker/README.md,
  Docker-Cases wiki, docker-cases.md); the multi-user behaviour in
  docker/README.md and security-architecture.md; "functionally unchanged"
  instead of "unchanged" for an image built with both switches off
  (server.Dockerfile comment, README, changeset).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167CiuzLrmjYWxwKp3rMWjw
2026-09-23 14:44:26 +08:00
Michael GrundbergandClaude Opus 5 e558264977 chore: leave the changeset to the maintainer
CONTRIBUTING says releases are handled by the maintainer via changesets after
merge, and every `.changeset/*.md` on master was written by him or by the
release bot — including the ones covering other people's pull requests. The
summary this file carried moves to the pull-request description, where it is
the maintainer's to reuse or rewrite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 08:28:25 +02:00
Michael GrundbergandClaude Opus 5 9a48c43aa1 docs(watching): a restart is not a gap, and here is the measurement
Claimed after a manual test that a session comes back from a server restart
without its badge until it next produces output. Measured instead of assumed,
and it is wrong: a codex session with a background terminal still running had
its label back within about 20 seconds of the restart, with no input from
anyone. Reconciliation re-attaches the pane, the attach repaint carries the
composer glyph, the idle confirmation arms on it, and the probe re-reads the
label — the ordinary path, doing the ordinary thing.

What produced the false claim was a session whose monitor had simply expired
while it sat there. Its footer carries no chip, so `watching: null` was the
right answer and there was nothing missing to restore.

Recorded at the field and in the invariants, because the shape of this invites
exactly one wrong fix: a polling timer to keep a value fresh that the pane
already refreshes by itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 08:00:16 +02:00
DevvynandClaude Opus 5.5 5cf5a45438 feat(docker): opt-in gh + az CLIs with git credential helpers for private repos
Add Case -> Clone Repo could only reach public repositories in the Docker
deployment. This lets a deployment opt in to the GitHub CLI and the Azure
CLI (+ azure-devops extension) as git credential helpers. Codeman itself
still collects no credentials.

- server.Dockerfile / agent.Dockerfile: CODEMAN_INSTALL_GH /
  CODEMAN_INSTALL_AZ build args (0 or 1, default 0; anything else stops the
  build). Off leaves no apt repository, package, extension, helper script
  or credential entry, so a default build is unchanged. On installs from
  the vendors' apt repositories and configures system gitconfig helpers:
  github.com / gist.github.com -> `gh auth git-credential`, dev.azure.com /
  *.visualstudio.com -> new docker/git-credential-azure-cli (an Entra ID
  token from `az account get-access-token`, or AZURE_DEVOPS_EXT_PAT).
  A helper whose CLI is not signed in prints nothing, so a private clone
  still fails fast.
- The extension lives in AZURE_EXTENSION_DIR outside HOME
  (/opt/codeman-az-extensions, runtime-owned; /opt/az-extensions, gid-0
  group-writable in the agent image).
- Hosts turn them on in docker-compose.override.yml: `build: args:` for the
  server image, `environment:` CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ for the
  agent image. build-agent-image.mjs and the in-app auto-build share one
  env -> ARG table (pinned by the parity test) and pass nothing when unset.
  docker-compose.yaml is untouched; .env.example only gains a comment, so
  the self-updater's environment gate sees no new keys.
- Docker cases seed the gh sign-in (~/.config/gh/hosts.yml, config.yml) and
  the az sign-in files from ~/.azure per file, read-only, like pi/grok.
- The Clone Repo AUTH_REQUIRED message says how to sign the server's git
  in instead of claiming private repositories cannot be cloned.
- Docs: docker/README.md "Private repositories", docker-compose.md,
  docker-cases.md, the Quick-Start / Core-Concepts / Docker-Cases wiki
  pages, security-architecture.md, architecture-invariants.md, changeset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167CiuzLrmjYWxwKp3rMWjw
2026-09-23 08:57:09 +08:00
DodgyBadger 110c4696ad fix(mobile): swallow blank-space long presses 2026-09-22 17:09:01 +00:00
Michael GrundbergandClaude Opus 5 ac6236b268 fix(terminal): clean a copy once, and reach every pane that copies
Review fixes for #469.

The Ctrl+C branch cleaned the selection to decide whether to copy and then
passed that cleaned string to copyTerminalSelection(), which cleans again. The
trailing trim is a fixed point, so that was safe until this PR; the margin
strip is not, because it takes the lesser of the declared width and the run
every line shares, so a second pass takes up to `margin` columns more. The
branch now gates on the cleaned string and hands the raw one on. Verified in
chromium with a real drag, a real Ctrl+C and a real clipboard read on a live
claude pane: an on-screen `      fix(terminal): trim it` reaches the clipboard
as `    fix(terminal): trim it`, and reverting the branch reproduces the
reported `  fix(terminal): trim it`.

Pane B of a split resolves its own width. `_cliGutterColumns()` and
`_normalisedSelectionRange()` take the session and the terminal to read,
defaulting to the primary pane's, so Pane B looks its own run mode up instead
of keeping a margin Pane A drops on the same keystroke. Verified live with two
claude panes open side by side.

A detached session window (`/session/:id`) receives the gutter map. The
injection sat inside the block that skips the run menu's payloads for a solo
window, so the toggle worked in the main window and did nothing in the popup on
the same device. It needs no availability probe, so it moved below that block
and the solo window still carries none of the payloads it skipped before.

The settings description said the width is measured and named Codex as exempt.
Nothing is measured, and Codex is one of the two panes that are stripped.
docs/wiki/Settings-Reference.md gains the row every Terminal and Input toggle
carries. CLAUDE.md no longer says the clean touches trailing runs "and nothing
else" one sentence before the leading-margin rule, and both it and
docs/architecture-invariants.md record that the strip is not idempotent.

Two round-trip tests run on a mode that declares a gutter, which the existing
copyTerminalSelection cases could not, since they all use the harness default
mode that declares none. The Ctrl+C branch itself is pinned at the source,
because it lives inside initTerminal's attachCustomKeyEventHandler closure over
a real xterm the vm harness cannot build. Both pins fail on the reintroduced
bug. Gate: 7865 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 19:02:55 +02:00
Rounak DattaandClaude Opus 5 00f022ccf8 fix(terminal): recover a dropped output frame, do not merely schedule it
`_onSessionTerminal` drops an incoming frame once the app-owned render queues
already hold 128KB. That is the right call — the alternative is an unbounded
backlog — but a hole in a TUI byte stream is a desynced cursor, and a desynced
cursor is muffled text (#464). The drop was only half of it.

The recovery was a fire-and-forget timer: it nulled its own handle and then
called `_onSessionNeedsRefresh()`, which opens with four early returns. Two of
them — a buffer load already in flight, a refresh already owning this session —
are MOST likely to be true during exactly the output burst that caused the
drop. So the recovery was skipped precisely when it was needed, with nothing
left to retry it, and the dropped bytes were never replayed.

`_onSessionNeedsRefresh` reports whether it actually repainted now, and
`_scheduleDroppedOutputRecovery` re-arms while it has not. Bounded by
`DROP_RECOVERY_MAX_ATTEMPTS`, because every reason the refresh can be skipped is
transient contention that clears in seconds and a permanently failing refresh
must not become a loop against the API; giving up at the cap leaves exactly what
the old code left, so the floor is no worse than before. The same 2s debounce
still collapses a burst of drops into one attempt.

This is the principle Ark0N established reviewing #431 for the WebSocket
output-gap marker — only a repaint that actually happened settles the recovery —
applied to the one recovery path that still trusted a timer having fired.

The retry decision is a pure function in constants.js so the gate can reach it,
and the scheduler itself is driven from app.js under a fake clock. The retry
case and the no-retry case only pin the fix AS A PAIR: either alone passes
against something wrong, one against the old fire-and-forget timer and the other
against retrying forever. Checked by reverting app.js to the old shape, where
three of the twelve fail.

Two harness details that would otherwise have made the tests lie. The vm context
baked in the real `setTimeout`, so `vi.useFakeTimers()` could not reach the
scheduler and every case reported zero calls; it delegates lazily now. And
app.js reached `CodemanDroppedOutput` as a bare global, which resolves in a
browser but not in the vm — worth fixing beyond the test, because that call sits
inside a timer where a ReferenceError is swallowed and would take the recovery
with it. It reads through `window.` like terminal-ui.js does with its own
constants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 21:34:03 +05:30
Michael GrundbergandClaude Opus 5 b13672596f fix(watching): tell the page when the badge goes away
Reported from a manual test: a Codex session went on showing the watching badge
after its background terminal had finished. The server was right and the page
was stale — `Session.watching` changes while the session's status does not, and
nothing broadcast it.

The label is usually SET on the idle transition, which broadcasts anyway, so the
badge always appeared correctly. It CLEARS when the work ends, and a CLI can end
background work without taking a turn: codex repaints its background-terminal
row away and stays idle, so `_confirmIdle()` concludes without emitting `idle`
(that emit is guarded by `wasWorking || isInitialReady`) and no other event
fires. Every open page kept drawing a badge the server had already dropped.

`_readWatching()` now emits `watchingChanged` when, and only when, the label
really changes, and the wiring pushes the session state on it. No new SSE event:
the badge reads off the session payload every surface already has.

A/B measured on an isolated beta with the page loaded and then left untouched.
Without this commit the server dropped the label at t+50s and the page still
showed the badge at t+100s; with it, page and server cleared in the same
ten-second window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 17:59:45 +02:00
Michael GrundbergandClaude Opus 5 8d45b92eba docs(codex): record that a sub-agent leaves no row to read
Measured on the same beta, codex-cli 0.154.0: a sub-agent started without
waiting outlives the turn exactly as a background terminal does — the sandboxed
process was still running — and codex shows nothing for it. The last rows of the
pane are the composer and the status line, and `Sub-agents running` belongs to
the on-demand `/subagents` panel rather than to the row above the composer.

So there is no second codex label to add. A codex session waiting on a sub-agent
reads as plainly idle, which misfiles nothing (codex raises no idle prompts) and
simply leaves that one kind of quiet unexplained until codex pins a row for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 17:35:56 +02:00
Michael GrundbergandClaude Opus 5 05c788ce9d fix(watching): close the review findings on the label and its window
A dual review (Codex CLI and Claude's code-reviewer, same diff, same brief)
found the trust boundary weaker than the comments around it claimed. Eleven
findings, all applied.

The two blockers were both about who can write the row the label is read from.
Claude's window covered two rows, and the second one is the status line, whose
command a session running with permissions bypassed can write into its own
`.claude/settings.json` — so an agent could print `· 1 monitor ·` onto a row of
its own and silence its own idle alert. The default window is one row now, which
is the footer and nothing else, and the constant says why. Separately, the label
reached `data-tab-meta-sig` unescaped while the row is installed with innerHTML,
which is an injection sink for any config-supplied pattern whose capture group is
permissive; it goes through escapeHtml() like every other untrusted string in
that file.

The Codex entry could not be fixed the same way, and now says so. Its row is
third from the bottom only while a terminal runs; with none running that slot
holds the last row of the transcript, so matching the complete row (with the
`/stop to close` tail, window narrowed to three) raises the bar without closing
it. What contains it is `hooks: 'none'`: no hook event from a codex session
reaches notePrompt(), so a forged label costs a wrong badge and cannot quiet an
alert. The registry comment, `docs/cli-registry.md` and the test all state that
rather than claiming a guarantee the code does not have.

Also from the review: the TUI header badge no longer counts an acknowledged
item, which was the same gate the classifier fix already went through and was
wrong for human acknowledgement too; the TUI approval card reads the quiet
reason and drops to a new `info` tone instead of asking for a reply; the badge
carries an aria-label, because the phone it was built for has no hover target;
the schema refuses `watchingLines` without a `watchingLine`; and the pattern and
its window are resolved together rather than one memoized and one not.

Documentation moved with it. The mechanism now lives in
`docs/architecture-invariants.md` with CLAUDE.md keeping the rule and a pointer,
`docs/wiki/Notifications-And-Approvals.md` tells users why a session stopped
buzzing, and both that page and the changeset name the limitation neither did
before: a question asked in plain prose is not a dialog, so it is silenced along
with the false alarms while background work runs.

Verified live again after the narrowing, on an isolated beta: a Claude session
reported `1 monitor` and took its idle prompt acknowledged, and a Codex session
reported `1 background terminal` against the full-row anchor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 17:11:33 +02:00
Michael GrundbergandClaude Opus 5 9c286eeddf fix(session): persist an exit retraction, and let tests reach the watcher
Ten findings from a two-model review of this branch. Both reviewers cleared the
detection logic itself; everything here is a gap around it.

A route that starts a command in a pane now PERSISTS as well as broadcasts.
`/interactive` and `/shell` did neither before, and the pane-exit watcher cannot
cover for them: its next tick finds `paneExit` already cleared in memory,
reports no change and writes nothing, so `state.json` kept saying the agent had
exited for as long as the session stayed quiet. Nothing reads that record for a
decision yet, which is exactly why it had to be fixed now — part 2 is designed
to read it. The `clearPaneExitForNewPane()` docstring claimed its callers
already persisted; that claim was false for these two, and now says what the
caller owes instead.

The watcher's four guards were unreachable by any test. `refreshPaneExits()`
opened with `if (IS_TEST_MODE) return;`, so the read gate, the in-flight
suppression, the generation counter and the empty-read rule could each be
deleted with the whole suite green. The tmux call moves into `readPaneRows()`,
which a test subclass overrides — the shape `runRemoteReconnectTick` already
uses in this file for the same reason — and the test-mode gate moves with it, so
what a test cannot do is spawn a process rather than exercise the bookkeeping.
Each of the four guards now has a test that fails when it is deleted.

The muted status dot turned out to be a specificity fight on three surfaces, not
two. `.tab-status.error` was not excluded, so a session whose agent exited and
whose PTY-exit breaker then tripped lost its red dot to the mute — the state the
browser answers with a "restart it?" confirm, and a needs-you colour by the same
argument that protects the two alert classes. And mobile.css gives a `busy` dot
a 9px size and a green glow with `!important`, while `status` stays `busy` for a
pane whose agent died mid-turn, so a phone rendered a grey dot still wearing the
green halo beside a badge reading "exited". Both measured against the real
stylesheets, both now excluded, and the CSS test reads mobile.css too instead of
being structurally blind to half the problem.

Six comments said things that were not true. Two named the stats collector as
what replaces a restored reading, which is the opposite of the design. The
interval constant argued that 2000 ms keeps a read inside a tick, when the
5000 ms exec timeout means it cannot — which is why the in-flight guard exists.
`MuxSession.discovered` did not say the flag is permanent, though `saveSessions()`
serializes it. The empty-read docstring claimed a distinction that `|| true`
makes impossible. The invariants doc promised more than its drift test delivers.
And CLAUDE.md had no pointer at all, leaving its two hardest prohibitions
("never set `status: 'error'`", "never null the pid") only in the file it is
meant to route people to.

Refs Ark0N/Codeman#446.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 15:24:09 +02:00
Rounak DattaandClaude Opus 5 e1e7dc5bd8 fix(terminal): Ark0N's read of the #464 geometry work
Five items, two of which he could only see by running it, plus six smaller
ones. Taking the two blockers first, because both were wrong in ways the
existing tests could not catch.

**Adopting the PTY's rows put the CLI's input line off-screen.** A phone that
took a desktop's 43 rows into a viewport with room for 18 painted an
`.xterm-screen` far taller than its container; xterm's own viewport then had
nothing to scroll, so the bottom of the frame sat below the container with no
gesture able to reach it. Output visible, typing invisible, for as long as the
desktop kept the claim hot. `reconcilePtyGeometry` adopts COLUMNS ONLY now:
width is the axis Ink's wrap and `eraseLines` arithmetic depend on, and keeping
the local row count keeps the composer at the bottom of a viewport that
scrolls. Measured at his geometry — a 360x300 container against a 198x43 pane
now keeps 13 rows, takes 198 columns, paints 202px into a 210px container, and
the input line is inside the box.

**`capture-geometry-retry.browser.test.ts` failed, and CI could not see it**
because the file is in `BROWSER_TEST_GLOBS`. Its premise WAS the clamp —
`getTerminalDimensions()` floored while `fitAddon.fit()` did not — which this
work removes at the source, so it can never hold again at any viewport. The
case survives on its own terms: a pane already drawing at the requested size
must not be replayed. Its premise is now the #464 invariant itself, that the
floored report and the terminal agree, which is a stronger guard because the
clamp coming back fails it here rather than silently restoring the replay loop.
The helper docblock that repeated the old premise is corrected too.

**A session with no pane reported 120x40 and the client adopted it.**
`resize()` writes `_ptyCols`/`_ptyRows` only when `ptyProcess` is set and
nothing seeds them from the spawn geometry, so a dead-pane session still held
the constructor defaults — clicking that tab resized the browser terminal to
120x40 and, on anything narrower, claimed another device owned the pane when
none existed. `Session.ptyGeometry` returns null without a pane, the HTTP route
answers `{}` and the socket sends no frame at all. The raw `ptyCols`/`ptyRows`
getters are deleted rather than left available to be misused again.

**The 40-column floor clipped the pane with nothing able to reach it.** The
affordance keyed on a PTY mismatch, and the floor produces no mismatch — xterm
and the PTY agree throughout, the terminal is simply wider than the box. It
keys on what does not FIT now, MEASURED (`.xterm-screen` against the container,
on the next frame, because the screen takes its width with the render) rather
than derived from cell arithmetic. Measured at 360px: font 24 applies 40
columns and paints 560px, and all 200px of the overhang is reachable.
`.pty-oversized` is renamed `.term-overflows-x`, because after this the old
name describes only one of the two causes.

**"Scroll sideways" did not work on touch for the sessions it targets.**
`touch-action: pan-x` is cancelled before it starts by the `preventDefault()`
`touchstart` calls on every 'content' tap. The terminal's own touchmove handler
pans the container now, with the axis locked once per gesture so a diagonal
cannot pan and scroll at once, and the CSS grants no `touch-action` at all —
handing the browser a pan AS WELL would move the pane twice for one finger on
the taps where that preventDefault does not run. Measured under real touch
dispatch: a 140px swipe reaches `scrollLeft` 140 where it reached 0 before, the
buffer does not move with it, and a vertical swipe still scrolls the scrollback.

Three defects in the above, found while checking it rather than by being told:

- `canPanHorizontally` first tested `scrollWidth > clientWidth` alone, which is
  true of a container that is not a scroller — a sideways swipe would have
  locked the axis, done nothing, AND suppressed the vertical scroll it should
  have been. Gated on the class as well.
- The notice advised scrolling sideways whenever the PTY was wider, including
  when it still fitted and nothing scrolled. It is gated on measured overflow,
  and on a comparison against the width this container WOULD request rather
  than the one it currently holds — once adopted those are equal, so the second
  question answers itself false while the condition is still true.
- `_syncTerminalOverflowAffordance` could throw out of `document.getElementById`
  before reaching its try block. It runs off every geometry change, so a
  cosmetic affordance could have taken the resize down with it.

The smaller items:

- `docs/architecture-invariants.md` no longer explains the equality guard as a
  clamp signature; it records what the clamp used to do and why it cannot any
  more. Edited by hand — that file is outside the Prettier glob, and letting
  Prettier near it rewrote eleven unrelated emphasis markers.
- `throttledResize`'s HTTP fallback reads the reply. It is the path where a
  declined resize is least likely to be noticed, because no socket means no
  `{"t":"zc"}` frame either.
- The changeset covers the whole release: the geometry work, the queued replay
  clear, the renderer watchdog, the body-covering fetch deadline, the WebSocket
  output-gap reconcile, the build-generated service-worker precache and
  per-build cache key, and the crash-trail hygiene.
- `@xterm/headless` is declared in the root devDependencies instead of being
  reached through workspace hoisting.
- The output-gap marker is cleared after any response arrives, not only when
  the capture was non-empty: a server that answers with an empty capture HAS
  reconciled us, and leaving the marker set refetched on every reconnect.
- `e587d845`'s message claimed a test asserted the failed-load copy against the
  built asset. It did not — that assertion lived in a probe deleted with the
  other scratch scripts, so the claim was false when it was written. There is a
  real test now, and it reads the source rather than `dist/`, because `dist/` is
  not committed and a test that skips when it is absent would pass for the wrong
  reason in CI.

`Session.ptyGeometry` gets behavioural coverage against the real class in
`session-resize-arbitration.test.ts` rather than a source guard, including the
contrast — a pane that does exist still reports, and still follows a resize —
so "always null" would fail it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 18:53:46 +05:30
Michael GrundbergandClaude Opus 5 ce80b7a212 feat(terminal): take the transcript gutter off a copy, at the width the CLI declares
Copying a paragraph out of a Claude Code or Codex pane puts that pane's own
two-column transcript gutter on the clipboard, so every pasted line arrives
indented. #451 shipped the trailing half of the copy clean and left the leading
half out, because deriving the width from the selection fires on 73% of ordinary
indented text and cannot tell a margin from content.

The width is DECLARED rather than derived. `capabilities.transcriptGutter` on
the CLI registry is a bounded integer; claude and codex each declare 2, measured
on live panes, and no other stock entry declares any, so a CLI whose transcript
layout nobody has measured is never touched. The server publishes the map as
`window.__codemanTranscriptGutter`, built by filtering `enabledClis()` on the
capability rather than by listing ids, and `_activeCliGutterColumns()` looks the
active session's mode up in it. The copy path reads no terminal buffer at all.

The declared width is a CEILING, not the answer: `clean()` strips the lesser of
it and the run every selected line shares. A block can therefore only shift as a
unit, the structure inside a selection survives by construction, and a selection
reaching column 0 loses nothing. That is what keeps a `git log` body at its own
four-space indent inside an agent's two-column gutter.

Codex was measured separately, because it renders nothing like Claude: it draws
boxes narrower than the pane and pushes its transcript into ordinary scrollback.
On a live 0.154.0 answer its `•`/`›`/`⚠` markers sit in the gutter, prose
continuations sit at 2, and a nested YAML block the model wrote rendered at
2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120, 160, 198, 235 and 282 columns
its indents were 0, 2, 4, 6 and 8 at every one, never 1. Copying that YAML out
of a live Codex pane now yields 0/2/4/6: gutter gone, nesting intact.

Two derived versions were built and measured first, and both are recorded in the
code because both looked correct:

- Painted trailing padding — a full-screen TUI writes real spaces across the
  unused part of a row, a shell leaves them never-written for xterm to trim —
  has no false positives and never over-stripped. It is also a function of pane
  WIDTH: the padding exists only while a rendered line stops short of the CLI's
  own layout width, and Claude's prose wraps to fill it. Dragging the same two
  prose rows of one live transcript at five window sizes, the share of padded
  rows ran 44%, 6%, 6%, 7% and 87% at 123, 160, 198, 235 and 298 columns, so the
  strip silently did nothing at every ordinary size while a corpus captured
  entirely at 282 columns said it worked.
- Taking the narrowest indent on the rows around the selection fires at every
  width and over-strips about 1% of selections, because a file listing inside
  the transcript can be the narrowest thing on screen.

Measured over 1,392,281 selections — every 1, 2, 3, 5, 10 and 20-row window of
real Claude screens replayed from live PTY streams at 100, 120, 160, 198, 235
and 282 columns — the declared width over-strips none, breaks no relative indent
and alters no text, and serves 100% of the selections whose own indent covers
the gutter. Verified end to end in a browser with a real mouse drag and a real
Ctrl+C: Claude and Codex panes paste flush at 123, 198 and 298 columns, a shell
pane is untouched at every one.

The strip sits behind `copyStripMargin` (App Settings, Selection & clipboard),
per-device and default ON: a display key, absent from the .strict()
SettingsUpdateSchema, read as `!== false` because the desktop branch of
getDefaultSettings() returns {}. The toggle is checked before the map.

Two review findings from #451, handled:

- The mid-row flag governs ONE line now. `range.start.x > 0` excludes only the
  first selected line, the one whose margin the mousedown genuinely cut off, so
  the same three rows no longer produce three different clipboard results.
- The reversed-drag finding does not reproduce on the pinned xterm.
  `getSelectionPosition()` reads `_selectionService.selectionStart`, whose
  getter returns `SelectionModel.finalSelectionStart`, and that swaps the pair
  when `areSelectionValuesReversed()` says so. A real upward mouse drag through
  chromium against xterm 6.0 reports the same range as the downward drag.
  `_normalisedSelectionRange()` keeps the ordering as a guard, because the model
  one layer down exposes the unnormalised fields under the same two names.

Tests: test/terminal-copy-clean.test.ts (64, up from 31), plus the injected
script stripped in test/server-index-title.test.ts. Every guard is pinned:
removing any one of seven reds at least one test, including declaring the wrong
gutter width. Full suite green, 7,861 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 13:33:50 +02:00
Rounak DattaandClaude Opus 5 e587d84590 fix(terminal): make the failed-load notice fit the narrowest terminal
A third pass in a real browser, at the widths this app actually renders at.

The notice a failed history load writes into the blanked pane was one
70-character sentence. At 430px that exactly filled the line; at 320px it
wrapped and left a lone '.' on a line of its own. The floor this app will
render at is 40 columns — reachable today by raising the font on a phone — so
the notice is three lines now, none over 25 columns, one fact each: what
failed, that the session is still alive, and what to do.

It says RELOAD rather than "reopen the tab" because `selectSession`
early-returns when the session is already active, so clicking the tab you are
already on retries nothing. The earlier wording named no next step at all,
which left a mostly-empty terminal and no way out of it.

CLAUDE.md no longer cites "758px reachable to the right" as evidence: that
figure is a property of the test content, not of the fix, and the file's value
is that a reader can trust a claim without re-deriving it. What is pinned
instead is the invariant that survives any content — the full pane width is
reachable, and removing the class returns scrollLeft to 0, so a resolved
mismatch cannot leave the pane parked off-screen.

Verified at 430, 360 and 320px against the shipped bundle, with the test
asserting the built asset carries the copy so an edit that never reached the
build fails rather than passing on the source's wording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 13:57:40 +05:30
Rounak DattaandClaude Opus 5 d9fa9ba1eb test(terminal): follow the existing suites to the one geometry owner
The gate caught fourteen failures the focused tests could not: every harness
that builds a partial app out of cherry-picked mixin methods, and every source
guard that named `fitAddon.fit()` by hand.

Most are wiring — `syncTerminalGeometry`, `_refitAfterCellSizeChange` and
`_resizeTerminalTo` added to the fakes so the real chain runs rather than a
stub of it. `file-browser-search` is the one that shows why it matters: without
the method on the fake, selectSession's unconditional call threw into its own
catch and every later assertion in the file measured a load that never
happened.

Two are not wiring.

`detached-session-pane-sizing` pinned the behaviour this change deliberately
reverses. It asserted the LOCAL fit still runs for a session owned by its own
window — "withhold the send, never the reflow" — so the assertion is restated
rather than patched, with the reason beside it and in the file's docblock: a
reflow the PTY is never told about leaves this xterm rendering a CLI's frames
against a shape that does not exist, and the popup that owns the pane is
drawing for its own width regardless. The old rule bought a garbled frame, not
a correct one.

`mobile-prompt-composer` sliced `_cleanupSessionData` as a fixed 1200-character
window, so the assertion depended on how much unrelated code sat above the line
it cared about. It reads the whole method now.

`terminal-scroll-intent` records `syncTerminalGeometry` rather than `fit`,
under its own name: recording a bare fit there would name the very thing the
subject was changed to stop doing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 13:22:28 +05:30
Rounak DattaandClaude Opus 5 abf1d1f1ca fix(terminal): the PTY and the browser terminal must never disagree about size
Issue #464, "text gets muffled sometimes, in both TUI default and fullscreen".
The screenshot is not a dropped frame or a frozen renderer — it is arithmetic.
Claude Code's TUI wraps its frame at the width the PTY reported and erases the
previous frame by walking the cursor up the rows it believes that frame took. A
browser terminal of a different width makes each logical line occupy more
physical rows than Ink counted, so `eraseLines(n)` clears too few and the new
frame paints over rows nothing erased: doubled lines, and short tool summaries
sitting inside longer prose rows with the prose's tail still visible.

Reproduced against this repo's own xterm before changing anything — a 120-column
PTY against a 62-column terminal renders every wrapped line twice. `test/
terminal-pty-geometry.test.ts` pins that, and pins the clean render at matching
widths beside it, so the assertion cannot be satisfied by code that fixes
nothing.

Four ways the two drifted apart, none of them observable from either end:

1. `fitAddon.fit()` resizes xterm to `proposeDimensions()` RAW while every
   server-facing path reported those floored at 40x10. Measured in Chrome at
   430px: font size 44 proposed 13 columns, the server was told 40, and xterm
   stayed at 13. Three call sites each did their own fit-then-floor, and two
   re-read the proposal after the fit — `_shrinkPaddingToFit()` runs exactly
   there, so the container had moved.
2. `throttledResize` (keyboard up) and `sendResize` (session detached into its
   own window) reflowed locally and withheld only the SIGWINCH. That is the one
   combination that cannot be right: a reflow nothing is rendering for buys
   nothing and costs correctness. Both now withhold everything, and the
   keyboard's settle timer still sends the one resize that stops the PTY going
   stale.
3. `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size — a geometry
   change — and told the server nothing at all, so raising the font on a phone
   left the CLI wrapping at the old column count.
4. `Session.resize` DECLINES a small-viewport request while a desktop connection
   holds an active sizing claim, and said nothing, because resize was write-only.

`syncTerminalGeometry()` is now the one function that may change the terminal's
size: it fits, floors and applies as a single step, so the numbers xterm holds
are the numbers the server is told. A test sweeps every module for a bare
`fit()` on the main terminal, and finds exactly one — the owner's own.

For (4) the client cannot win, so it is told the truth instead: both transports
answer a resize with `session.ptyCols`/`ptyRows` (`{"t":"zc"}` on the socket,
the body of the resize POST) and `_onPtyGeometryReport` adopts them. A terminal
that keeps a shape the PTY refused does not render "too narrow", it renders
garbled. Adopting can leave the pane wider than the screen and the container is
`overflow: hidden`, so `.pty-oversized` grants horizontal reach for exactly as
long as the mismatch lasts: correct-and-reachable beats correct-and-clipped
beats garbled. That rule sets both overflow axes and its own `touch-action`
because mobile.css loads later and sets `.terminal-container { overflow:
visible; touch-action: none }` — a bare `overflow-x` would leave overflow-y
computing to `auto` and hand the browser a vertical scroll container the
terminal's touch handler knows nothing about.

Verified in Chrome at 430px against a live server, with a desktop client holding
the claim: the phone adopts 198x43, gets `overflow-x: auto` / `overflow-y:
hidden` / `touch-action: pan-x`, 758px of reach to the right, and keeps its own
vertical scrolling. The pre-fix build was measured in the same harness for the
control.

Two things this deliberately does not do. It does not change who owns the pane
size — the desktop still wins, and `_startMobileResizeRetry` still takes it back
once that goes idle. And `throttledResize` still holds the PTY's shape for the
whole keyboard animation rather than sending a SIGWINCH per step; that decision
predates this and was not re-tested here.

Also in this commit, Ark0N's third-pass review items on #431:

- The response viewer's byte-buffer fallback and `_onSessionClearTerminal` both
  used the no-param `/terminal` form, capped only by `terminalBufferMaxBytes`
  (32MB) — the largest body the frontend asks for anywhere. One carried no
  deadline at all and the other got the 15s tail budget. Both now take the
  full-history budget.
- A `?full=1` capture that outruns its deadline falls back to the bounded tail.
  The pane is blanked before that fetch, so an abort used to leave a black
  rectangle, discard the queued live output and never reach `_connectWs`. A
  failed load now still opens the socket, says one dim line where the content
  would have been, and clears the tab's spinner — which nothing did, so a failed
  select left `aria-busy="true"` set forever.
- `_wsOutputGapSession` is cleared at the repaint that settles it, not in a
  `finally` that also ran on the catch. A reconcile that threw, or hit the new
  deadline — the flaky link the marker exists for — dropped the gap with nothing
  to retry it. `ws.onopen` no longer clears it up front either.
- The replay-clear invariant is pinned in the gate, which is the drift this PR
  exists to fix: `_resetTerminalForReplay` must be a queued write and nothing
  else, and no module may blank the terminal with a `clear()+reset()` pair.
- `DIAG_ENTRY_MAX_CHARS` replaces the hardcoded 300, bound through a local
  first: `CodemanDiag?.x` still throws a ReferenceError when the identifier was
  never declared, and that is the one function in the app that must not throw.
- panels-ui's two kill-all clears route through the same helper, and the
  xterm-version guard's comment says "resolved lockfile version" rather than
  "dependency RANGE", which is what it has pinned since the last round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 13:07:33 +05:30
Michael GrundbergandClaude Opus 5 90fd0a5a15 fix(tmux): gate the pane-exit read, and mute the dot on the rich rail too
Four changes the maintainer asked for on Ark0N/Codeman#446 before merging.

The pane-exit watcher stays always-on, but a tick now costs nothing when there
is nothing to observe. `hasObservablePaneSession()` skips the tmux exec while
every session on the manager is one of the shapes `Session.paneExitApplies`
already forces to UNKNOWN: a remote SSH session (its local pane holds the ssh
client), a docker case (a `docker exec` into the container's own tmux), and a
record rebuilt from the socket (no provenance at all). The timer is untouched.
Skipping retracts nothing, for the same reason a failed read does not: the map
still holds the last real reading, and every path that puts a new command in a
pane calls `clearPaneExit()` itself. The two copies of that rule are pinned
against each other in `test/session-pane-exit.test.ts`, because drift between
them is silent in both directions.

`DEFAULT_PANE_EXIT_INTERVAL_MS` was already a constant beside the stats and
remote-reconnect intervals; its comment now says why the watcher owns its own
cadence and why the number is what it is.

The never-default-an-absent-status rule is written where `PaneExit` is declared.
It names `status ?? 0` as the thing never to write, and says that an agent the
OOM killer took would otherwise read as a user typing `/exit` — which is what
absent-stays-absent keeps a later clean-exit sweep away from. Nothing fails when
somebody adds that `??`, which is why the sentence is there rather than a test.

Checking the dot's specificity found a second fight, and it was losing. On the
tab strip the alert rules win as intended: a session that exits with a
permission dialog pending still renders red, and yellow for an idle alert. On
the rich vertical tab rail they did not — that rail's own `tab-state-*` dot
rules are (0,9,1) against the strip's mute at (0,5,0), so an exited session
there kept a full green dot AND the working halo beside a badge reading
"exited". The rail twin matches that specificity exactly and therefore must stay
below those rules in source order; it clears the halo as well, which the strip's
rule never had to think about.

`test/session-pane-exit-ui.test.ts` now resolves the real stylesheet in jsdom
rather than matching selector text: postcss collects every rule that paints
`.tab-status`, a real engine decides, and the tests read back the answer. Two
mutations were run against it to prove it has teeth — dropping the hand-written
alert exclusions fails three cases, and moving the rail twin above the state
rules fails one.

Refs Ark0N/Codeman#446.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:19:55 +02:00
Michael GrundbergandClaude Opus 5 64c288a683 feat(codex): read Codex's own background-terminal row
Codex states background work too, and it says so in a different place. Claude
writes `· 1 monitor ·` on the last row of the screen; Codex pins
`1 background terminal running · /ps to view · /stop to close` ABOVE its
composer, which puts that row third from the bottom once the status line and
the composer are counted.

So how far up the screen to look is now per-CLI data as well:
`capabilities.workDetect.watchingLines`, bounded to 1..8 by the schema, and
defaulting to Claude's two. That bound is the point. The window is half the
injection guard, since every row it adds is another row the agent itself may be
able to write, and the label is what silences an idle alert. The other half is
the anchor, and Codex's is ` · /ps to view`: chrome naming a slash command only
the CLI can offer, so a session that writes "I left 1 background terminal
running for you" into its own output matches nothing.

Measured against a live codex-cli 0.154.0 pane rather than read out of a
binary. The row appears when the terminal starts, follows the composer down as
the conversation grows, and is gone after `/stop`. Verified end to end on an
isolated beta: the session payload carried `watching: "1 background terminal"`
and the badge rendered with it, and both cleared when the terminal stopped. The
fixtures in the tests are that capture verbatim.

Codex has no hook signals, so no idle prompt and no false NEEDS YOU row: for a
Codex session this is the badge alone, which is the case the maintainer said a
registry field could cover and a hook never could. Cross-CLI tests pin that
neither pattern fires on the other's screen, and that a CLI declaring nothing
still reports nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:58:52 +02:00
Rounak DattaandClaude Opus 5 abd39318e6 fix(terminal): deadline must cover the body, precache must ignore the cache-bust query
Review fixes. Two of these are defects in the previous commit.

1. The fetch deadline only covered time-to-headers. `await fetch()` settles on
   response headers, so clearing the abort timer in a finally around it left the
   body — the multi-megabyte `?full=1` capture the deadline exists for —
   completely unbounded; it only ever bounded a server that accepts a connection
   and never replies. Measured against a server that sends headers immediately
   and stalls the body 4s under a 1s deadline: fetch resolved at 30ms, timer
   cleared there, body completed at 4026ms unaborted. Now the body is read
   inside `_fetchTerminalCapture`, which returns {json, headers, headersAt} —
   headers because two callers read server-timing, headersAt because those same
   callers measure header-vs-body time and can no longer observe that moment.
   `_terminalCaptureInflight` is scoped the same way, so a body still streaming
   counts toward a capture starting beside it. Same test now aborts at 1005ms.

2. The precache could never be hit, and the previous commit made that expensive
   rather than free. `renderIndexHtml` runs `cacheBustAssets`, which appends
   `?v=<mtime>` to every same-origin .js/.css reference INCLUDING content-hashed
   names — confirmed against a running instance:
   `vendor/xterm-zerolag-input.6fee72f2.js?v=1789402869101`. `caches.match` is
   query-sensitive, so entries keyed on the bare hashed path were unreachable;
   deriving the list from the manifest turned cheap 404s into ~1.3MB downloaded
   at every install that nothing could read back, once per deploy now that
   CACHE_NAME rotates. The fallback match takes `{ ignoreSearch: true }`, which
   also lets runtime-cached entries survive an mtime change.

3. `_wsOutputGapSession` was only cleared in ws.onopen, so paths that already
   repaint the buffer left it set and the socket replayed everything a second
   time. `selectSession` loads the buffer and only THEN calls `_connectWs`, so
   neither the _isLoadingBuffer nor the _terminalRefreshOwner guard applied.
   `_markTerminalBufferReconciled()` is now called from _onSessionNeedsRefresh's
   finally, from selectSession after its load, and from _cleanupSessionData.

   The scope claim was also wrong and is corrected in the comment: when the
   network drops, SSE drops with it and handleInit's keepTerminal branch already
   reconciles. The genuinely uncovered case is the WS dying while SSE stays up,
   where _onSSETerminal discards SSE terminal frames until _wsReady flips in
   onclose — up to the ping+pong window of output nothing writes.

4. CLAUDE.md said "all of them measured rather than reasoned", which the PR's
   own "not verified" section contradicted. Split explicitly: the replay race is
   measured, the watchdog mechanism is verified against xterm 6.0.0 under jsdom
   (field path resolves, a forced stale handle makes refreshRows a no-op, the
   kick schedules a fresh frame), and the iOS rAF-discard premise is reasoned
   and still wants a device. Adds the two missing entries — the WebSocket
   reconcile and the sw.js/build.mjs "keep these in sync or the build throws"
   contract.

Also: test/xterm-private-api.test.ts pins the RESOLVED lockfile version instead
of the declared `^6.0.0` range, which was the wrong assertion in both directions
— a real upgrade to 6.4.0 can rename a private field while resolving inside the
range, and an innocuous range edit failed while changing nothing installed. And
test/sw-precache-manifest.test.ts now parses HASHABLE out of scripts/build.mjs
rather than hand-copying it, which was the same drift this PR exists to fix; the
parse is guarded against silently matching nothing.

The deadline fix has a behavioural test against a real socket plus a source
guard asserting `await res.json()` precedes the finally — verified to fail when
the helper is reverted to the old shape, so it is not vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:25:36 +05:30
Rounak DattaandClaude Opus 5 c0422c4e21 feat(terminal): renderer watchdog, atomic replay clear, fetch deadlines, reconnect recovery
Four ways the terminal can silently stop being correct — in each case the
buffer keeps updating, nothing throws, and the only recourse is a reload.

1. Renderer freeze after backgrounding. iOS DISCARDS scheduled rAF callbacks
   when a PWA backgrounds, and xterm's RenderDebouncer only clears its
   `_animationFrame` handle from inside that callback — so one drop leaves it
   permanently set and every later refresh() early-returns. Parsing is
   decoupled from rendering, so bytes keep filling the buffer correctly while
   nothing paints. Codeman has exactly ONE xterm for the whole page load, so a
   single backgrounding wedges it until a reload. Adds a 2s liveness poll and
   `_kickRenderer()`, which does what the dropped `_innerRefresh` would have.

2. Replay clears raced live output. xterm's write() is async-queued while
   reset() is synchronous and, per upstream, "does not clear input buffers and
   does not reset the parser" — so bytes queued before a reset are parsed after
   it and fuse into the snapshot. Verified against the real xterm 6 here:
   write('p8'); reset(); write('rmissions') renders "p8rmissions". The main
   path was already safe via a queued erase; the needsRefresh and clearTerminal
   paths were not. All three now share one queued `\x1bc` (RIS), which unlike
   3J/H/2J also resets modes, charsets, scroll regions and SGR state.

3. Output lost on WebSocket reconnect. Input frames carry seq+cid and are
   delivered exactly once; output frames carry nothing. ws.onopen re-sends dims
   and flushes queued input, and needsRefresh only fires on external-CLI
   startup and SSE backpressure drain — never on reconnect. Output produced
   while offline was simply absent afterwards. Interim fix: reaching onclose
   means the drop was unintentional, so the session is marked and the next open
   reconciles from the server buffer. Sequencing output is the follow-up.

4. Terminal captures had no deadline. No AbortController anywhere in the
   frontend, including `?full=1`, which the code itself calls "unbounded-ish
   work: at the default history limit it can be megabytes". Adds a budget that
   scales with full-vs-tail and with captures in flight, degrading to a plain
   fetch where AbortController is missing.

Also: the service-worker precache was dead — the build content-hashes assets
but sw.js listed pre-hash names, so 15 of 23 entries 404'd (verified against a
running instance) and cache.add().catch() hid it. Offline still worked via
runtime caching, but CACHE_NAME was a constant so activate's cleanup never
deleted anything and every past release's assets accumulated. Both are now
derived from the build manifest. Crash-trail entries are flattened and capped,
since they are joined with \n into one value and one call site interpolates a
server-controlled WS close reason.

The watchdog reads xterm privates — there is no public API. Every access is
optional-chained so a shape change degrades to a no-op. `_renderService` only
exists after open(), which needs a real DOM, so the gate cannot assert the
field path; test/xterm-private-api.test.ts pins the dependency range instead.

Tests: 23 new (terminal-resilience, sw-precache-manifest, xterm-private-api),
all pure/static so they run in the gate, which excludes the mobile suite. One
static source guard in history-truncation-notice updated for the renamed call;
the behaviour it pins is unchanged.

Not verified: no browser available, so no runtime reproduction of the freeze
and no real-device test of the reconnect path. Both warrant a device pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:24:52 +05:30
Michael GrundbergandClaude Opus 5 74884a20eb feat(approvals): let a watching session keep quiet, and fix the tui gate
The badge alone left the row in NEEDS YOU, which is the thing the issue was
about. The fix is the alert that does not fire.

An idle prompt from a session that is watching its own background work now
opens ALREADY acknowledged. `hook-event-routes` passes `Session.watching` to
`notePrompt()`, which sets `acknowledgedAt` and records why in a new
`acknowledgedReason`. Nothing new suppresses anything: `acknowledge()` has
always meant "the alert this prompt armed is spent", and the prompt itself
stays pending, answerable and available as Read My Mind context. A wrong label
therefore costs a card that does not blink, never an alert that was never
created.

Every surface follows from that. The broadcast carries the reason, so a live
page declines to arm the tab alert and raises no desktop notification. The push
is skipped, since a false alarm is hardest to ignore on a phone. A reloading
page reads `acknowledgedAt` in `seedApprovals()`, which it already did. And
`classifySession()` now reads it too, which is a pre-existing bug fixed here:
acknowledging on one device cleared the alert everywhere except `codeman tui`.
It re-arms for free, because the next idle prompt supersedes the item and is
built fresh. Only `idle` is eligible, so a dialog that blocks the agent still
goes red whatever else it started.

The label is pane-derived and therefore prompt-injectable, so it is now read
from the last two rows of the screen only, with Claude's pattern anchored on
the `·` its footer joins items with, ANSI-stripped and length-capped at the
source. An agent that prints `· 1 monitor ·` into its own output finds no
match.

Verified on an isolated beta: a session that armed a monitor took its idle
prompt acknowledged with no alert on any surface, wore the badge, and showed
"quiet, watching 1 monitor" on its still-answerable card; the same session with
the monitor killed alerted normally on the next prompt. `test/watching-no-alert.test.ts`
pins both directions across all four surfaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:10:05 +02:00
Michael GrundbergandClaude Opus 5 1cb0441bd8 fix(session): degrade the resume pin to the session id, not to nothing
A single pin that failed its transcript gate returned the options untouched,
so `resumeSessionId` fell back to `_resumeSessionId` — undefined for an
ordinary session — and the renderer emitted the bare
`claude --dangerously-skip-permissions --session-id "<this.id>"`. Every
session prompted before its first `/clear` owns a transcript under that id,
so the dropped pin handed back exactly the refusal this branch removes, with
no `||` branch to catch it. It was also a regression against master on the
`restartCli()` path, which pinned `_claudeSessionId ?? this.id` and, since the
constructor seeds that field, could never land unpinned.

The pin now walks three candidates in priority order — the conversation
chain's tail, the launch seed, then the session's own id — and takes the first
one a transcript backs. A candidate that misses is passed over rather than
ending the walk.

Falling off the end pins nothing, which also settles the second half of the
problem: the old code skipped the transcript check whenever the pin was the
session's own id, so a genuinely new pane rendered the two-branch form after
all. That costs a brand-new session claude's "No conversation found" line in
its scrollback, and `wrapWithNice()` prefixes only the first branch of the
rendered `a || b`, so the branch that actually runs loses its priority for the
life of the session. With no transcript anywhere the bare `--session-id` is
the correct command, so the comment claiming an unchanged shape is now true.

The transcript lookup reads the server process's own `CLAUDE_CONFIG_DIR` when
a session declares none. A pane inherits the server environment through tmux,
so on an install that exports it the CLI writes its transcripts there and
every lookup under `~/.claude` was a false negative — which under the old code
meant the colliding command. `claudeCredentialsPath()` and
`realClaudeConfigDir()` resolve the same directory the same way. The header
sentence calling a skipped resume "the safe direction" described the opposite
of what happens at this call site, and says so now.

The create-path fallback writes `_resumeSessionId` alongside the create
options. That branch leaves `isRestored` false, so `_claudeSessionId` is
recomputed from the launch fields and settled on `this.id` while the CLI
resumed the chain tail; the response viewer, Read My Mind and the unified-list
alias map read that field until the next first-hand hook.

Four new tests: a chain tail with no transcript while the session id has one,
no transcript anywhere, the create path's alias, and the process-env lookup.
All four fail against the previous commit. Two existing tests move with the
gate — the guess-refusal test now backs the session's own id, and the
custom-model restart test gives its working pane the transcript that makes
`--session-id` collide in the first place, alongside a new one pinning the
no-transcript case.

CLAUDE.md described the pin as a `restartCli()`-only thing sourced from the
live conversation id. All three halves of that moved here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:21:21 +02:00
Michael GrundbergandClaude Opus 5 3f2cde2db7 feat(session): say when a session is watching its own background work
An agent that arms a monitor, backgrounds a shell or hands a task to a
cloud session is told to end its turn. The pane then falls quiet, Claude
Code's idle_prompt notification arrives a minute later, and every surface
files the session under NEEDS YOU with nothing for a human to answer.

Claude states what it is still running on the last row of its screen
(`⏵⏵ bypass permissions on · 1 monitor · ← for agents`). That row is now
`capabilities.workDetect.watchingLine` in the CLI registry, guarded by
compileVersionRegex() like every other config regex, and the idle probe
reads it off the capture it already takes: `watchingLabel()` in
session-activity.ts searches the last five lines only, so a session that
PRINTS "1 monitor" is not mistaken for one running it.

The label lands on Session.watching and rides toLightDetailedState() out
to every surface. The phone overview, the desktop home rail and the rich
sidebar rows wear it as a `watching` badge in the accent colour, beside
the state pill and never in place of it: an agent can arm a monitor and
ask a question in the same breath, and only the pill says which.

Verified end to end against a throwaway session on an isolated beta
instance: the payload carried `watching: "1 monitor"` once the turn
ended, the badge rendered next to a yellow `waiting` pill, and both
cleared when the monitor died.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:00:30 +02:00
Michael GrundbergandClaude Opus 5 47e7935274 fix(session): resume the conversation when respawning a dead pane
A CLI that launches with `--session-id <id>` refuses an id that is already in
use (claude: `Error: Session ID ... is already in use.`), and every session
whose agent has been prompted owns a transcript under that id. The dead-pane
respawn in `_setupOrAttachMuxSession()` passed the bare launch line, so
recovering such a session relaunched a CLI that died on startup, the pane went
dead again at once, and the conversation was stranded behind a tab that looked
merely idle.

`restartCli()` has pinned a resume id against this since the custom-model work,
and its comment states the assumption that made the other path look safe:
"Unlike the dead-pane respawn, this one kills a WORKING pane whose conversation
already has a transcript". A pane whose agent exited has a transcript too.

Both relaunch paths now build options through
`_buildRespawnPaneOptionsWithResumePin()`, and so does the create-path fallback
after a failed respawn, which otherwise met the same refusal that made it the
fallback. Four gates guard the pin, each standing for a way of resuming the
WRONG conversation or of making a working relaunch fail.

A remote or docker session is never pinned. Unlike `restartCli()`, whose route
refuses both, the dead-pane respawn is reached by every session shape. Their
pane commands already render a self-healing `--session-id || --resume`, and
both flip to resume-first once the resume id differs; the conversation lives on
the far side, so a local id resolves to nothing there and the `--session-id`
fallback then collides with the transcript the far side does hold.

The id comes from the conversation CHAIN rather than `_claudeSessionId`, which
also holds history-correlated guesses keyed on the working directory.
`_recordClaudeSessionInChain()` refuses those so they cannot "write a foreign
conversation into this pane's permanent record", and launching from one is
worse than the display bug that rule prevents. The chain tail also outranks the
launch seed, which is written once at construction and never moves off a
`/clear`.

A pin no transcript backs is dropped, because the fallback branch keeps
`--session-id <this.id>` and would collide. A synthetic `restored-<fragment>`
id from socket discovery is dropped too, and logged: it fails claude's `uuid`
token pattern, so the renderer would emit the unpinned command while the caller
believed otherwise.

Tests cover each gate and the rendered command. Four of them fail against the
unfixed source; the remote and docker ones were separately checked against a
build with only that guard removed, since they pass on master for the wrong
reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 15:36:17 +02:00
DevvynandClaude Sonnet 5 a2dcc91ddf fix(docker): add and correct the cross-checkout collision guard for Update-Codeman.sh
Same guard as Start-Codeman.sh's own (docs/docker-self-update.md-adjacent
incident, 2026-09-21): docker-compose.yaml hard-codes `name: codeman`, so a
second checkout run without COMPOSE_PROJECT_NAME resolves to the SAME
Compose project as any other checkout on the host. It has to live here too,
not just in Start-Codeman.sh: this script's own --no-cache build and its
`down`/`down --volumes` both run BEFORE the handoff at the bottom of the
file, so Start-Codeman.sh's copy of the guard would only fire after this
script's own destructive calls already ran — and its default `down
--volumes` is more destructive than Start-Codeman.sh's own targeted
refresh, clearing every named volume the resolved project has.

Also fixes a real bug the same guard shipped with: under `set -o pipefail`,
`grep -v` legitimately exits 1 when nothing survives the filter (the
ordinary, no-collision case), and without `|| true` on the pipeline that
non-zero status propagates through the command substitution and `set -e`
aborts the WHOLE script at the guard — every time, collision or not. Caught
only by actually executing the guard end-to-end against a stub `docker`
(the existing smoke-test harness), never by a static text/regex check on
the source; the stub's `config --format json` response was also fixed to
pretty-print like real Compose does, since a compact one-liner silently
resolved project_name to empty and exercised neither script's guard the
way production output does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-21 21:23:39 +08:00
Michael GrundbergandClaude Opus 5 c67c130caa feat(web): mark a session tab whose agent has exited
The tab now reads "exited (137)" beside the session name, drawn from the
`paneExit` field the server publishes. `applyPaneExitBadge()` owns the DOM
work, called from the incremental render path — the only path a live session
ever takes, since going from live to exited adds and removes no tab and so
never reaches the full rebuild.

An unknown answer draws nothing. A death tmux could not explain reads "exited"
with no number rather than "exited (0)", so an unexplained death and a clean
exit do not look alike. A signal death reads "exited (signal 9)".

The badge carries `data-i18n-skip`, like the status pills: it is generated
text, `i18n.js` walks inserted content, and a dictionary entry added later
would fight the renderer, whose in-place comparison is against English.

The tab also carries a `tab-agent-exited` class that mutes the status dot. That
dot is drawn from `status`, which stays `idle` or `busy` for an exited pane as
the issue requires, so without this a green or pulsing dot sits beside a badge
saying the agent is gone — the first thing a tester asked about. `status`
itself is untouched, so this is a rendering rule only. The CSS excludes the two
alert classes by hand, following the convention the rich-rail dot rules
document: a dot turning red or yellow because a session is blocked on a human
outranks "the agent exited".

The tab keeps its click behavior. X still closes it, and nothing here closes,
sweeps or restarts anything.

`docs/architecture-invariants.md` gains the mechanism under "Session data and
lifecycle", where every comparable one already lives: what the tri-state means,
the four shapes it is absent for, why the watcher cannot ride the stats
collector, why an absent `#{pane_dead_status}` is not 0, and the three things
that must never happen to an exited pane.

Refs Ark0N/Codeman#446.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 10:52:15 +02:00
Michael GrundbergandClaude Opus 5 90a95f562b feat(session): publish and persist a local pane's agent exit
The mux layer now knows a pane's agent has exited. This puts it on the session
record, where the board and, later, the reboot restore can see it.

`SessionState.paneExit` carries `{ status?, signal?, at }` and rides the
existing `session:updated` broadcast through `toState()`. No new SSE event. The
server pulls each answer from `mux.getPaneExit()` rather than off a broadcast
payload, so the raw reading never reaches a browser: for a remote or docker
session that reading is the death of an ssh client or a `docker exec`, not of
the agent.

The field is tri-state, and the third state is its absence: `undefined` means
Codeman does not know, and it never reads as alive. `Session.setPaneExit()`
forces that unknown for every shape a dead local pane does not describe. A
direct-PTY session owns no pane. A remote SSH session's local pane holds the
ssh client, whose death means a transport drop OR an exit, which is the
ambiguity PR #355 settled by not guessing. A docker case's local pane holds a
`docker exec` into the container's own tmux. And a session rebuilt from the
socket has no provenance at all: `reconcileSessions()` gives it a synthetic
`restored-<fragment>` id that matches no `state.json` entry, so a remote
session rediscovered after `mux-sessions.json` was lost arrives with no
`remote` field and looks local — `MuxSession.discovered` marks it, and absent
metadata there counts as unproven rather than as proof. The scoping lives on
`Session` rather than in `TmuxManager` so there is one copy of the rule.

`status` and `pid` are untouched. `status: 'error'` belongs to the PTY-exit
circuit breaker and makes the browser offer a restart, and a null `pid` is what
makes the browser re-attach and launch a fresh CLI. A reading that repeats the
previous answer writes nothing and broadcasts nothing.

An unknown answer never reads as alive, but a stale KNOWN one would keep
reading as exited, so `clearPaneExitForNewPane()` retracts it on every path
that puts a new command in the pane: the start/attach path, the `restartCli()`
relaunch behind a custom-model switch, and the remote reattach. Without the
second of those, switching an endpoint on an exited session launched a new
command and then persisted and broadcast the old exit straight back onto it.

`toState()` is also what `state.json` persists, so the record survives a
reboot, which is the only thing that does: a reboot takes the tmux server, and
with it every live signal and every `mux-sessions.json` entry. Nothing reads it
there yet — making the restore refuse such a session is a behavior change that
belongs with the part that closes them. Recovery threads the saved value back
through the constructor so the first persist after boot cannot blank it.

Refs Ark0N/Codeman#446.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 10:01:09 +02:00
Michael GrundbergandClaude Opus 5 02dc46dcd7 feat(tmux): report a dead pane's exit from the batched pane list
Codeman creates every tmux pane with `remain-on-exit on`. When the agent exits,
tmux keeps the pane, the tmux session, and the `tmux attach-session` process
Codeman records as the session's pid, so no PTY exit handler fires and nothing
writes the exit down. tmux itself knows: it marks the pane dead and reports the
exit status. This reads that.

`PANE_LIST_FORMAT` gains `#{pane_dead}`, `#{pane_dead_status}` and
`#{pane_dead_signal}`, and `startPaneExitWatcher()` refreshes a
muxName-to-observation map from ONE batched `tmux list-panes -a` per tick. Boot
reconciliation already ran that same call, so it now fills the map too and
recovery starts with a reading.

The watcher owns its own interval rather than riding `startStatsCollection()`,
which the issue suggested. That collector is armed when a browser opens the
Monitor panel and DISARMED when it closes it, and boot skips it entirely unless
recovery found a live session, so a session created on a freshly booted server
would publish nothing and one browser could turn detection off for every other.
Measured on an isolated instance: a dead pane with status 0 reported nothing
until `POST /api/mux-sessions/stats/start` was called by hand. It is still one
batched read per tick; only the timer changed.

Three rules keep a positive answer trustworthy. A session answers only when
tmux listed exactly one pane for it, because Codeman never splits a pane and a
session the user split by hand has none that speaks for the agent. A pane
answers only when `#{pane_dead}` said 1 or 0, because an empty field is a tmux
that did not answer. An absent status stays absent rather than becoming 0:
measured on tmux 3.2a, a SIGKILLed pane reports neither a status nor a signal,
and calling that a clean exit would be wrong in the direction that matters.

Two guards stop a slow read undoing a fast one. `EXEC_TIMEOUT_MS` is 5000 ms
against a 2000 ms interval, so a read can outlive two ticks: one already in
flight suppresses the next, and a generation counter that every
`clearPaneExit()` bumps discards a read that started before a respawn or a
kill. An observation also carries its pane pid, so a second command in the same
pane that exits the same way starts a new timestamp rather than inheriting the
first death's.

A non-empty read of `list-panes -a` is authoritative for the whole socket, so
sessions missing from it are pruned, which also bounds the map as tmux sessions
come and go outside `killSession()`. A failed or empty read retracts nothing.

The manager reports the raw pane reading and applies no session-shape scoping,
because the remote-reconnect watcher beside it needs exactly that raw reading.

`parsePaneList` becomes `parsePaneRows`, returning one row per pane instead of
a name-to-pid map; reconciliation builds its map from the rows. The parser's
existing cases carry over unchanged, including the launchd/systemd literal-tab
regression from PR #71.

Refs Ark0N/Codeman#446.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 10:00:54 +02:00
DevvynandClaude Sonnet 5 5b4878df3b fix(docker): address Ark0N's PR review on Update-Codeman.sh — fix the handoff, the build/down ordering, and default-clear the build volumes
Three blockers, all fixed and verified by actually running the script (not
just string-matching it):

1. `exec "$script_dir/Start-Codeman.sh"` failed EACCES/exit 126 on every
   checkout, since Start-Codeman.sh is committed non-executable (100644) —
   the same fact my own second commit on this branch established. Fixed to
   `exec bash "$script_dir/Start-Codeman.sh"`.

2. `down` ran before `build --no-cache`, so Codeman and every session it was
   running were offline for the entire rebuild, and a build failure left the
   stack down with nothing to bring it back — the exact ordering mistake
   Start-Codeman.sh's own "Build BEFORE taking the stack down" comment exists
   to prevent. Reordered to build, then down, then hand off.

3. The default path could throw the rebuild away: codeman-node-modules/
   codeman-dist only re-seed from the image while EMPTY, Start-Codeman.sh
   only clears them when it detects the checkout's HEAD or package-lock.json
   moved, and neither condition is true for the Dockerfile-only change this
   script exists for — so a plain `bash docker/Update-Codeman.sh` rebuilt an
   image whose fresh node_modules/dist then sat unused behind the old
   volumes. Made clearing them the default; `--keep-volumes` opts out
   (replaces the old `--volumes`/`-v` flag, which is no longer needed since
   clearing is now the default).

Smaller items from the same review, also fixed:

- The --no-cache build now derives PUID/PGID from CODEMAN_APPDATA_PATH's
  owner first, via the identical owner_of() helper Start-Codeman.sh uses
  (parity-tested) — without it, the build used Compose's default 1000:1000
  regardless of the real appdata owner (99:100 on the Unraid layout
  docker/README.md documents), and Start-Codeman.sh's own correctly-PUID'd
  build during the handoff would then rebuild those layers anyway, so the
  --no-cache image never actually shipped.
- docker/README.md's "rebuilds ... only when it detects ... moved" wrongly
  described BOTH the rebuild and the volume-clearing as conditional;
  Start-Codeman.sh rebuilds on every start, only the volume-clearing is
  conditional. Corrected, and reworded around the new default.
- --help/-h now prints usage and exits 0 instead of falling into the
  unrecognised-argument branch.
- "the ONLY named volumes this stack declares" now says docker-compose.yaml
  specifically, since a docker-compose.override.yml could add more.

New tests: PUID/PGID derivation parity with Start-Codeman.sh's owner_of(),
--help handling, and — the one that actually catches blocker #1, which five
source-string-matching tests did not — a real end-to-end smoke test: a
synthetic deployment, a stub `docker` on PATH logging every invocation, the
real script executed via a real subprocess. Confirms the real command
sequence (build --no-cache, then down --volumes or plain down, then evidence
the handoff genuinely ran Start-Codeman.sh) and that a working handoff fails
honestly at Start-Codeman.sh's own later check rather than with EACCES.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
2026-09-21 15:19:00 +08:00
DevvynandClaude Sonnet 5 3b714446b4 fix(docker): drop the wrong executable-bit assertion for Update-Codeman.sh
Start-Codeman.sh, its sibling and the script it hands off to, is itself
committed non-executable (100644) upstream — it's documented and invoked
as `bash docker/Start-Codeman.sh`, never `./docker/Start-Codeman.sh`. The
"is executable" test I'd added for Update-Codeman.sh asserted the opposite
convention, which the file correctly does not follow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
2026-09-21 13:58:45 +08:00
DevvynandClaude Sonnet 5 9ba90a674a chore(docker): add Update-Codeman.sh for scripted major-update rebuilds
docker/README.md and docs/docker-self-update.md both already point operators
at "stop the stack, rebuild, restart" for anything the in-app updater refuses
to apply (a changed server.Dockerfile, a changed docker-compose.yaml, or a
new required .env key) — but that was a manual, hand-typed procedure with no
script of its own, unlike every other start/update path this deployment has.

docker/Update-Codeman.sh scripts it: `docker compose down`, then an
unconditional `docker compose build --no-cache` (a major update should be
certain of what actually ships, not reuse whatever layers happened to be
cached), then hands off to the existing Start-Codeman.sh for the same
careful PUID/PGID, override-file and fingerprint handling every other start
already goes through — rather than reimplementing any of that by hand and
risking it drifting out of step.

An optional --volumes/-v flag also removes the codeman-node-modules/
codeman-dist named volumes, the scripted form of the "Resetting the build
artefacts" procedure docs/docker-self-update.md already documents by hand.
Safe: those two are the only named volumes this stack declares; application
data and case workspaces are host bind mounts, never touched by
`docker compose down` either way.

Docs updated: a "Major updates" section in docker/README.md, and a pointer
from docs/docker-self-update.md's existing "Resetting the build artefacts"
troubleshooting entry.

Tests: extended test/docker-entrypoint.test.ts (the existing home for
Start-Codeman.sh's own static checks) with a bash -n parse check, the
down-before-build-before-handoff ordering, the --volumes flag's effect,
unrecognised-argument handling, and byte-for-byte agreement with
Start-Codeman.sh's own override-file resolution logic (so `down` here and
`up` there can never target different Compose files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
2026-09-21 13:57:54 +08:00
DevvynandClaude Sonnet 5 d3ee9f23c2 fix(cli-registry): correct accent colours, and a real gemini/antigravity/omp rendering bug
Two related fixes, found while re-measuring stock.ts's `accent` field
against the actual rendered UI (docs/cli-registry.md flags this field as
"transcribed, not authoritative — re-measure before wiring one up"):

1. A real, user-visible bug: `.btn-toolbar.btn-run.mode-gemini`,
   `.mode-antigravity` and `.mode-omp` had no override rule inside the
   `html:not([data-skin="og"])` block, unlike codex/pi/grok/deepseek, which
   do. The generic `.btn-toolbar.btn-run` rule in that block resolves at
   higher specificity than the base sheet's per-mode pair, so all three
   rendered as plain claude-blue on every skin except `og` — including
   `daylight-blue`, which is the actual DEFAULT skin for a fresh install
   (index.html's pre-paint script), not an edge case. Added the three
   missing rules, sourced from each CLI's own already-designed og-skin
   colours (no new colours invented), mirroring the exact pattern
   pi/grok/deepseek already use. Also corrected the stale comment on the
   pi rule, which claimed this was still broken for gemini/antigravity.

2. `stock.ts`'s `accent` field was simply wrong for most CLIs — e.g. claude
   was registered as Anthropic's brand orange (#d97757) while its button
   renders blue, antigravity was registered purple while it renders cyan,
   pi was registered green while it renders pink. Measured each CLI's real
   `border-color` from its own `.mode-<id>` rule on the og skin (the
   cleanest single representative hex each entry's gradient resolves
   around) and corrected all 9 non-shell entries to match. `accent` has no
   reader yet (confirmed via the DECLARED_FOR_LATER guard test), so this
   changes no rendered output — it's a data-accuracy fix, matching the
   registry's own "transcribed, not authoritative" warning taken literally.
   Also fixed a false claim in types.ts's doc comment for the field
   ("CSS derives every per-CLI gradient from it via --cli-accent") — no
   such CSS variable exists anywhere in the codebase.

Full gate: 406 files / 7721 tests / 0 failures, typecheck/lint/format:check/
check:public-assets/check:frontend-syntax all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-21 12:23:19 +08:00
github-actions[bot]Claude Fable 5.1github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
9466acfc1a chore: version packages (#461)
* chore: version packages

* chore: sync CLAUDE.md version to 1.32.0

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Codeman maintainer <noreply@anthropic.com>
2026-09-21 06:00:49 +02:00
Codeman maintainer e899af4305 chore: changeset for the merge-time fixes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 04:53:19 +02:00
Codeman maintainer 299a21d5f5 fix(split-pane): merge-time fixes for split-pane sessions (#453)
The maintainer's promised merge-time fixes from the final review of #453:

1. closeSplitPane() tears down a divider drag still in progress, so a split
   that collapses mid-drag no longer leaves body.split-pane-resizing (the
   page-wide col-resize cursor and user-select lock) set until a reload.
2. openSplitPane() re-applies the picker's own exclusions (detached session,
   pid === null, no session record) for a row that went stale while the
   menu sat open, refusing silently like its neighbouring gates.
3. architecture-invariants: the hard-hide of .btn-split is the
   @media (max-width: 1179px) rule in styles.css, not mobile.css.
4. SplitTerminalPane.destroy() nulls onclose (and onerror) beside onopen
   and onmessage.
5. Picker rows drop the data-session-id attribute nothing read.
6. The Pane-A-ends branch collapses with skipPrimaryResize, so the closing
   resize is no longer aimed at the session the server just removed.
7. The {t:'r'} refresh path is single-flight across the fetch and the
   chunked write, coalescing a mid-replay refresh into one trailing re-run.

Tests: split-pane-auto-collapse-unit gains the drag-teardown, exclusion and
skip-resize cases; the new split-pane-terminal-unit covers destroy() and the
refresh single-flight. All were run against the pre-fix module to confirm
they fail there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit dbd39aed015ae5ae5870aba398bf4b4ab5118e47)
2026-09-21 04:53:19 +02:00
Codeman maintainer d8e85285c9 fix(mobile): merge-time fixes for the prompt composer (#444)
- styles.css: restate the composer overlay's own bottom gutter after the fold rules
  (the generic .paste-overlay longhand erased it: 0px flat, hinge strip replacing it
  folded) and subtract the fold strip from the dialog's max-height
- test/foldable-layout.test.ts: simulate the cascade for
  .paste-overlay.prompt-composer-overlay (fails without the CSS fix); pin the palette
  anchor by name instead of ELEMENTS.at(-1)
- keyboard-accessory.js: guard the app global in refreshForActiveSession() like the
  rest of the file
- keyboard-accessory.js: a whitespace-only draft is empty (Send no longer submits
  blank lines); the text still goes out untrimmed
- keyboard-accessory.js: derive _composerMaxLength and the frame refusal from one
  64 KiB frame limit minus both bracketed-paste markers so they cannot drift
- keyboard-accessory.js: translate the textarea placeholder and label at build time,
  since the DOM translator skips <textarea> subtrees
- i18n.js: zh-CN entries for the composer dialog copy
- docs/wiki/Mobile-Guide.md: describe the Compose key instead of a clipboard key
- CLAUDE.md: a "Mobile prompt composer" paragraph after the accessory bar one
- test/mobile-prompt-composer.test.ts: pin the whitespace rule and the derived budget

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit f6725ba52da17b0bdbee8be3b5011e7cae514f69)
2026-09-21 04:39:28 +02:00
Codeman maintainer 0f955327b2 fix(cli-registry): merge-time fixes for the run-menu consolidation (#458)
- test/opencode-resize.test.ts: retarget the launcher guard at the real code (this.selectSession(firstSessionId), any this.activeSessionId assignment) with an anti-vacuity check; the old strings existed nowhere, so it could never fail
- session-ui.js: restore as comments the two invariants the merged bodies lost (deepseek leaves statusReporting unset, i.e. ON; no effort field for external CLIs, it is Claude-specific)
- docs/cli-registry.md: move the frontend-guard paragraph below the two backend-guard paragraphs so they keep their antecedent, and note the widened comparison shape
- test/frontend-cli-no-id-branching.test.ts: the comparison shape accepts any left-hand identifier (const m = this._runMode; m === 'codex' was invisible), normalized to `mode`; the two `m !== 'shell'` display filters are allowlisted and the remaining blind spots documented
- test/run-mode-dispatch.test.ts: table-driven pin of run() dispatch (claude to runClaude, each RUN_MODE_LAUNCH id to _runCliMode(id), shell to runShell, unknown to runClaude, lock held and released)
- CLAUDE.md: name the second CI-gated guard next to the backend one
- server.ts: every </head> injection passes a replacer function; a clis.json label containing $' re-injected the rest of the document past escapeScriptJson (two render tests pin it, proven failing on the string form)
- _isAltCliMode(): no reference anywhere in the tree, nothing to fix

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 1ea363ff808a62861559bc141e724b163cc1c56e)
2026-09-21 04:37:45 +02:00
Codeman maintainer d3f2ec0220 fix(custom-model): merge-time fixes for the promoted-model picker (#459)
The maintainer's promised follow-ups to opticon454's picker promotion,
applied on the landing branch after the merge (ecb95b5d):

- session-ui.js: the promotion tag ("Currently loaded" / "Last used") and
  the "Default" pill are two separate spans, so a promoted row that is
  also the endpoint's defaultModelId shows both instead of silently
  losing its Default marking; two tests pin it (both fail on the old
  exclusive-slot rendering).
- styles.css: a dedicated #customModelPickModal .set-scope rule, since
  the pill was only styled inside the three settings modals and rendered
  as plain body text here; same skin tokens, modal layout untouched.
- docs/wiki/Custom-Model-Endpoints.md: describe the promotion (currently
  loaded, else last used per device), the separate Default pill, and
  that nothing is ever auto-chosen.
- CLAUDE.md + docs/custom-model-endpoints.md: credit the real "Last used"
  writers (_runCustomModelEntryViaRestart and
  _quickStartWithCustomModelConfirm; runCustomModelEntry only dispatches
  since 88e5b7b2) and drop the now-wrong "both defer to Default" sentence.
- Not done: moving the one-shot "last used" write into
  _runCustomModelEntryOneShot, because the existing one-shot tests assert
  that _quickStartWithCustomModelConfirm writes the key itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 0cb0f911adc14a852ba5c2951768a4aa87c25657)
2026-09-21 04:30:01 +02:00
Codeman maintainer 6ef71ec3b9 chore: thanks for 1.32.0
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 04:28:22 +02:00
Codeman maintainer d47f93abdb chore: changesets for #453, #444, #459 and #458
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 04:27:17 +02:00
Codeman maintainer dcf9437308 Merge pull request #460 from Ark0N/feat/installer-v2
feat(install): three questions up front, an unattended build, and a URL you can scan
2026-09-21 04:23:15 +02:00
Codeman maintainer aa13af1f7f Merge pull request #444 from DodgyBadger/feat/mobile-prompt-composer
feat(mobile): add manual prompt composer
2026-09-21 04:23:15 +02:00
Codeman maintainer 9a2e14a93a Merge pull request #453 from timkjr/feat/split-pane-sessions
feat: split-pane sessions — view two live terminals side by side
2026-09-21 04:23:14 +02:00
Codeman maintainer ecb95b5d67 Merge pull request #459 from opticon454/feature/run-menu-picker-currently-loaded-model
feat(custom-model): promote the currently-loaded/last-used model in the Run-menu picker
2026-09-21 04:23:14 +02:00
Codeman maintainer a7452dc046 Merge pull request #458 from opticon454/followups
feat(cli-registry): drive the run-menu frontend from the CLI catalogue (PR B2)
2026-09-21 04:23:13 +02:00
Codeman maintainer 72d437ab63 fix(install): fold in both reviews of #460
The two reviews on the PR (DeepSeek Harness, then Claude) found one class of
bug twice and a list of smaller ones; all of them land here, each pinned in
test/install-sh-invariants.test.ts and, where it is bash logic, driven in the
bash:3.2 CI step as well.

The Start line the done screen prints is now composed in one place
(start_command_hint) from every non-default value, the same five the exec
branch exports through export_bind_env, so "do not start" under a sub-path or
a custom port no longer prints a bare `codeman web`. The --lan / --tailscale /
env preset paths read ${CODEMAN_PASSWORD:-$EXISTING_PASSWORD}: a flag re-run on
a unit that carried a password used to rewrite it without the password and
with the unauthenticated ack. --password and --port flip RECONFIGURE so they
reach the unit instead of taking the quiet update path, and `install.sh name`
re-syncs the unit's base URL after the mapping is re-added.

Also: the sudo keepalive is ended before the exec into the foreground server
(exec skips the EXIT trap, and the loop keys on $$); Ctrl+C in the HTTPS-toggle
poll is trapped for the poll only and skips Tailscale for the run instead of
killing the installer; uninstall asks before removing a LaunchDaemon this
installer never wrote; a foreign daemon gets a launchctl kickstart hint and the
done screen stops claiming the new build is running; the preflight summary
reads the Tailscale state with a line grep when node is not installed yet; the
LAN security notice uses the configured port; a bare re-run ends on the done
screen; a build failure after a rename names the install.sh tailscale
recovery; TS_JOINED_HERE (written, never read) is gone; the plan doc and
architecture-invariants say what the code does. A minor changeset is included.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 03:03:31 +02:00
Codeman maintainer 1ba0684438 docs(install): describe installer v2 and the Tailscale naming options
README, the Installation / Remote-Access / Running-As-A-Service wiki pages,
docs/security-architecture.md and CLAUDE.md describe the three-question flow,
the flags, the subcommands, the sub-path answer for an occupied :443 and why
the rename is opt-in. docs/installer-v2-plan.md is the design and the
verification record (what was measured, what still needs a fresh machine);
docs/tailscale-installer-plan.md points at it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 21:38:12 +02:00
Codeman maintainer af744bdb54 feat(install): ask three questions up front, then install unattended and end on the URL with a QR code
The installer used to ask about ten things, half of them after a multi-minute
build, and the question that matters most (how do I reach the dashboard) came
last. It now looks at what is on the machine, asks at most three questions
(access, an optional tailnet name, service), and does the rest unattended.

- Every step that needs a human runs before the build: one consent for all
  missing packages, one sudo prompt kept warm for the run, the AI CLI menu,
  and the Tailscale install/login/operator/HTTPS-toggle preflight (the toggle
  is polled and the admin page opened in a browser, instead of "re-check
  now?").
- The build, the service and `tailscale serve` run behind spinners with their
  output in ~/.codeman/install.log; the tail is shown on failure and a failed
  dependency install names its step.
- The done screen leads with the URL (tailnet, network, this machine) and a
  terminal QR code from the qrcode package Codeman already ships.
  `install.sh status` prints it again.
- Tailscale is two halves: tailscale_prepare (question phase) decides the
  serve SHAPE, tailscale_apply (after the build) issues the one serve command.
  When :443 already belongs to another app, Codeman goes under a sub-path
  (serve --set-path /codeman + CODEMAN_BASE_URL in the unit; serve strips the
  prefix, Codeman's ingress tolerates that, --base-url covers the URLs it
  emits) or a second port, instead of replace-or-nothing.
- Renaming the node to codeman-<hostname> is opt-in and defaults to no
  everywhere (the tailnet name is the machine's ssh identity); --name and
  `install.sh name` do it, uninstall offers the old name back. Serve config is
  keyed by the DNS name, so a rename takes our mapping down first and re-adds
  it under the new name.
- Flags pipe through `bash -s --`: --tailscale|--lan|--local, --name|--no-rename,
  --service|--run|--no-start, --yes, --password, --port. --port is now also
  written into the service file.
- npm install runs with CODEMAN_NO_AUTOSTART=1: postinstall otherwise builds
  and starts a detached `codeman web` on 127.0.0.1:3000, which made the
  service crash-loop on EADDRINUSE while the done screen reported "running"
  off the orphan (fresh Ubuntu 24 sandbox).
- The LAN address comes from the default route, not the first interface.
- A foreign /Library/LaunchDaemons/com.codeman.web.plist is left alone
  instead of being replaced by a LaunchAgent.
- The cloudflared question leaves the main flow (`install.sh cloudflared`).
- "Continue WITHOUT a password?" defaults to yes (owner decision).

Tests: the invariants test pins no `serve reset`, no funnel, no Tailscale
Service, every serve mutation through ts_cmd_serve, rename before shape,
flag/header parity, the rename default and the NO_AUTOSTART opt-out; the CI
bash 3.2 step drives the question phase with stubbed tailscale state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 21:38:11 +02:00
timkjrandClaude Sonnet 5 46d8b92049 fix(split-pane): port Ctrl+Shift+C's never-falls-through guarantee to Pane B
The smart-copy gate only entered its selection-check block behind
hasSelection(), so a selection-less Ctrl+Shift+C skipped straight to
`return true` and ceded the keystroke to the browser's own handling
(e.g. Chrome's Inspect-Element binding) instead of matching Pane A's
"never falls through" contract for that chord.

Verified live in a real browser that this is a UX-parity fix, not an
interrupt-safety one: xterm's evaluateKeyboardEvent never emits PTY
data for a shifted ctrl-letter regardless of any gate (only "_" and
"@" get special-cased), so no accidental 0x03 was ever at risk. The
regression test added here asserts on the dispatched event's
defaultPrevented rather than the absence of a WS frame, since the
frame-count check passes vacuously for this exact key combo whether
or not the gate fires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:34 -05:00
timkjrandClaude Sonnet 5 0b3e086334 fix(split-pane): address Ark0N's fourth pass — PTY-less picker exclusion, hollow chord test, remaining key gates
- buildSplitPickerSessions() now excludes any session with pid === null
  (exited CLI, tripped PTY-exit breaker, a restore that never re-attached).
  Pane B has no equivalent of selectSession()'s auto re-attach POST, so a
  split opened onto one had nothing reading its tmux pane: no terminal
  events ever arrived and Session.write() silently dropped every keystroke
  with no ack either way, while the socket itself reported healthy.
- Fixed the hollow chord regression test: the synthetic keydowns carried no
  keyCode, which is what xterm's evaluateKeyboardEvent switches on to
  produce a data frame at all, so the assertion held regardless of whether
  the gate fired. Adding real keyCodes surfaced a second, real bug in the
  Alt+B case: the event bubbles to app.js's own document-level shortcut
  dispatcher, which really toggles the sidebar and resets the layout
  attribute the gate reads before Pane B's own (later, non-capture) handler
  ever sees it — fixed by driving the app's real settings cache instead of
  only the DOM attribute.
- Ported the two remaining primary-pane gates with real consequences:
  Ctrl+Z (SIGTSTP) is swallowed for every non-shell session, matching
  terminal-ui.js's reasoning (an Ink/TUI agent loop stops dead with no
  visible output otherwise), and Shift/Ctrl+Enter now POSTs to
  /api/sessions/:id/send-key for THIS pane's own session instead of
  letting xterm send a bare \r, which used to submit an incomplete prompt
  instead of inserting a newline. Smart-copy Ctrl+C is re-implemented
  against Pane B's own terminal (copying app.copyTerminalSelection() would
  have copied Pane A's selection instead).
- Updated docs/architecture-invariants.md and docs/split-pane-sessions-plan.md
  to match, and added CLAUDE.md's missing .split-picker-menu z-index entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:34 -05:00
timkjrandClaude Sonnet 5 fafef0aa00 fix(split-pane): gate app-level chords out of Pane B, address Ark0N's third pass
Pane B had no attachCustomKeyEventHandler of its own, so the document
capture-phase shortcut handler's preventDefault() (which does not stop
xterm) left Ctrl+K/Alt+1/Alt+B ALSO writing their raw byte/escape
sequence into Pane B's live PTY on top of whatever the app action did
to Pane A. Pane B now installs the same registry-aware gates the
primary pane's own attachCustomKeyEventHandler uses. Ctrl+V is left on
xterm's default paste — no image-paste trap to route it to.

Plus the rest of the review's smaller items:
- Narrowing the window past the desktop gate now closes an open split
  instead of leaving it stranded on screen.
- Split is refused while a web tab is active (activeWebviewId), which
  used to open Pane B's socket behind a hidden container.
- Pane B now handles the server's `{t:'r'}` refresh frame via a shared
  _loadBuffer() helper (also used by connect()), instead of ignoring it.
- The divider drag now uses pointer events + setPointerCapture (mirrors
  tab-rail-resize.js), a button!==0 guard, preventDefault, and a
  body.split-pane-resizing cursor/selection lock — a plain mousedown
  drag selected the text under the cursor as it crossed both terminals.
- Pane B's close control and the picker rows are real <button>s now
  (keyboard-reachable), with matching CSS chrome resets.
- Dropped the redundant CodemanBase.base prefix on the buffer fetch
  (the global fetch wrapper already applies it).
- data-preview-order for the Split settings chip moved from a collision
  with Ultracode Agents (both 15/12) to 11.5, matching its real
  position between Multi-monitor and Ultracode Agents in the header;
  widened test/app-settings-structure.test.ts's regex to allow the
  decimal (Number() already parses it fine for the preview sort).
- Added zh-CN i18n entries for the Split button and empty-picker text.
- Dropped the stray unused `vi` import Ark0N flagged as unrelated to
  this feature (vitest's `globals: true` makes it ambient anyway).
- Documented the fix and the deliberate no-cid/seq choice in the
  split-pane-sessions architecture-invariants entry.

Added a real-Chromium regression test asserting Ctrl+K/Alt+1/Alt+B
dispatched at Pane B's own textarea send no `{t:'i'}` frame over its
WebSocket. Full CI gate green (409 files, 7736 tests) plus all 8
split-pane browser tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:33 -05:00
timkjrandClaude Sonnet 5 3152ec801d docs(split-pane): short CLAUDE.md rule, stale module count, wiki entries, shortcut-handler caveat
CLAUDE.md previously only mentioned split-pane in the load-order list,
with nothing in the Architecture/frontend prose the way every other
feature gets, and its own module count was one stale (34, should have
been bumped to 35 when terminal-split.js was added). Add a short
pointer-style paragraph next to the other terminal features, fix the
count.

docs/wiki/The-Dashboard.md's header button table and
docs/wiki/Settings-Reference.md's header chips list are the two
user-facing surfaces that never mention Split at all; added both, plus
a note that the feature is desktop-only regardless of the setting.

docs/split-pane-sessions-plan.md: recorded the one design note that
isn't a code change — the global capture-phase shortcut handler always
resolves against Pane A, so Ctrl+L/Ctrl+W typed into Pane B affects the
other session. Not fixed for v1, same reasoning as the rest of the
"deliberately plainer" section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:33 -05:00
timkjrandClaude Sonnet 5 2e3e245cc6 fix(split-pane): throttle the drag, chunk the scrollback, and the rest of Ark0N's second pass
Two majors:
- The divider drag was unthrottled: every mousemove did a full xterm
  reflow on BOTH panes and sent Pane B a {t:'z'} resize frame with no
  unchanged-dimensions skip, fanning out into a `tmux resize-window`
  child plus a SIGWINCH per event — ~50 of each dragging across half a
  wide viewport. SplitTerminalPane.fit() is now split into localFit()
  (reflow only) and fit() (reflow + send); the drag coalesces moves
  into one localFit() per animation frame via requestAnimationFrame,
  and sends the real resize for both panes exactly once, at drag end,
  matching the primary pane's own throttledResize convention.
- Pane B pulled the FULL scrollback unchunked for every session mode,
  writing it in one terminal.write() call. Mirrors the primary pane's
  own mode check (app.js's selectSession): shell sessions get a
  bounded 1MiB ?tail= fetch instead of ?full=1, and the fetched buffer
  is written through a minimal chunked writer (32KB slices, yielding a
  frame between each) instead of one primary-pane chunkedTerminalWrite
  this simpler, independently created/destroyed pane has no equivalent
  of (no session-switch generation counters or live-output gate).

Smaller items from the same review:
- Pane B now follows live appearance changes (applyTerminalSkin,
  applyTerminalFontFamily, applyTerminalFontWeights, setFontSize all
  propagate to it, matching the teammateTerminals pattern) and reads
  the real codeman-font-size/terminalFontFamily/weights/DEFAULT_SCROLLBACK
  settings at construction instead of hardcoding fontSize 14 / scrollback 5000.
- The Pane-B-promotion path now skips selectSession() when
  _closingSessions already owns this delete (the user closing Pane A's
  own tab), matching _onSessionDeleted's own active-session-handoff guard.
- Detaching a session AFTER a split is already open now yields the PTY
  size in _sendResize() too (not just at picker-open time), mirroring
  sendResize's own detachedElsewhere guard.
- .btn-split joins the body.solo-mode hide list, next to .btn-multimonitor.
- The split row was 6px wider than its container (two flex-shrink:0
  50% panes plus a 6px divider): both panes are now flex-shrink 1.
- Pane B's header and the split-picker rows are marked so i18n.js's
  exact-string lookup skips them, matching .session-name elsewhere —
  a session literally named e.g. "Sessions" was translatable on zh-CN.
- The Split button now reflects open/closed state via a `.split-open`
  accent style, aria-pressed, and a title/aria-label that says which
  behaviour the next click gets.
- _splitPane.connect() is no longer an unawaited call with no .catch().
- terminal-split.js's fileoverview pointed at a doc path that was
  renamed away in the previous push; @dependency now credits
  constants.js for CodemanTerminalFont, not terminal-ui.js.
- index.html's Split settings chip no longer reuses data-preview-order
  "12" (already the Ultracode Agents chip's slot in the same "header"
  preview group).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:32 -05:00
timkjrandClaude Sonnet 5 165cfb52d6 fix(split-pane): stop breaking every settings save, finish the desktop gate
Blocker from Ark0N's second PR #453 pass: moving showSplitButton into
settings-ui.js's per-device displayKeys set was only half of making it
per-device. saveAppSettings() still put it in the object PUT to
/api/settings, SettingsUpdateSchema (.strict()) does not declare it,
the server answered 400 INVALID_INPUT, and because the call site never
checked res.ok the UI still reported "Settings saved" while NOTHING
persisted — workspaceHooksEnabled, agentSkillEnabled, tunnelEnabled,
claudeModel, every toggle, on every save, on every device. Strip it
out via the same destructure every other per-device key goes through
(`showSplitButton: _ssp,`), drop the stray mention from a schemas.ts
comment (a mention there reads as "this is a real field" to the next
grep), and add a static guard test mirroring
test/terminal-auto-copy.test.ts's three-way rule.

Also finishes the desktop gate the first pass only did in CSS at
599px: SPLIT_PANE_MIN_WIDTH (1180, matching HOME_SESSIONS_MIN_WIDTH)
now backs an actual JS width check in _applySplitButtonVisibility,
with a matchMedia listener so a live window resize hides/shows the
button without a reload — the CSS backstop in styles.css is the
reverse-direction guarantee for when JS hasn't run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:32 -05:00
timkjrandClaude Sonnet 5 6c8bd6c606 fix(split-pane): gate the Split button to desktop, make it per-device
Ark0N's PR #453 review: nothing gated this feature to desktop even
though the design called for it (two 240px min-width panes plus the
divider need ~486px, and the divider has no touch handlers), and
showSplitButton was a SYNCED setting, so turning it on at a desk also
put the button in the phone header.

- Hard-hide .btn-split on phones in mobile.css regardless of the
  setting, matching the other desktop-oriented header buttons in the
  same @media (max-width: 599px) block.
- Move showSplitButton into settings-ui.js's per-device displayKeys
  set and drop it from SettingsUpdateSchema entirely, matching the
  showFileViewerButton/skin precedent (CLAUDE.md's "per-device keys
  ... must NOT be added to SettingsUpdateSchema" rule) — a desktop
  opt-in must never sync onto a phone that never asked for it. Removes
  the now-invalid server-round-trip test for the setting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:32 -05:00
timkjrandClaude Sonnet 5 8d3bde5469 fix(split-pane): address the rest of Ark0N's PR #453 review
- Exclude popped-out (detached) sessions from the split picker:
  SplitTerminalPane._sendResize() has no yield-to-detached-window check
  the way the primary pane's sendResize() does, so splitting against a
  detached session put its own window and Pane B in a fight over the
  same PTY's dimensions. Simplest fix per the review: keep them out of
  buildSplitPickerSessions() entirely.
- Show a visible dead state when Pane B's WebSocket drops. onData
  already silently discards keystrokes while the socket isn't OPEN
  (there is no reconnect for v1), so a dropped socket left the pane
  looking normal while it quietly ate everything typed into it.
- openSplitPane() returns early with no active session, so a split
  triggered from the home screen no longer creates and connects Pane B
  behind the opaque welcome overlay with nothing to show for it.
- onMove() during a divider drag now bails when the split has
  auto-collapsed mid-drag (the other pane's session ending) instead of
  throwing on `divider.parentElement` being null.
- Promote Pane B via `selectSession(id, { auto: true })` when Pane A's
  session ends — this is an app-driven selection, not the user clicking
  a tab, so it must not spend the promoted session's idle alert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:31 -05:00
timkjrandClaude Sonnet 5 78dcb0aa24 fix(split-pane): refit Pane B when the window/sidebar/tab-rail resizes
Ark0N's PR #453 review: fit() was only ever called from the divider
drag, and the trailing-edge ResizeObserver callback in terminal-ui.js
(throttledResize) only ever measured Pane A's own container. Split at
a wide viewport, shrink the window (or toggle the Alt+B sidebar, or
drag the tab rail), and Pane A's cols changed while Pane B silently
kept its stale PTY size in both xterm and the real pane.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:31 -05:00
timkjrandClaude Sonnet 5 7fc66e8161 docs(split-pane): keep the design spec, drop the task-plan scaffolding
Per Ark0N's review on PR #453: rename the design spec to
docs/split-pane-sessions-plan.md, matching every other feature's
*-plan.md convention, and drop the 957-line implementation task plan
(docs/superpowers/plans/2026-09-15-split-pane-sessions.md) — workflow
scaffolding for the subagent-driven-development run, not repo
documentation. Fixes the now-dangling link in architecture-invariants.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:30 -05:00
timkjrandClaude Sonnet 5 1678386f50 test(split-pane): cover the blank-Pane-B and stale-width-Pane-A fixes
Real-browser regression coverage for the previous commit:

- SplitTerminalPane connects onto an already-quiet session and shows its
  existing scrollback with no new output, proving the ?full=1 fetch (not
  a live echo) populated the pane.
- openSplitPane() force-resizes Pane A synchronously as part of opening
  a split.
- Dragging the divider force-resizes Pane A once, at drag end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:30 -05:00
timkjrandClaude Sonnet 5 3859506f9b fix(split-pane): populate Pane B history and force-resize Pane A on split changes
Pane B's SplitTerminalPane.connect() only opened a WebSocket and waited for
live output — ws-routes.ts's terminal socket sends nothing on connect, only
future 'terminal' events — so it stayed blank until the target session
happened to produce new output. It looked intermittent rather than
always-broken because a resize sent by _sendResize() often nudges the
session's real tmux window to a new size, and tmux repaints its current
screen on resize; that incidental repaint was what usually populated the
pane. When Pane B's computed dimensions already matched the session's
last-known size, Session.resize() skipped the resize as a no-op and the
pane stayed empty. Fetch the existing scrollback (?full=1) before opening
the socket, same as the primary pane does.

Pane A never told its own session's PTY/tmux about a size change at all,
relying purely on the passive 300ms-debounced ResizeObserver in
terminal-ui.js. openSplitPane() now force-resizes Pane A immediately on
entering split (mirroring closeSplitPane()'s existing symmetric call), and
the divider-drag handler force-resizes it once at drag end (matching the
codebase's established trailing-edge debounce convention rather than
flooding a resize per mousemove).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:29 -05:00
timkjrandClaude Sonnet 5 33b2605815 fix(split-pane): stop leaking document listeners on repeated split-picker toggles
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:29 -05:00
timkjrandClaude Sonnet 5 d0a887d98a test(split-pane): add fast unit coverage for the _onSessionDeleted auto-collapse ordering
test/split-pane-auto-collapse.browser.test.ts covers "Pane B's session ends"
in a real Chromium, but that suite is excluded from the npm test CI gate.
The "Pane A's session ends, Pane B gets promoted" branch had no coverage
anywhere, and it is the one branch whose correctness depends on exact
ordering: _splitSessionId must be captured BEFORE closeSplitPane() runs
(which nulls it) or the promoted session id is lost. Loads terminal-split.js
via `vm` against a minimal fake CodemanApp (same technique as
test/session-close-fallback.test.ts), and pins all three branches (Pane A
ends, Pane B ends, unrelated session ends) plus that the original
_onSessionDeleted always still fires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:29 -05:00
timkjrandClaude Sonnet 5 24a92c8f3e fix(split-pane): refuse to split a session against itself
Nothing stopped a stale picker click (opened before switching tabs) or
clicking Pane B's own session tab while split from landing on
openSplitPane(sessionId) with sessionId === activeSessionId, or from
selectSession() rebinding the primary pane onto the session Pane B was
already showing — either way, two live WebSockets to one session, each
independently claiming PTY dimensions via its own {t:'z',...} resize frame.
openSplitPane() now refuses early when the target is already the active
session, and a new selectSession() prototype patch (same top-level pattern
as the existing _onSessionDeleted patch) closes an active split BEFORE the
primary pane rebinds to the session Pane B holds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:28 -05:00
timkjrandClaude Sonnet 5 f7852081b7 docs(split-pane): fix orphaned Session list layout section
The new "Split-pane sessions" section was inserted between the "Session
list layout (header strip vs. left sidebar)" heading and that section's own
body paragraphs, orphaning the heading from its content. Move "Split-pane
sessions" to after the Session list layout section's full body, before
"Gesture control: the setting" — no change to the Session list layout prose
itself, only where the new section sits relative to it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:28 -05:00
timkjrandClaude Sonnet 5 f0e24d8ce2 fix(split-pane): hide the split container together with the rest of the terminal on a web tab
.main.webview-active hid .terminal-wrap when a web tab became active, but
.terminal-wrap is reparented INSIDE .terminal-split-container while a split
is open, so Pane B and the divider stayed stranded on screen over the
dashboard iframe. Hide the whole split container as one unit, mirroring the
existing .terminal-wrap rule; no state is destroyed, so returning to the
session tab shows the split intact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:28 -05:00
timkjrandClaude Sonnet 5 2724c922ce fix(split-pane): style and dismiss the split-picker menu
.split-picker-menu/-item/-empty (created in openSplitPicker()) had zero CSS
and could not be dismissed except by picking an item — a default-path defect
since the Split button ships enabled to anyone who flips showSplitButton on.
Add CSS matching the sibling .run-mode-menu popover's look (floating-bg
backdrop blur, border, shadow, z-index 1000 above the header's 100), and
dismiss on outside click or Escape via the same one-shot listener pattern
session-ui.js already uses for its other transient popovers
(toggleCaseSettings(), toggleRunModeMenu()). Picking an item now routes
through the same _dismissSplitPicker() method as the outside-click/Escape
handlers, so the listeners never outlive the menu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:27 -05:00
timkjrandClaude Sonnet 5 57406f6c14 fix(split-pane): stop clamping Pane B's resize dimensions to a 40x10 floor
_sendResize() clamped Pane B's proposed cols/rows to a 40/10 floor before
sending the {t:'z',...} resize frame, so the PTY was misinformed of Pane B's
real width at the divider's own reachable 20% position, causing real
output-wrapping bugs. The primary pane (terminal-ui.js's
getTerminalDimensions()) sends fitAddon.proposeDimensions() unclamped and
lets the server enforce its own valid range ([1,500]/[1,200] in
ws-routes.ts); Pane B now matches that convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:27 -05:00
timkjrandClaude Sonnet 5 8903a72662 fix(split-pane): hide the Split header button when showSplitButton is off
.btn-split--hidden had no matching CSS rule anywhere, so the opt-in Split
header button shipped visible to every user on every viewport regardless of
the setting. Add the `display: none !important` rule alongside its sibling
marker classes (.btn-multimonitor--hidden etc.), plus a static regression
guard (test/split-pane-hidden-button-css.test.ts) that fails if any future
"*--hidden" marker class in index.html is missing a matching CSS rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:27 -05:00
timkjr ba7b8b7bef docs: add split-pane sessions architecture-invariants entry 2026-09-20 13:10:26 -05:00
timkjr 8c73128cd6 feat(split-pane): auto-collapse split when either session ends 2026-09-20 13:10:26 -05:00
timkjrandClaude Sonnet 5 2abf328db8 feat(split-pane): add open/close orchestration, picker, and divider drag
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:25 -05:00
timkjrandClaude Sonnet 5 fa8bb13a27 fix(split-pane): reset _wsReady on WS close/error in SplitTerminalPane
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:25 -05:00
timkjrandClaude Sonnet 5 6f64e557e5 docs(plan): fix session-creation test bug found by Task 4's implementer
Task 4's implementer found two real bugs in this plan's browser-test
helpers: POST /api/sessions nests the id at data.session.id (not
data.id), and mode:'shell' needs a follow-up POST .../shell to actually
spawn a PTY. Fixed in Task 4's own snippet (documentation accuracy —
already fixed in the real committed code) and pre-emptively in Tasks
5/6's createShellSession() helper before either was dispatched, so
neither implementer has to rediscover it independently. Also corrected
the <script> tag snippet to defer, matching the real file's convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:25 -05:00
timkjrandClaude Sonnet 5 97a1238c85 feat(split-pane): add SplitTerminalPane class for Pane B
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:24 -05:00
timkjr aa0521602d feat(split-pane): add split container/divider/pane-b CSS 2026-09-20 13:10:24 -05:00
timkjr 2bc16d5fd9 feat(split-pane): add showSplitButton setting and header button 2026-09-20 13:10:23 -05:00
timkjr d60a164025 feat(split-pane): add pure divider-clamp and picker-list helpers 2026-09-20 13:10:23 -05:00
timkjrandClaude Sonnet 5 727817410c docs(plan): fix Task 6's SSE handler patch to target the prototype
Monkey-patching the instance's _onSessionDeleted inside a
DOMContentLoaded listener races connectSSE()'s handler-wrapper cache,
which captures the function reference by value on first connect and
never re-reads it. Patching CodemanApp.prototype at module-evaluation
time (synchronous script-tag order) is unraceable: it completes before
any instance exists or connectSSE() ever runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:22 -05:00
timkjrandClaude Sonnet 5 28d3bd7da8 docs(plan): fix Task 2/4/5/6 tests against real test infrastructure
Preflight scan for SDD execution caught two classes of defect before
dispatch: Task 2's test invented a buildTestApp() helper and response
envelope that don't exist for /api/settings; Tasks 4-6 used
@playwright/test's runner against a test/browser/ directory that
doesn't exist in this codebase. Both corrected against real patterns
found in existing tests (system-routes-settings-partial-put.test.ts,
terminal-copy-shortcut.test.ts, tab-rail-resize.browser.test.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:22 -05:00
timkjrandClaude Sonnet 5 d0f9bdd251 docs: fix plan wording and add execution-environment note
Global Constraints previously read as if local-echo/CJK/accessory-bar
were desktop features; they are mobile-only, and split-pane is the
desktop-only side of that equation. Also names the exact spec section
instead of a loose paraphrase, and adds a worktree/branch note so an
executing subagent knows where this plan runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:22 -05:00
timkjrandClaude Sonnet 5 ff98006471 docs: add split-pane sessions implementation plan
7 tasks: pure divider/picker helpers, showSplitButton header wiring,
split-container CSS, SplitTerminalPane (Pane B's independent xterm+WS),
open/close orchestration with picker and divider drag, auto-collapse on
either session ending, and an architecture-invariants entry.

Also folds in the "detach session" prior art discovered mid-brainstorm
into the spec's architecture section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:21 -05:00
timkjrandClaude Sonnet 5 5f1be90ae9 docs: fix tab/pane terminology in split-pane spec
The Problem paragraph and the architecture section used "tab" where
"pane" was meant, colliding with the browser's own tab concept.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:21 -05:00
timkjrandClaude Sonnet 5 3da8bb7046 docs: add split-pane sessions design spec
Scopes v1 of an in-app split view (two live session panes side-by-side,
draggable divider) after multi-monitor spanning turned out to solve a
different problem than showing multiple panes at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:10:20 -05:00
DodgyBadger ac574d6c64 fix(mobile): compact the compose key 2026-09-20 17:31:10 +00:00
DevvynandClaude Sonnet 5 d9e6ebb20a fix(cli-registry): address round-2 review on #458 — count-based allowlist, RUN_MODE_LAUNCH drift guard
Three of Ark0N's four "will take at merge" items, applied instead since
they were straightforward to do properly:

1. test/frontend-cli-no-id-branching.test.ts's ALLOWED_BRANCHES keyed on
   <file>::<expression> (fixed last round) closed the line-shift problem
   but opened a new one: every stock id was already allowlisted for
   session-ui.js in the `mode === '<id>'` form, so a BRAND NEW branch
   reusing that exact expression anywhere in the file passed unnoticed.
   Reproduced live (`if (this.mode === 'codex')` injected into
   runOpenCode()) — stayed green under the old version. Each allowlist
   entry now carries the exact count of approved call sites, and a new
   test asserts actual-vs-declared count for every key; a mismatch in
   either direction is real (higher = new unreviewed branch riding in on
   an existing approval, lower = a reviewed site was removed and the
   entry is now stale). Reproduced again against the fix: same injection
   now fails with an exact diagnostic (expected 2, found 3).

2. Added test/run-mode-launch-table-drift.test.ts. RUN_MODE_LAUNCH
   restates four things stock.ts already owns (label, install command,
   supportsCustomModel, the external-mode key set), and they agree today
   with nothing enforcing it. supportsCustomModel is the dangerous one:
   the Run-menu picker's rows come from the server-injected
   window.__codemanCustomModelClis (built from
   capabilities.customModelInjection.kind), so a CLI gaining a real
   injection recipe later would be OFFERED in the picker while
   _runCliMode silently drops the customModel field for it — the session
   launches on the vendor's cloud while the UI claims the local endpoint.
   Drives the real session-ui.js via JSDOM and compares RUN_MODE_LAUNCH
   against STOCK_CLIS on all four axes.

3. Inlined the "Open Question 7 in PR-B2.md" references in the allowlist
   reasons — PR-B2.md is a local planning doc, never part of the
   committed tree, so the reference was dead on arrival for anyone
   reading the repo. Points at the PR #458 review thread instead.

4. Added a sentence to docs/cli-registry.md naming the new frontend guard
   alongside the backend one it mirrors.

Full gate: 406 files / 7721 tests / 0 failures, typecheck/lint/format/
check:frontend-syntax all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-20 21:37:05 +08:00
DevvynandClaude Sonnet 5 88e5b7b200 fix(custom-model): address Ark0N's PR review — client-side probe timeout, defer "last used" past confirmation, docs, zh-CN
Four things from the maintainer's review on PR #459, all fixed:

1. Bound _getCustomModelCurrentlyLoaded's probe client-side (~800ms via
   Promise.race, on top of — never instead of — the route's own 5s
   server-side timeout). Without it, an asleep/firewalled endpoint behind
   a saved model list left the picker completely invisible for up to 5s
   after the Run menu had already closed, with no spinner or toast.
   `timeoutMs` is an optional param (default 800, real callers never pass
   it) so a test can drive it in milliseconds, same pattern as
   `_watchLlamaSwapLoading`'s own `pollIntervalMs` — this code runs in a
   JSDOM window's own realm, whose setTimeout vi.useFakeTimers() cannot
   patch.

2. "Last used" is now written only once a launch actually applies, never
   on the mere click. It moved out of runCustomModelEntry (unconditional)
   and into each path's own success point: _quickStartWithCustomModelConfirm
   after the final post succeeds, and _runCustomModelEntryViaRestart right
   after the apply's success check. A context-window-warning decline means
   this exact model cannot work with this CLI at all, so the old
   unconditional write would promote, next time the picker opened, the one
   model guaranteed to fail again.

3. Documented the promotion/tag precedence and the new
   codeman:customModelLastUsed:<mode>:<endpointId> localStorage key in both
   CLAUDE.md's Custom Model Endpoint Profiles section and
   docs/custom-model-endpoints.md's Run-menu picker section.

4. Added zh-CN entries for "Currently loaded" and "Last used" in i18n.js,
   next to this modal's existing "Choose a model"/"Custom Endpoints" pair.

New tests: the client-side timeout (endpoint that never answers, one that
answers within the bound, and a rejected-after-timeout probe settling
quietly), and "last used" recording on success vs. NOT recording on either
confirmation's decline, for both the restart and one-shot paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
2026-09-20 21:19:42 +08:00
DevvynandClaude Sonnet 5 73607663fd fix(custom-model): guard the picker's async open against a slower, superseded probe
Code review (high effort) on the previous commit found a real race: making
_openCustomModelPickModal async (it now awaits the currently-loaded-model
probe before rendering) meant a second, faster call for a different
endpoint could render first, only for the first call's slower probe to
resolve afterwards and overwrite the modal with the wrong endpoint's model
list — while _pendingCustomModelPick (set synchronously, before either
await) still named the second, correct endpoint. Picking a model in that
state would launch/apply the wrong model on the wrong endpoint.

Fixed with the same mutable-generation-counter guard
_watchLlamaSwapLoading already uses for an identical async-superseded-by-
newer-call shape: every DOM write, including _pendingCustomModelPick
itself, is deferred until after the awaited probe, and a call that finds
its generation already superseded bails out untouched instead of clobbering
whatever a newer call already rendered.

Added a regression test driving two overlapping opens with a controlled
promise so the earlier, slower probe resolves after the later, faster one
renders, asserting the late response is a no-op.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
2026-09-20 19:27:07 +08:00
DevvynandClaude Sonnet 5 458ca578e7 feat(custom-model): promote the currently-loaded/last-used model in the Run-menu picker
Custom Model Endpoint Profiles' "which model" picker (session-ui.js's
_openCustomModelPickModal) always listed models in their raw discovery
order, so on a host with several downloaded GGUFs the user had to
remember (or eyeball the "Default" tag) which one llama-swap actually
had hot before picking — the whole point of the picker being fast is
undone if it makes you think first.

The picker now promotes exactly one model to the top of the list:

- If llama-swap reports a model from this host's own list `ready`
  right now (via the existing GET /api/model-endpoints/:id/running-status
  route), that model is promoted and tagged "Currently loaded" — it's
  what a launch attaches to with zero wait.
- Otherwise, the last model actually launched on this exact
  (harness, endpoint) pair is promoted and tagged "Last used", read
  from a new per-device localStorage key
  (codeman:customModelLastUsed:<mode>:<endpointId>), written by
  runCustomModelEntry on every launch attempt regardless of outcome.
- A plain (non-llama-swap) OpenAI-compatible server, an unreachable
  endpoint, or a loaded-but-not-yet-ready model never promotes
  anything — the rest of the list keeps its discovery order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
2026-09-20 19:20:45 +08:00
DodgyBadger 884713cca5 fix(mobile): retain oversized composer drafts 2026-09-20 07:21:20 +00:00
DodgyBadger a220c28a14 fix(input): count code points when clearing prompts 2026-09-20 07:18:49 +00:00
DodgyBadger 0761de3dae fix(mobile): use raw fallback for composed prompts 2026-09-20 07:18:23 +00:00
DodgyBadger c2eaba990b fix(mobile): release echo passthrough after compose 2026-09-20 07:17:41 +00:00
DodgyBadger e214691429 fix(mobile): preserve composer delivery after replay 2026-09-20 07:16:59 +00:00
DodgyBadger 773b405429 feat(mobile): add manual prompt composer 2026-09-20 07:16:59 +00:00
DevvynandClaude Sonnet 5 2df9355367 fix(cli-registry): address PR B2 review — fix two test guards, drop unused catalogue
Two required fixes from Ark0N's review of #458:

1. test/frontend-cli-no-id-branching.test.ts's ALLOWED_BRANCHES keyed on
   <file>::<line>::<expression>. A single inserted line anywhere above an
   entry shifted every subsequent line number, so all 21 entries went stale
   simultaneously and the same 21 branches were reported as "new" — on a
   file six other open PRs also touch. Dropped the line number from the key
   (<file>::<expression>, matching the backend guard's own design), which
   collapses 21 line-keyed entries to 11 or-collapse where the same
   expression recurs at multiple call sites in the same file.

2. test/run-mode-ui.test.ts's terminal-ownership guard scanned method
   bodies via `^ {2}async (run[A-Za-z]*)\(\) \{$`, which matched the 8
   one-line run<Mode>() wrappers PR B2 introduced but not _runCliMode(mode),
   where the real logic (and the actual risk the guard exists to catch) now
   lives. Fixed the regex to `^ {2}async (_?run[A-Za-z]*)\(\w*\) \{$` and
   added _runCliMode to the sanity list. Same-class fix in
   test/opencode-resize.test.ts, which had the identical blind spot via
   runOpenCode.toString().

Both reproduced live before fixing (inserted the same comment line; added
this.terminal.clear() to _runCliMode) to confirm the bug, then confirmed
the fix catches it and the suite stays green otherwise.

Also resolves Open Question 2 by dropping window.__codemanCliCatalog
entirely: nothing consumed it, and a registry DECLARED_FOR_LATER field
costs nothing until read while an unconsumed script tag on every page
render is a different trade. Reverts Phase 1 cleanly — server.ts's
injection, shortBadge back in types.ts's DECLARED_FOR_LATER list and the
pinned guard test, and the three associated render-index-html.test.ts /
server-index-title.test.ts assertions.

Full gate: 405 files / 7717 tests / 0 failures (net unchanged), typecheck/
lint/format clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-20 02:04:37 +08:00
DevvynandClaude Sonnet 5 cd64b0a3f7 feat(cli-registry): drive the run-menu frontend from the CLI catalogue (PR B2)
PR #380 (PR B) held back the frontend half of the CLI registry refactor,
explicitly deferring window.__codemanCliCatalog and making session-ui.js /
mobile-overview.js catalogue-driven as "PR B2".

- Inject window.__codemanCliCatalog in renderIndexHtml(), following the
  existing __codemanCustomModelClis pattern (escapeScriptJson-guarded,
  resolved per-request). Reading CliEntry.shortBadge here is what makes it
  genuinely read, so it drops out of types.ts's DECLARED_FOR_LATER list.
- Consolidate session-ui.js's 8 near-duplicate run<Mode>() launch functions
  (opencode/codex/gemini/antigravity/pi/omp/grok/deepseek) into one shared
  _runCliMode() plus a local RUN_MODE_LAUNCH config table. The 8 method
  names stay as thin wrappers (index.html calls them by name; tests assert
  on the name). Also collapses a duplicated 8-way isAltMode/isExternalCli
  OR-chain (same expression, copy-pasted twice in openSessionOptions) into
  one EXTERNAL_CLI_MODES check.
- Add test/frontend-cli-no-id-branching.test.ts, a guard scoped to
  session-ui.js/mobile-overview.js only (not the rest of src/web/public/,
  which stays explicitly out of scope per CLAUDE.md), mirroring the
  backend's own no-id-branching guard.

mobile-overview.js and the wiring of accent/echo/wheelForward/
keyboardAccessory were investigated and deliberately left alone: the first
is already a single, tested, gated table (not duplicated logic); the second
set belongs to terminal-ui.js/keyboard-accessory.js/styles.css, files
outside this PR's mandate.

Verified on a tmux-capable devbox (this sandbox has no tmux): full CI gate
at 405 files / 7717 tests / 0 failures, typecheck clean, 94 targeted tests
covering exact per-CLI wire-body shapes unmodified and passing, and a live
anti-vacuity check on the new guard (injected a real branch, confirmed it
fails, reverted, confirmed green).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
2026-09-19 21:16:31 +08:00
Devvyn 2d573d8a34 Merge branch 'master' of https://github.com/Ark0N/Codeman into followups 2026-09-19 19:43:45 +08:00
Devvyn 55a80eab86 Merge branch 'master' of https://github.com/Ark0N/Codeman into followups 2026-09-19 08:12:17 +08:00
Devvyn b1db5515d7 Merge branch 'master' of https://github.com/Ark0N/Codeman into followups 2026-09-16 15:23:52 +08:00
149 changed files with 17280 additions and 1587 deletions
+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 only inside a Codeman-managed session.",
"version": "1.31.0",
"version": "1.32.1",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+44
View File
@@ -100,6 +100,50 @@ jobs:
fi
echo "bash $BASH_VERSION: dsh identity probe survives a missing timeout"
'
# Installer v2: the question phase runs before the build, and every decision it
# takes is bash logic over stubbed tailscale state. Drive the flags, the launch
# default, the occupied-:443 menu and the rename question with canned answers,
# so a bash-4 construct or a flipped default in any of them fails here, not on a
# Mac. The JSON parsers need node (absent in this image) and are stubbed; their
# own coverage is test/install-sh-invariants.test.ts plus the vitest gate.
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 -e HOME=/tmp/h bash:3.2 bash -c '
set -euo pipefail
mkdir -p /tmp/h
. /w/install.sh
parse_flags --tailscale --service --name Build-Box --port 4000
[[ "$CODEMAN_TAILSCALE" == "1" && "$LAUNCH_PRESET" == "2" && "$TS_NAME" == "Build-Box" && "$CODEMAN_PORT" == "4000" ]]
[[ "$(ts_sanitize_name "$TS_NAME")" == "build-box" ]]
has_tty() { return 0; }
ANSWER=""; read_reply() { eval "$1=\"\$ANSWER\""; }
systemctl() { return 0; }
LAUNCH_PRESET=""; NONINTERACTIVE=0
choose_launch_mode linux >/dev/null 2>&1
[[ "$LAUNCH_CHOICE" == "2" ]]
check_tailscale() { return 0; }
ts_status_field() { case "$1" in "s.BackendState") printf Running ;; "s.Self && s.Self.DNSName") printf "box.tail.ts.net." ;; esac; }
ts_backend_state() { printf Running; }
ts_dns_name() { printf box.tail.ts.net; }
ts_serve_443_target_port() { printf 8080; }
ts_serve_find_port_mapping() { :; }
ts_serve_port_used() { return 1; }
detect_tailscale_serve_url() { :; }
tailscale_choose_mapping >/dev/null 2>&1
[[ "$TS_SERVE_MODE" == "path" && "$BIND_BASE_URL" == "/codeman" ]]
RENAMED=""; tailscale_rename_node() { RENAMED="$1"; }
TS_NAME=""; tailscale_choose_name >/dev/null 2>&1
[[ -z "$RENAMED" ]]
# A flag re-run keeps the password the unit already carries (and so
# never writes the unauthenticated ack), and the hand-start line the
# done screen prints carries every non-default value.
read_existing_binding() { EXISTING_FOUND=1; EXISTING_HOST=0.0.0.0; EXISTING_PASSWORD=s3cret; EXISTING_ACK=0; EXISTING_BASE_URL=""; }
CODEMAN_HOST=0.0.0.0; CODEMAN_TAILSCALE=0; unset CODEMAN_PASSWORD; BIND_ACK=0
choose_network_binding >/dev/null 2>&1
[[ "$BIND_PASSWORD" == "s3cret" && "$BIND_ACK" == "0" ]]
BIND_HOST=0.0.0.0; BIND_PASSWORD=x; BIND_ACK=0; BIND_BASE_URL=/codeman; CODEMAN_PORT=4000
[[ "$(start_command_hint)" == "CODEMAN_HOST=0.0.0.0 CODEMAN_PASSWORD="*" CODEMAN_BASE_URL=/codeman CODEMAN_PORT=4000 codeman web" ]]
RECONFIGURE=0; parse_flags --port 4001; [[ "$RECONFIGURE" == "1" ]]
echo "bash $BASH_VERSION: question phase (flags, launch default, occupied :443, rename opt-in, kept password, start line) ok"
'
- name: CLI catalogue artifacts are in sync with stock.ts
run: npm run generate:cli-catalog -- --check
+58
View File
@@ -1,5 +1,63 @@
# aicodeman
## 1.32.1
### Patch Changes
- 13e652e: Terminal copy: copying text out of a Claude Code or Codex pane no longer puts the pane's two-column transcript gutter on the clipboard, so pasted lines arrive flush instead of indented (#469). The width comes from the CLI registry (`capabilities.transcriptGutter`, 2 for claude and codex, measured on live panes) and is only a ceiling: a selection only ever shifts as a block, so its own indentation survives. Other CLIs and shells are untouched. It works in split panes and detached session windows too, and can be turned off per device in App Settings under Selection & clipboard.
- 13e652e: Sessions: recovering a Claude session whose tmux pane had died relaunched `claude --session-id <id>`, which Claude refuses once that id has a transcript, so the pane died again straight away and the conversation was stranded. The relaunch now resumes the conversation (`--resume <id> || --session-id <id>`), including when tmux lost the whole session (#467).
- 00f022c: Terminal: when a burst of output overflows the render queue and a frame has to be dropped, the repaint that repairs it is now retried until it actually happens, instead of being scheduled once and silently skipped when another load was in flight (#470).
- 13e652e: Mobile: a long press on blank terminal space on Android Chrome no longer opens the keyboard and blanks the terminal (#471, fixes #360). The long-press guards are now armed before the press is checked for selectable text, so a press on empty space is swallowed the same way a press on a word already was.
- 13e652e: Sessions: a tab whose agent has exited (the CLI quit, but tmux kept the pane) now says so with a muted dot and an `exited (137)` badge, instead of looking like an idle session (#466, part 1 of #446). The state is published as `paneExit` on the session and survives a restart. Nothing closes such sessions yet; that is part 2.
- 13e652e: Docker: optional GitHub CLI and Azure CLI for private repositories (#472). Both are off by default. With `CODEMAN_INSTALL_GH=1` / `CODEMAN_INSTALL_AZ=1` as build args in `docker-compose.override.yml`, the server image gets `gh` and/or `az` (with the `azure-devops` extension) wired in as git credential helpers, so after one `gh auth login` or `az login` from a shell session, Add Case → Clone Repo can clone private GitHub and Azure DevOps repositories. `CODEMAN_AGENT_IMAGE_INSTALL_GH` / `_AZ` do the same for the Docker-case agent image, and only then are the sign-ins copied into new case containers. In multi-user mode a non-admin's clone runs with the credential helpers cleared. This changes `server.Dockerfile`, so Compose deployments need a `Start-Codeman.sh` rebuild rather than an in-app update.
- 13e652e: Run menu: the Gemini, Antigravity and OMP run buttons now show their own colours on every skin; they rendered in Claude blue on all skins except OG (#463). The CLI registry's `accent` values were also corrected to the colours the UI really paints, and a test now guards the stylesheet trap that caused it.
- 13e652e: Terminal: five ways the browser terminal could silently stop being correct are fixed (#431, #464). The browser terminal and the PTY can no longer disagree about their width, which is what produced doubled lines and half-overwritten text ("text gets muffled sometimes"): there is now one function that sizes the terminal, and every resize is answered with the geometry the PTY really holds. A replay clear goes through the terminal's own queue, so bytes written just before it no longer fuse into the next snapshot. A renderer that stops painting after an iOS PWA is backgrounded heals itself instead of needing a reload. Every terminal capture has a deadline that also covers the response body, and a capture that runs out of time during a tab switch falls back to the bounded tail instead of leaving a blank pane. Output lost to a half-open WebSocket is repainted on the next successful open. The service worker's precache list is now generated by the build and its cache is rotated per build, so old releases' assets no longer pile up.
- 13e652e: Docker: new `docker/Update-Codeman.sh` for the major-update path the docs used to describe by hand (#465). It rebuilds the image with `--no-cache` before taking the stack down, clears the build-artefact volumes, refuses to run when another checkout's Compose project already owns the same name, and then hands over to `Start-Codeman.sh`.
- 13e652e: Approvals: a session that is idle only because it is waiting on its own background work (Claude Code's `1 monitor` footer chip, or a Codex background terminal) no longer raises the yellow NEEDS YOU alert or a push (#473, fixes #468). Its idle item is opened already acknowledged, and the tab, the home screens and the rail show a small `watching` badge next to the state instead. The item still exists in the Approvals Inbox, and the TUI's pending count now leaves acknowledged items out.
- b404dac: Maintainer fixes applied while landing this batch:
- Terminal (#431): while another device holds the pane's width, a resize retry no longer re-fits xterm to the container and re-wraps the whole buffer every 30 s, and no longer clears scrollback for a redraw that never comes. The PTY's spawn geometry is now recorded at attach, so `ptyGeometry` never reports a size the PTY never held.
- Terminal (#470): the `TERMINAL DROP` crash-trail line is logged once per recovery window instead of once per dropped frame (which wiped the rest of the trail within a second), and a refresh that died at its fetch deadline is no longer retried.
- Sessions (#467): the resume pin also covers the branch where tmux lost the whole session, the conversation id Codeman reports follows what the relaunch actually resumed, and the test setup strips `CLAUDE_CONFIG_DIR` so the suite stays green for anyone running a separate Claude config dir.
- Sessions (#466): detailed sidebar and rail rows show an `exited` pill instead of `idle`, the exit is announced to screen readers, and the user manual's tab-appearance table lists the new state.
- Approvals (#473): a failed pane capture clears the `watching` badge rather than keeping a stale one (a failure now falls toward an alert, not toward silence), and the header bell's count leaves acknowledged items out, matching the TUI.
- Run menu (#463): the Gemini and Antigravity run buttons no longer render two-tone on phones, Gemini's registry accent matches its tab badge, and a test now guards the stylesheet trap for every run mode.
- Docker (#465): `Update-Codeman.sh` removes exactly the two build-artefact volumes it names instead of every named volume in the project, reports a failing `docker compose` instead of exiting silently, and its docs and comments were corrected. (#472): the multi-user notes say that a non-admin's seeded Docker case also receives the gh/az sign-in when those switches are on.
### Thanks
- @irisitymichaelgrundberg for four PRs in this release: the `watching` badge that stops background work from raising false alerts (#473, from their own report #468), the exited-agent badge (#466) and the dead-pane resume fix (#467), both from their report #446, and the transcript-gutter strip for copied text (#469), a follow-up to their #451.
- @rounakdatta for the terminal resilience work (#431) and the dropped-frame recovery (#470), both from their report #464, and for answering four rounds of review in full.
- @opticon454 for private-repository support in the Docker images (#472), the `Update-Codeman.sh` script (#465) and the run-button colour fix (#463).
- @DodgyBadger for the Android long-press fix (#471), from their own report #360.
## 1.32.0
### Minor Changes
- d47f93a: feat(custom-model): the model picker puts the ready model first
When a custom endpoint has more than one model, the Run menu's picker now promotes one row to the top instead of showing raw discovery order: the model llama-swap reports loaded and ready right now (tagged "Currently loaded", the one a launch attaches to with zero wait), else the model you last launched on that harness and endpoint (tagged "Last used", remembered per device). The endpoint's default keeps its own pill, nothing is ever auto-chosen, and a plain OpenAI-compatible server or an endpoint that does not answer within a second simply keeps the old order. The probe is bounded on the client too, so a GPU box that is off no longer holds the picker closed for five seconds.
- d47f93a: feat(split-pane): view two live sessions side by side
A new Split button in the header (opt-in in App Settings, off by default, desktop only at 1180px and wider) opens a picker and shows a second live session beside the active one: its own terminal, its own WebSocket, and a divider you can drag. When either session ends the view collapses back to one pane, with Pane B promoted to the primary when it is Pane A that ended. Nothing is persisted on purpose in this first cut, so a page reload always returns to a single pane. Pane B is deliberately plainer than the primary pane (no local-echo overlay, CJK input, touch handling or keyboard accessory bar); the design and the v2 boundaries are in discussion #452.
- 72d437a: Installer v2. `curl -fsSL https://getcodeman.com/install | bash` now looks at the machine first, asks at most three questions up front (how the dashboard is reached, optionally what to call the machine on your tailnet, whether to run Codeman as a background service), does the install unattended behind progress spinners with the output in `~/.codeman/install.log`, and ends on the URL with a QR code to scan. One consent covers every missing package and sudo asks for your password once. Flags pipe through `bash -s --` (`--tailscale | --lan | --local`, `--name <n> | --no-rename`, `--service | --run | --no-start`, `--yes`, `--password`, `--port`), `install.sh status` prints the URL and the QR code again, and the cloudflared question moved out of the main flow into `install.sh cloudflared`. On the Tailscale route, a `:443` that already belongs to another app gets Codeman under `https://<node>/codeman` (or on a second port) instead of a dead end, the node can be renamed opt-in (`--name`, `install.sh name`, undone by uninstall), and the HTTPS-certificates toggle is polled with the admin page opened for you. Also fixed on the way: the installer's own `npm install` no longer lets the postinstall start a stray server on port 3000 (the service crash-looped on EADDRINUSE while the done screen said "running"), the LAN address comes from the default route rather than the first interface, a hand-written LaunchDaemon on a headless Mac is left alone, a flag re-run keeps an existing dashboard password, and the done screen's start command carries the sub-path and port it was installed with.
- d47f93a: feat(mobile): a Compose key for writing prompts on a phone
The agent keyboard bars on phones replace their Paste key with Compose: a real multiline editor with autocorrect and spellcheck, per-session drafts kept in memory only, image attach that never writes into the terminal early, and a Send that delivers the text as one paste followed by Enter, so a long prompt no longer has to be typed blind into the terminal composer. Anything you had already typed into the terminal is picked up into the editor. Shell sessions keep the direct Paste key. This is the manual first slice from #359; the auto-open setting and terminal tap routing are a separate follow-up.
### Patch Changes
- d47f93a: refactor(run-menu): one table-driven launcher for every external CLI
The eight near-identical per-CLI launch functions in the Run menu collapsed into one launcher driven by a table that a CI test keeps in step with the CLI registry, and a second no-id-branching guard now covers the frontend the way the backend guard covers the server. No behaviour change: the refactor was verified byte-identical across 288 launch permutations against the previous code.
- e899af4: Maintainer fixes applied while landing the above. The model picker's promoted row keeps its Default pill (the promotion tag and the default marker are two pills now, and they render as pills in the picker rather than as plain text). The phone composer keeps its bottom gutter on folding devices (the generic fold rule used to erase it), a whitespace-only draft is no longer sent, and its dialog is translated on a zh-CN UI. A split that collapses mid-drag no longer leaves the page stuck in resize-cursor mode, Pane B refuses a session that has no live process, and a burst of refresh frames replays once instead of twice. The `</head>` script injections on the page render use replacer functions, so a CLI label containing `$'` can no longer splice the document into the inline script, and the frontend no-id-branching guard now catches comparisons on any variable name.
- 6ef71ec: ### Thanks
- @timkjr for split-pane sessions (#453): five review rounds turned around in two days, and the pointer-capture edge case measured in a real browser rather than reasoned about.
- @DodgyBadger for the mobile prompt composer (#444), a first contribution that took the scope back down to one slice when asked, and that verified the delivery path against a live tmux pane and a live Claude Code composer instead of trusting the diff.
- @opticon454 for putting the ready model first in the picker (#459) and for collapsing the eight Run-menu launch functions into one (#458), proven byte-identical across 288 launch permutations instead of argued.
## 1.31.0
### Minor Changes
+86 -68
View File
File diff suppressed because one or more lines are too long
+7 -6
View File
@@ -61,12 +61,13 @@ The installer asks before every system change, and re-running the same line upda
curl -fsSL https://getcodeman.com/install | bash
```
This installs Node.js, tmux and a build toolchain if missing (node-pty ships no Linux prebuilds, so it compiles from source), clones Codeman to `~/.codeman/app`, and builds it. A few things worth knowing:
This installs Node.js, tmux and a build toolchain if missing (node-pty ships no Linux prebuilds, so it compiles from source), clones Codeman to `~/.codeman/app`, and builds it. It looks at what is already on the machine, asks at most three questions, then does all the work unattended and ends on the URL with a QR code for your phone. A few things worth knowing:
- **It asks first.** Every system change (package installs, AI CLI download) is prompted, and a menu at the end lets you choose: run Codeman in this terminal, install it as a background service (systemd/launchd, auto-start on boot), or don't start yet. Nothing runs in the background unless you pick it.
- **How it's reachable, your choice.** The installer offers three ways to reach the dashboard: **Tailscale** (loopback bind fronted by `tailscale serve`, so you get `https://<machine>.<tailnet>.ts.net` with a real certificate and your tailnet as the login, no password needed), **any device on your network** (`0.0.0.0`, with a strongly recommended password prompt), or **this machine only** (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. The highlighted default reflects what is already on the machine (Tailscale when it is already in use, your existing binding on a re-run), and a bare Enter never pulls in new software. A bare `codeman web` started by hand still defaults to loopback.
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh update` and `install.sh uninstall` also exist.
- **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
- **Three questions, all up front.** How the dashboard is reached, optionally what to call this machine on your tailnet, and whether to run Codeman as a background service (systemd/launchd, auto-start on boot; Enter says yes). Everything that needs you, including one consent for all missing packages, one sudo password, and the Tailscale login, happens before the build, so you can walk away while it compiles.
- **How it's reachable, your choice.** **Tailscale** (loopback bind fronted by `tailscale serve`, so you get `https://<machine>.<tailnet>.ts.net` with a real certificate and your tailnet as the login, no password needed), **any device on your network** (`0.0.0.0`, with a strongly recommended password prompt), or **this machine only** (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. The highlighted default reflects what is already on the machine (Tailscale when it is already connected, your existing binding on a re-run), and a bare Enter never pulls in new software. If another app already owns `:443` on your node, Codeman goes under `https://<machine>.<tailnet>.ts.net/codeman` or on a second port instead of replacing it. A bare `codeman web` started by hand still defaults to loopback.
- **The name is yours to choose.** By default the URL uses the machine's existing tailnet name. Answering yes to the second question renames the machine to `codeman-<hostname>` (which also renames it for SSH, so the default is no); `install.sh name` does it later.
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh status` prints the URLs and the QR code again; `install.sh update`, `install.sh tailscale` and `install.sh uninstall` also exist.
- **Flags for the impatient.** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` answers the questions from the command line (`--lan`, `--local`, `--run`, `--no-start`, `--name <n>`, `--port <n>`, `--yes` too). **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently; set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the nine is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
@@ -219,7 +220,7 @@ codeman web --https
# Open on your phone: https://<your-ip>:3000
```
> `localhost` works over plain HTTP. Use `--https` when accessing from another device, or use [Tailscale](https://tailscale.com/) (recommended): the installer can set it up for you (choose **Tailscale** at the network-access prompt, or run `bash ~/.codeman/app/install.sh tailscale` on an existing install). That gives you `https://<your-machine>.<tailnet>.ts.net` with a real certificate: private to your tailnet, no password required, and PWA install + push notifications work on your phone.
> `localhost` works over plain HTTP. Use `--https` when accessing from another device, or use [Tailscale](https://tailscale.com/) (recommended): the installer can set it up for you (choose **Tailscale** at the network-access prompt, or run `bash ~/.codeman/app/install.sh tailscale` on an existing install). That gives you `https://<your-machine>.<tailnet>.ts.net` with a real certificate: private to your tailnet, no password required, and PWA install + push notifications work on your phone. The installer ends on that URL with a QR code to scan, and `bash ~/.codeman/app/install.sh status` prints it again any time.
### Secure QR Code Authentication
+3
View File
@@ -30,6 +30,9 @@ export const BROWSER_TEST_GLOBS = [
'test/capture-load-window.browser.test.ts',
'test/capture-geometry-retry.browser.test.ts',
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
'test/split-pane-terminal.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
];
/**
+8
View File
@@ -50,6 +50,14 @@ CODEMAN_USERNAME=admin
# README.md, "Reverse-proxy host allowlist".
# CODEMAN_ALLOWED_HOSTS=codeman.example.com,.internal.example.com
# The GitHub CLI (gh) and the Azure CLI (az, with the azure-devops extension)
# can be built into the images as git credential helpers, so Codeman can clone
# private GitHub and Azure DevOps repositories. Both are OFF by default and are
# NOT set here: turn them on in docker-compose.override.yml with the build args
# CODEMAN_INSTALL_GH / CODEMAN_INSTALL_AZ and, for the Docker-case agent image,
# the environment variables CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ. See
# README.md, "Private repositories".
# Optional: authenticate Gemini CLI without an interactive login.
GEMINI_API_KEY=
+88
View File
@@ -41,6 +41,94 @@ Releases that change `server.Dockerfile`, `docker-compose.yaml`, or add a key to
changed, and asks you to run `Start-Codeman.sh` here on the host instead. Details:
[`../docs/docker-self-update.md`](../docs/docker-self-update.md).
### Major updates
`Start-Codeman.sh` rebuilds the image on every start, but with the layer cache,
and it refreshes the build-artefact volumes selectively: `codeman-dist` when
the checkout's HEAD moved, `codeman-node-modules` only when `package-lock.json`
changed. That is right for an ordinary `git pull`. It is not enough when a
`server.Dockerfile` change bumps the Node base image without touching the
lockfile: `node-pty` is compiled from source (there is no Linux prebuild), so
the old `codeman-node-modules` volume would keep a build made for the previous
Node version. For that case, or whenever you want to be certain of what ships,
`docker/Update-Codeman.sh` force-rebuilds the image with no layer cache, stops
the stack, removes the `codeman-node-modules` and `codeman-dist` volumes, then
hands off to `Start-Codeman.sh` for the usual start:
```sh
bash docker/Update-Codeman.sh
```
Pass `--keep-volumes` to skip clearing them (safe only if you know the
rebuilt image's `node_modules`/`dist` did not change). The scripted default
is the "Resetting the build artefacts" procedure in
[`../docs/docker-self-update.md`](../docs/docker-self-update.md). Only those
two volumes are removed, by name within this Compose project; any volume a
`docker-compose.override.yml` adds is left alone, and application data and
case workspaces are host bind mounts, never touched either way.
## Private repositories (GitHub and Azure DevOps)
The images can include the GitHub CLI (`gh`) and the Azure CLI (`az`, with the `azure-devops` extension), wired into the system Git configuration as credential helpers, so Codeman can clone private repositories. Both are **opt-in and off by default**, and are turned on per host in `docker-compose.override.yml`.
### Turning them on
Add the build arguments to `docker-compose.override.yml` (see [Local customisation](#local-customisation)), then rebuild with `Start-Codeman.sh`. Set only the one you need:
```yaml
services:
codeman:
build:
args:
CODEMAN_INSTALL_GH: '1'
CODEMAN_INSTALL_AZ: '1'
environment:
# The same two switches for the Docker-case agent image Codeman builds.
CODEMAN_AGENT_IMAGE_INSTALL_GH: '1'
CODEMAN_AGENT_IMAGE_INSTALL_AZ: '1'
```
The `build: args:` pair controls the Codeman server image. The `environment:` pair controls the agent image for [Docker cases](../docs/docker-cases.md), which Codeman builds on the first Docker case; an agent image that already exists is not rebuilt by this, so run `node scripts/build-agent-image.mjs --no-cache` inside the container afterwards. The same variables work in front of that command when building it by hand. Values must be `0` or `1`; anything else stops the build with an error naming the argument.
They are not `.env` settings: turning a CLI on is a per-host choice, which is what the override file is for, and a new `.env.example` key makes the in-app updater refuse to update every existing installation until its `.env` gains the key.
The Azure CLI is the large one, about 600 MB of the roughly 670 MB the pair adds. A CLI left off leaves nothing functional behind: no apt repository, no package, no `azure-devops` extension and no credential-helper entry, so git for that host behaves exactly as it does without this feature. With both off the image is functionally unchanged; it still carries the `AZURE_EXTENSION_DIR` variable, an empty extensions directory and one small layer that copies and then removes the helper script.
### Signing in
With a CLI on, the system Git configuration routes credentials through it:
| Host | Credential helper | Sign in with |
| ----------------------------------------------------- | ----------------------------------------- | ---------------------------- |
| `https://github.com`, `https://gist.github.com` | `gh auth git-credential` | `gh auth login` |
| `https://dev.azure.com`, `https://*.visualstudio.com` | `/usr/local/bin/git-credential-azure-cli` | `az login --use-device-code` |
Codeman itself still collects no Git credentials. Sign the container in once from a **Terminal / Shell** session (Run menu). The session runs as the runtime account, so the sign-in is stored under `CODEMAN_APPDATA_PATH` (`~/.config/gh`, `~/.azure`) and survives rebuilds and container recreation:
```sh
gh auth login # GitHub.com -> HTTPS -> "Login with a web browser" (device code)
az login --use-device-code # then: az devops configure --defaults organization=https://dev.azure.com/<org>
```
After that, **Add Case → Clone Repo** accepts private `https://` URLs on those hosts, and `git clone` works from any session. Until a CLI is signed in its helper prints nothing, so a private clone fails immediately with the usual authentication error rather than waiting on a prompt.
**Multi-user mode:** every Codeman user's git runs as the same server account, so these sign-ins would otherwise be shared. Clone Repo therefore runs a **non-admin**'s clone and preflight with every git credential helper cleared (`git -c credential.helper=`): a non-admin can clone public repositories and anything their own SSH setup allows, but not a private https repository through the admin's `gh`/`az` sign-in. Admins, and single-user mode, keep the helpers. A non-admin's own agent sessions still run as that same account, and with the agent-image `gh`/`az` switches on, a non-admin's Docker case with credential seeding on also receives the server account's `gh`/`az` sign-in, the same as the Claude and Codex credentials; see `docs/security-architecture.md`, multi-user mode.
Azure DevOps is authenticated with an Entra ID access token that the helper requests from `az` for each Git operation, so nothing is written to disk beyond `az`'s own sign-in. An account that has to use a personal access token can set `AZURE_DEVOPS_EXT_PAT` for the container instead (for example under `environment:` in `docker-compose.override.yml`); the helper prefers it when present. SSH remotes are unaffected by any of this and keep using the account's own keys.
Docker cases copy these sign-ins into a case container only when the matching agent-image switch is on (`CODEMAN_AGENT_IMAGE_INSTALL_GH=1` for `~/.config/gh/hosts.yml` and `config.yml`, `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` for the sign-in files from `~/.azure`) and the case has credential seeding on. With a switch off they are never copied, even when the files exist, because a GitHub token or an Azure refresh token is usable by anything in the container. The copies are made when the container is **created**, so an existing case container never picks them up: after turning a switch on, signing in, or rebuilding the agent image, **recreate the case container** (remove it; the next session in that case creates a fresh one).
The GitHub agent skill for `gh` installs into the runtime account's home in the same session:
```sh
gh skill install cli/cli gh --scope user
gh skill update gh # after a later gh release
```
### Versions
Both CLIs, and the extension, are installed from their vendors' repositories with no version pinned, so they arrive at whatever is current when that build step runs. Docker caches the step, though: `Start-Codeman.sh` rebuilds with the cache, which keeps the versions from the first build until the Dockerfile changes at or above that step or the image is rebuilt with `--no-cache`. They are apt packages owned by root, so they cannot be upgraded from a session; `az extension update --name azure-devops` is the exception and works without a rebuild.
## Local customisation
Compose merges `docker-compose.override.yml` on top of `docker-compose.yaml`. Keep host-specific changes there rather than editing `docker-compose.yaml`, so this repository can be updated without losing them. Both `docker-compose.override.yml` and `docker-compose.override.yaml` are ignored by Git.
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env bash
#
# The scripted major-update path for the Docker Compose deployment.
#
# docker/README.md and docs/docker-self-update.md both point operators here for
# anything the in-app updater itself refuses to apply: a changed
# `server.Dockerfile`, a changed `docker-compose.yaml`, or a new required
# `.env.example` key. None of those can be applied by a container restarting
# itself — a restart reuses the existing image and configuration (see "The
# environment gate" in docs/docker-self-update.md) — so this script does the
# three things an in-place update cannot: force a real image rebuild with no
# layer cache, stop the stack, then hand off to Start-Codeman.sh for the same
# careful PUID/PGID, override-file and fingerprint handling every other start
# goes through.
#
# ⚠️ Build BEFORE stopping the stack, deliberately, same reasoning as
# Start-Codeman.sh's own build-then-down ordering: the build needs nothing
# stopped, so a slow --no-cache rebuild costs no downtime, and a build failure
# (a bad Dockerfile edit, a network blip pulling a base image) leaves the
# ALREADY-RUNNING stack untouched instead of stopped with nothing to bring it
# back.
#
# ⚠️ Clears the codeman-node-modules/codeman-dist named volumes by DEFAULT.
# Docker seeds a named volume from the image only while that volume is EMPTY,
# so a rebuilt image's fresh node_modules/dist otherwise sit unused behind a
# volume's old content and the container comes back up looking unchanged —
# exactly wrong for a script whose whole point is "be certain of what ships".
# Start-Codeman.sh clears codeman-dist when the checkout's HEAD moved and
# codeman-node-modules only when `package-lock.json` changed. A released
# server.Dockerfile change arrives through `git pull`, so HEAD moves and dist
# is refreshed, but a Dockerfile change that bumps the Node base image leaves
# the lockfile untouched while every native module (node-pty is compiled from
# source, there is no Linux prebuild) has to be rebuilt against the new Node
# ABI. Start-Codeman.sh would keep the old codeman-node-modules volume, and it
# never builds with --no-cache. This script clears BOTH volumes, and ONLY
# those two (targeted `docker volume rm` by Compose label, never
# `down --volumes`, which would also take any volume an override file adds).
# Pass --keep-volumes to opt out and reuse whatever is already in them.
#
# Usage: docker/Update-Codeman.sh [--keep-volumes]
# --keep-volumes Do not clear codeman-node-modules/codeman-dist. Safe to
# combine with a source change Start-Codeman.sh's own
# detection would have cleared anyway; unsafe if the reason
# you are here is a change to server.Dockerfile alone.
set -euo pipefail
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
env_file="$script_dir/.env"
compose_file="$script_dir/docker-compose.yaml"
keep_volumes=0
for arg in "$@"; do
case "$arg" in
--keep-volumes)
keep_volumes=1
;;
--help | -h)
printf 'Usage: bash %s [--keep-volumes]\n' "$0"
exit 0
;;
*)
printf 'Error: unrecognised argument: %s\n' "$arg" >&2
printf 'Usage: bash %s [--keep-volumes]\n' "$0" >&2
exit 1
;;
esac
done
if [[ ! -f "$env_file" ]]; then
printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2
printf 'Create it from %s/.env.example before running this script.\n' "$script_dir" >&2
exit 1
fi
# Same override-file discovery as Start-Codeman.sh, and deliberately kept in
# step with it: a stack built here and started there must resolve to the exact
# same Compose files, or this script's build could target a configuration the
# handoff's own `up` never actually uses. Compose's own precedence (measured on
# v5.5.0 with both present: it uses .yml and ignores .yaml).
override_yml="$script_dir/docker-compose.override.yml"
override_yaml="$script_dir/docker-compose.override.yaml"
if [[ -f "$override_yml" && -f "$override_yaml" ]]; then
printf 'Warning: both %s and %s exist; Compose uses .yml and ignores .yaml.\n' \
"$override_yml" "$override_yaml" >&2
fi
compose_files=(-f "$compose_file")
for override_file in "$override_yml" "$override_yaml"; do
if [[ -f "$override_file" ]]; then
compose_files+=(-f "$override_file")
printf 'Using Compose override file: %s\n' "$override_file"
break
fi
done
compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}")
# Collision guard. Start-Codeman.sh has no equivalent; this is the only one,
# and it has to run before this script's own --no-cache build, `down` and
# volume removal below. docker-compose.yaml hard-codes `name: codeman`, so a
# second checkout run without COMPOSE_PROJECT_NAME resolves to the SAME Compose
# project as any other checkout on the host and would operate on ITS
# containers and volumes.
#
# The project name is read from the resolved config's top-level `name` key
# (the first `name` in the output; nested ones come later), the same parse
# Start-Codeman.sh uses. `--format json` needs Compose v2.3+. This is the first
# `docker` call the script makes, so its failure is reported here rather than
# left to `set -e`, which would exit with no output at all.
if ! project_config=$("${compose_command[@]}" config --format json); then
printf 'Error: `docker compose config --format json` failed (see the message above, if any).\n' >&2
printf 'Check that Docker and Compose v2.3+ are installed and on PATH, and that\n' >&2
printf '%s and the Compose files in %s are valid.\n' "$env_file" "$script_dir" >&2
exit 1
fi
project_name=$(
printf '%s\n' "$project_config" |
sed -n 's/^[[:space:]]*"name":[[:space:]]*"\([^"]*\)".*$/\1/p' | head -n1
)
if [[ -n "$project_name" ]]; then
# `|| true` on the pipeline's LAST command: under `set -o pipefail`, `grep -v`
# exits 1 when nothing survives the filter — the ordinary, no-collision case,
# since `docker ps` finds nothing at all on a first-ever deployment or a
# single matching (own) working_dir gets filtered out. Without it, that exit
# status propagates through the command substitution and `set -e` aborts the
# WHOLE script right here, every time, regardless of whether a collision
# actually exists — caught only by actually running this end-to-end (a
# static text/regex check on the source cannot see it). The empty-line
# filter keeps a container with no working_dir label from winning head -n1
# and hiding a real collision behind it.
other_working_dir=$(
docker ps -a --filter "label=com.docker.compose.project=$project_name" \
--format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null |
grep -v -F -x -- "$script_dir" | grep -v '^$' | head -n1 || true
)
if [[ -n "$other_working_dir" ]]; then
printf 'Error: Compose project "%s" is already in use by a DIFFERENT checkout:\n' "$project_name" >&2
printf ' %s\n' "$other_working_dir" >&2
printf 'This checkout is:\n' >&2
printf ' %s\n' "$script_dir" >&2
printf '\n' >&2
printf 'docker-compose.yaml hard-codes `name: %s`, so two checkouts on the same host\n' "$project_name" >&2
printf 'collide unless each one sets a distinct COMPOSE_PROJECT_NAME. Continuing would\n' >&2
printf 'rebuild and stop the OTHER checkout'"'"'s running container and, by default,\n' >&2
printf 'delete its codeman-node-modules/codeman-dist volumes.\n' >&2
printf '\n' >&2
printf 'Fix: export COMPOSE_PROJECT_NAME=<something-unique-to-this-checkout> before\n' >&2
printf 'running this script, then retry.\n' >&2
printf '\n' >&2
printf 'If instead THIS checkout was moved or renamed after its container was created,\n' >&2
printf 'the path above is its own old location: remove the old container (for example\n' >&2
printf '`docker rm -f <container>` for the codeman container) and retry, rather than\n' >&2
printf 'setting COMPOSE_PROJECT_NAME, which would start a second project beside it.\n' >&2
exit 1
fi
fi
# Same owner-detection Start-Codeman.sh uses to derive PUID/PGID for its own
# build — without it, the --no-cache build below gets Compose's untouched
# default of 1000:1000, and on any host whose appdata owner differs (99:100 on
# Unraid, per docker/README.md's chown example), Start-Codeman.sh's own
# correctly-PUID'd build during the handoff then rebuilds those layers with the
# right values anyway — so the "no cache, certain of what ships" image this
# script produces is not the one that actually ends up running.
#
# Deliberately NOT the same as Start-Codeman.sh's own handling of a MISSING
# appdata directory (which creates it): this script updates an EXISTING
# deployment, so a missing appdata path means there is nothing here yet to
# update, and creating one would just be this script quietly doing
# Start-Codeman.sh's first-run job worse.
appdata_path=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
)
if [[ -z "$appdata_path" || ! -d "$appdata_path" ]]; then
printf 'Error: CODEMAN_APPDATA_PATH is not set or does not exist: %s\n' "${appdata_path:-<unset>}" >&2
printf 'Run docker/Start-Codeman.sh first to set up a new deployment.\n' >&2
exit 1
fi
# `stat -c` is GNU, `stat -f` is BSD/macOS; the bind source lives on the Docker
# host, so both need to work. Identical to Start-Codeman.sh's own helper.
owner_of() {
stat -c '%u:%g' -- "$1" 2>/dev/null || stat -f '%u:%g' "$1" 2>/dev/null
}
if ! owner_ids=$(owner_of "$appdata_path"); then
printf 'Error: Cannot determine the owner of CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2
exit 1
fi
export PUID=${owner_ids%%:*}
export PGID=${owner_ids##*:}
if [[ "$PUID" == '0' ]]; then
printf 'Error: CODEMAN_APPDATA_PATH is owned by root: %s\n' "$appdata_path" >&2
printf 'Change the directory ownership to the unprivileged account that should run Codeman.\n' >&2
exit 1
fi
# --no-cache, always: a plain `build` reuses cached layers (npm install, apt
# packages, the CLI installs baked into the image) and can silently keep them
# frozen at whatever they were the day the cache was populated — exactly wrong
# for a major update, whose whole point is being certain of what actually
# ships. `scripts/build-agent-image.mjs` makes the same call for the same
# reason (see its entry in CLAUDE.md's Additional Commands table). Runs BEFORE
# the stack is stopped — see the header comment for why.
printf 'Building a fresh image (--no-cache)...\n'
"${compose_command[@]}" build --no-cache
printf 'Stopping the stack...\n'
if [[ "$keep_volumes" == '1' || -n "$project_name" ]]; then
"${compose_command[@]}" down
else
# No resolvable project name means the label filter below could match
# nothing, so fall back to Compose's own removal, and say what it really does.
printf 'Warning: could not resolve the Compose project name; clearing EVERY named volume\n' >&2
printf 'in this Compose project (override file included) with `down --volumes` instead.\n' >&2
"${compose_command[@]}" down --volumes
fi
# Targeted removal of exactly the two build-artefact volumes, scoped by label to
# THIS project (the volume key alone is shared by any other stack declaring the
# same key). Same lookup as Start-Codeman.sh's refresh. A failure is reported,
# not fatal: the stack is already down, and the handoff below is what brings
# it back up.
if [[ "$keep_volumes" != '1' && -n "$project_name" ]]; then
printf 'Clearing the codeman-node-modules/codeman-dist volumes (pass --keep-volumes to skip).\n'
for key in codeman-node-modules codeman-dist; do
volume_name=$(
docker volume ls -q \
--filter "label=com.docker.compose.volume=$key" \
--filter "label=com.docker.compose.project=$project_name" |
head -n1
) || volume_name=''
if [[ -n "$volume_name" ]] && ! docker volume rm -- "$volume_name"; then
printf 'Warning: could not remove volume %s; the container may keep serving the\n' "$volume_name" >&2
printf 'previous build from it. Remove it by hand and rerun this script.\n' >&2
fi
done
fi
# Start-Codeman.sh does everything a plain `up -d` does not: re-derives
# PUID/PGID, pre-creates CODEMAN_CASES_PATH with the right ownership, resolves
# DOCKER_SOCKET_GID, records the server.Dockerfile/docker-compose.yaml
# fingerprint the in-app updater's gate reads on every future update, and
# starts the (already freshly built) image. Reimplementing any of that here
# would only risk drifting out of step with it — hand off instead, exactly as
# docs/docker-self-update.md's own reset procedure does.
#
# ⚠️ `bash`, not a bare exec of the path: Start-Codeman.sh is committed
# non-executable (100644), the same as this script, and is documented
# everywhere as `bash docker/Start-Codeman.sh` rather than
# `./docker/Start-Codeman.sh` — execing the bare path fails with EACCES.
printf 'Handing off to Start-Codeman.sh...\n'
exec bash "$script_dir/Start-Codeman.sh"
+82
View File
@@ -26,6 +26,88 @@ RUN apt-get update \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
# GitHub CLI and Azure CLI (+ the azure-devops extension) with the same system
# git credential helpers as docker/server.Dockerfile, so an agent in a Docker
# case can clone and push to private GitHub / Azure DevOps repositories. The
# sign-ins themselves are NOT baked in: `~/.config/gh` and `~/.azure` are seeded
# per container at launch like every other CLI's credentials (CRED_STORES in
# src/docker-hosts.ts), and a helper whose CLI is not signed in prints nothing,
# so git fails fast instead of prompting. See server.Dockerfile for why the
# vendor apt repositories are configured here rather than via deb_install.sh.
#
# Each is OPT-IN and OFF by default, like the server image: CODEMAN_INSTALL_GH=1
# / CODEMAN_INSTALL_AZ=1 turn one on; off leaves no repository, package,
# extension or helper entry. scripts/build-agent-image.mjs and the in-app
# auto-build pass them from CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ in their own
# environment (for the Compose deployment: `environment:` in
# docker-compose.override.yml), and pass nothing when those are unset, so
# these defaults (off) apply.
ARG CODEMAN_INSTALL_GH=0
ARG CODEMAN_INSTALL_AZ=0
RUN set -eux; \
for flag in "CODEMAN_INSTALL_GH=${CODEMAN_INSTALL_GH}" "CODEMAN_INSTALL_AZ=${CODEMAN_INSTALL_AZ}"; do \
case "${flag#*=}" in 0|1) ;; *) echo "${flag%%=*} must be 0 or 1, got '${flag#*=}'" >&2; exit 1;; esac; \
done; \
codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")"; \
arch="$(dpkg --print-architecture)"; \
pkgs=""; \
install -d -m 0755 /etc/apt/keyrings; \
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \
curl -fsSL -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
https://cli.github.com/packages/githubcli-archive-keyring.gpg; \
chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg; \
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list; \
pkgs="${pkgs} gh"; \
fi; \
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
curl -fsSL -o /etc/apt/keyrings/microsoft.asc \
https://packages.microsoft.com/keys/microsoft.asc; \
chmod go+r /etc/apt/keyrings/microsoft.asc; \
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/microsoft.asc] https://packages.microsoft.com/repos/azure-cli/ ${codename} main" \
> /etc/apt/sources.list.d/azure-cli.list; \
pkgs="${pkgs} azure-cli"; \
fi; \
if [ -n "${pkgs}" ]; then \
apt-get update; \
apt-get install -y --no-install-recommends ${pkgs}; \
rm -rf /var/lib/apt/lists/*; \
fi; \
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then gh --version; fi; \
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then az version --output none; fi
# Outside HOME so the seeded `~/.azure` (auth files only) never has to carry
# extensions. gid 0 + group-writable, the same arbitrary-uid convention as HOME
# below, so `az extension update` works as whatever uid the container runs as.
# Created even without az; an empty directory costs nothing.
ENV AZURE_EXTENSION_DIR=/opt/az-extensions
RUN set -eux; \
install -d -m 0755 "${AZURE_EXTENSION_DIR}"; \
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
az extension add --name azure-devops --only-show-errors; \
rm -rf /root/.azure; \
fi; \
chgrp -R 0 "${AZURE_EXTENSION_DIR}"; \
chmod -R g=u "${AZURE_EXTENSION_DIR}"
# Only an installed CLI gets a helper entry (see server.Dockerfile).
COPY docker/git-credential-azure-cli /usr/local/bin/git-credential-azure-cli
RUN set -eux; \
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \
for host in https://github.com https://gist.github.com; do \
git config --system "credential.${host}.helper" '!/usr/bin/gh auth git-credential'; \
done; \
fi; \
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
chmod 0755 /usr/local/bin/git-credential-azure-cli; \
for host in https://dev.azure.com 'https://*.visualstudio.com'; do \
git config --system "credential.${host}.helper" /usr/local/bin/git-credential-azure-cli; \
git config --system "credential.${host}.useHttpPath" true; \
done; \
else \
rm -f /usr/local/bin/git-credential-azure-cli; \
fi
# The npm-published agent CLIs, supplied by scripts/build-agent-image.mjs from
# config/clis.stock.json so a new stock CLI needs no edit here. The default is
# today's literal list, so a bare `docker build` still produces the same image.
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# Git credential helper for Azure DevOps, backed by the signed-in Azure CLI.
#
# Configured in the image's system gitconfig for https://dev.azure.com and
# https://*.visualstudio.com (see server.Dockerfile). On `get` it answers with
# an Entra ID access token for the Azure DevOps resource as the password, the
# same token type Git Credential Manager uses for Azure Repos. It never prompts:
# when `az` is not signed in it prints nothing, so git fails fast with its own
# authentication error instead of hanging a request that has no terminal.
#
# AZURE_DEVOPS_EXT_PAT, the azure-devops extension's own PAT variable, is used
# instead when it is set, for accounts that authenticate with a PAT.
# `store` and `erase` are no-ops: the token belongs to az, which refreshes it.
[ "$1" = "get" ] || exit 0
# Drain the request git writes on stdin; the host scoping is in gitconfig.
cat >/dev/null
if [ -n "${AZURE_DEVOPS_EXT_PAT:-}" ]; then
printf 'username=pat\npassword=%s\n' "$AZURE_DEVOPS_EXT_PAT"
exit 0
fi
command -v az >/dev/null 2>&1 || exit 0
# 499b84ac-1321-427f-aa17-267ca6975798 is the fixed application ID of Azure
# DevOps: https://learn.microsoft.com/azure/devops/integrate/get-started/authentication/service-principal-managed-identity
token="$(az account get-access-token \
--resource 499b84ac-1321-427f-aa17-267ca6975798 \
--query accessToken --output tsv 2>/dev/null)" || exit 0
[ -n "$token" ] || exit 0
printf 'username=azure-cli\npassword=%s\n' "$token"
+106 -1
View File
@@ -68,6 +68,111 @@ COPY --from=docker:29-cli \
/usr/local/libexec/docker/cli-plugins/docker-buildx \
/usr/local/libexec/docker/cli-plugins/docker-buildx
# GitHub CLI and Azure CLI (with the azure-devops extension), so a user can sign
# this container in to GitHub and Azure DevOps from a Codeman shell session and
# then clone PRIVATE repositories, both from that session and through Add Case
# -> Clone Repo. Codeman still collects no Git credentials itself: the clone
# path (src/git-clone.ts) only inherits HOME and git's config, so whatever the
# user signs in to here is what authenticates, and nothing when they have not
# (the clone then fails fast with AUTH_REQUIRED, exactly as before).
#
# Each is OPT-IN and OFF by default: the image is functionally unchanged
# unless the build gets CODEMAN_INSTALL_GH=1 and/or CODEMAN_INSTALL_AZ=1, which
# a deployment sets under `build: args:` in docker-compose.override.yml
# (docker/README.md, "Private repositories"). Off installs no apt repository,
# package, extension or credential-helper entry; all that remains is the
# AZURE_EXTENSION_DIR variable, its empty directory and one layer that copies
# and then removes the helper script. The Azure CLI is the heavy one (~600 MB,
# mostly its bundled Python). The base docker-compose.yaml
# and .env deliberately do not carry them: turning a CLI on is a per-host
# choice, which is what the override file is for, and a new .env.example key
# would make the self-updater refuse existing installs until their .env gained
# it (docs/docker-self-update.md).
#
# Both come from their vendors' own apt repositories, the same ones the
# documented one-liners configure (https://github.com/cli/cli/blob/trunk/docs/install_linux.md
# and https://learn.microsoft.com/cli/azure/install-azure-cli-linux?pivots=apt).
# Microsoft's `deb_install.sh` is deliberately not piped into the build: it does
# exactly this plus a `gnupg` install, and a remote script run at build time is
# the one step a reviewer cannot read in this file. apt reads an ASCII-armoured
# `.asc` key directly, which is what keeps `gnupg` out of the image.
#
# Not pinned, unlike the agent CLIs below: nothing in Codeman depends on a
# particular gh or az behaviour, so the pinning argument there does not apply.
# The layer cache still keeps whatever version the first build fetched until a
# --no-cache rebuild.
ARG CODEMAN_INSTALL_GH=0
ARG CODEMAN_INSTALL_AZ=0
RUN set -eux; \
for flag in "CODEMAN_INSTALL_GH=${CODEMAN_INSTALL_GH}" "CODEMAN_INSTALL_AZ=${CODEMAN_INSTALL_AZ}"; do \
case "${flag#*=}" in 0|1) ;; *) echo "${flag%%=*} must be 0 or 1, got '${flag#*=}'" >&2; exit 1;; esac; \
done; \
codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")"; \
arch="$(dpkg --print-architecture)"; \
pkgs=""; \
install -d -m 0755 /etc/apt/keyrings; \
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \
curl -fsSL -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
https://cli.github.com/packages/githubcli-archive-keyring.gpg; \
chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg; \
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list; \
pkgs="${pkgs} gh"; \
fi; \
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
curl -fsSL -o /etc/apt/keyrings/microsoft.asc \
https://packages.microsoft.com/keys/microsoft.asc; \
chmod go+r /etc/apt/keyrings/microsoft.asc; \
echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/microsoft.asc] https://packages.microsoft.com/repos/azure-cli/ ${codename} main" \
> /etc/apt/sources.list.d/azure-cli.list; \
pkgs="${pkgs} azure-cli"; \
fi; \
if [ -n "${pkgs}" ]; then \
apt-get update; \
apt-get install -y --no-install-recommends ${pkgs}; \
rm -rf /var/lib/apt/lists/*; \
fi
# The azure-devops extension goes into a SYSTEM directory rather than the
# default ~/.azure/cliextensions: HOME is the application-data bind mount, which
# hides anything installed there at build time. The directory is handed to the
# runtime account below (next to /opt/codeman-cli) so `az extension update`
# works from a session. Nothing that runs as root executes from it. It is
# created even without az, so the chown below does not have to know.
ENV AZURE_EXTENSION_DIR=/opt/codeman-az-extensions
RUN set -eux; \
install -d -m 0755 "${AZURE_EXTENSION_DIR}"; \
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
az extension add --name azure-devops --only-show-errors; \
rm -rf /root/.azure; \
fi
# Git credential helpers, in the SYSTEM gitconfig so they apply to every
# account and survive a fresh application-data directory. Each one answers only
# for its own host and prints nothing when its CLI is not signed in, so git
# falls through to its normal non-interactive failure. Only an installed CLI
# gets an entry: a helper naming a missing binary would print an error on every
# clone from that host.
# github.com `gh auth git-credential`, what `gh auth setup-git` configures.
# Azure DevOps an Entra ID token from `az login` (git-credential-azure-cli),
# for both dev.azure.com and the legacy *.visualstudio.com hosts.
COPY docker/git-credential-azure-cli /usr/local/bin/git-credential-azure-cli
RUN set -eux; \
if [ "${CODEMAN_INSTALL_GH}" = 1 ]; then \
for host in https://github.com https://gist.github.com; do \
git config --system "credential.${host}.helper" '!/usr/bin/gh auth git-credential'; \
done; \
fi; \
if [ "${CODEMAN_INSTALL_AZ}" = 1 ]; then \
chmod 0755 /usr/local/bin/git-credential-azure-cli; \
for host in https://dev.azure.com 'https://*.visualstudio.com'; do \
git config --system "credential.${host}.helper" /usr/local/bin/git-credential-azure-cli; \
git config --system "credential.${host}.useHttpPath" true; \
done; \
else \
rm -f /usr/local/bin/git-credential-azure-cli; \
fi
# Keep credentials out of the image. Users authenticate these CLIs at runtime
# through Codeman sessions, and the configured host bind mount retains state.
#
@@ -153,7 +258,7 @@ RUN set -eux; \
--shell /bin/bash \
"${CODEMAN_RUNTIME_USER}"; \
fi; \
chown -R "${PUID}:${PGID}" /opt/codeman-cli
chown -R "${PUID}:${PGID}" /opt/codeman-cli /opt/codeman-az-extensions
WORKDIR /opt/codeman
File diff suppressed because one or more lines are too long
+42 -3
View File
@@ -36,7 +36,8 @@ interface CliEntry {
launch: CliLaunch; // the structured argv template
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
capabilities: CliCapabilities; // what every call site reads instead of the id
// .workDetect?: { promptGlyph, workingLine } — how this CLI's pane shows work
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines? } — how
// this CLI's pane shows work, and how it shows work it started in the background
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
}
```
@@ -45,10 +46,46 @@ interface CliEntry {
### Regexes that come from config
Two capability fields carry a regular expression an override file can set: `discovery.version.regex` and `capabilities.workDetect.workingLine`. Both go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
Three capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine` and `capabilities.workDetect.watchingLine`. All three go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
`watchingLine` reads a different row of the same screen. A CLI draws it while work the agent
itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for
agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that
into `Session.watching`, and an idle prompt from such a session opens already acknowledged,
so a pane waiting for its own background work never raises an alert a human cannot answer.
Group 1 is the label, and a CLI that declares no pattern reports no background work.
Two CLIs declare such a row today, and they put it in different places. Claude writes its
chip on the last row of the screen, so it keeps the default one-row window and anchors on
the `·` its footer joins items with. Codex pins
`1 background terminal running · /ps to view · /stop to close` ABOVE its composer, which
puts the row third from the bottom once the status line and the composer are counted, so its
entry declares `watchingLines: 3` and matches that row end to end. Both were measured
against live panes rather than read out of a binary, which is the standard for adding a
third.
That label is the one value in the registry that an AGENT can influence, because it comes off
the agent's own screen. Two things keep it honest, and both belong to whoever adds a pattern
for a new CLI. `watchingLabel()` in `session-activity.ts` searches only the last few
non-blank rows, which should be the part of the screen the CLI draws rather than the agent,
and the pattern should anchor on chrome only that CLI can produce. Keep the window as small
as the layout allows, since every row it adds is another row the agent may be able to write.
The label is also ANSI-stripped and length-capped at the source, and every interpolation of
it into markup goes through `escapeHtml()`, since it ends up on a badge and in an approval
card.
The two shipped entries do not sit equally well behind that rule, and the difference decides
what a pattern is allowed to do. Claude's chip is the last row, so its one-row window holds
nothing the agent can write — not even the status line above it, whose command a session
running with permissions bypassed can write into its own `.claude/settings.json`. Codex's row
shares its slot with the last row of the transcript whenever no terminal is running, so a
message ending in that exact line is matched. What keeps that harmless is `hooks: 'none'`: no
hook event from a codex session reaches the approvals inbox, so a forged label costs a wrong
badge and cannot silence an alert. Before giving a CLI both hook signals and a pattern, make
sure its row is one the agent cannot write.
### Three capabilities that must stay independent
`external`, `hooks` and `altScreen` describe three different, deliberately unequal sets, and deriving any one from another has already shipped a bug. `shell` has no hooks but is **not** an external CLI, so a hooks predicate written as `!isExternalCliMode()` accepted `until=stop` on a shell session and then blocked the caller for their entire timeout. `deepseek` is the mirror image: it IS external and it DOES have hooks.
@@ -102,6 +139,8 @@ It matches four shapes, not one: `mode === '<id>'`, `mode !== '<id>'`, `case '<i
The allowlist is not a formality. If a branch is about what a CLI can DO it belongs in `CliCapabilities`; the entries that remain are things that are not CLI-behaviour branches at all — chiefly the legacy per-mode `<Mode>Config` objects on `POST /api/sessions`, which are a fact about the public HTTP API rather than about any CLI, plus a few documented cases where `mode === 'claude'` is genuinely the right question (Read My Mind reads Claude's _own_ transcript, so a capability there would be actively wrong).
`test/frontend-cli-no-id-branching.test.ts` is the same guard for the two frontend files the CLI registry's Run-menu consolidation touches, `session-ui.js` and `mobile-overview.js` — deliberately not the rest of `src/web/public/`, whose per-CLI rules stay out of scope for now (see "Fields declared for later" below). Its allowlist keys on `<file>::<expression>` with no line number, since a single unrelated edit to a contended file would otherwise shift every subsequent line and make every entry go stale at once, and each entry additionally carries the exact number of approved call sites — a bare key would let a brand-new branch reusing an already-approved expression land unreviewed. Its comparison shape differs from the backend guard's in one respect: the left-hand side may be any identifier, not only one named `mode`, `id` or `agentType`, because the review of #458 found `const m = this._runMode; if (m === 'codex')` slipping past the named form while the scanned file already filters with `(m) => m !== 'shell'`.
## Two namespaces called `param`
`launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a **launch param**. The **legacy wire field** a param arrives as is a separate namespace, and `launch.legacyConfigAliases` is the only bridge between the two.
@@ -112,7 +151,7 @@ This matters because it is invisible when it is wrong. `capabilities.privilegedP
`shortBadge`, `accent`, `capabilities.echo`, `capabilities.wheelForward`, `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` are **declared but not yet read**. They all describe frontend behaviour, and the frontend is deliberately untouched here: `app.js`, `terminal-ui.js` and `styles.css` keep their own hand-authored per-CLI rules, and moving them is its own piece of work verified by a browser/mobile suite the CI gate cannot see.
Treat those values as **transcribed, not authoritative** — nothing enforces that `echo.policy` matches `_updateLocalEchoState`'s fallthrough, or that `accent` matches the gradient CSS paints, so re-measure before wiring one up. A field that is both wrong and unread is worse than an absent one, because the next reader trusts it; `test/cli-registry-no-id-branching.test.ts` pins the list so it cannot quietly grow, and wiring one up makes its line there fail, which is the direction you want.
Treat those values as **transcribed, not authoritative** — nothing enforces that `echo.policy` matches `_updateLocalEchoState`'s fallthrough, so re-measure before wiring one up. `accent` is the one exception: it was measured against styles.css on 2026-09-21 (method in the comment above `CLAUDE` in `stock.ts`), though nothing keeps it in step with the CSS either. A field that is both wrong and unread is worse than an absent one, because the next reader trusts it; `test/cli-registry-no-id-branching.test.ts` pins the list so it cannot quietly grow, and wiring one up makes its line there fail, which is the direction you want.
`overlays.credStore` is in the same category, for a sharper reason: the Docker credential-seeding path still reads its own `CRED_STORES` table, because this shape allows ONE store per CLI and the live table needs two for gemini (`.gemini` for the CLI's own auth plus `.config/gcloud` for Vertex), while deepseek declares none here even though `.dsh` is seeded. Wiring it means making the field an array and correcting those two entries — a change to credential seeding, which is simultaneously the worst thing here to get wrong and the least covered by tests, since every docker IO path is no-op'd under vitest.
+28
View File
@@ -183,6 +183,34 @@ launch, with the endpoint's `defaultModelId` marked but not auto-chosen —
the point of asking is letting one launch deliberately differ from the
saved default, not just confirming it.
The modal promotes exactly one row to the top of the list rather than
always showing raw discovery order, so the zero-wait choice is the one
under your thumb:
- **"Currently loaded"** — a model from this host's own list that
llama-swap reports `ready` right now, queried via
`GET /api/model-endpoints/:id/running-status`. Bounded client-side to
~800ms (`Promise.race`), on top of the route's own 5s server-side
timeout, so an endpoint that is asleep or firewalled cannot leave the
modal invisible for the full 5s after the Run menu has already closed.
- **"Last used"** — shown only when nothing is currently loaded: the model
actually launched last for this exact (harness, endpoint) pair, read
from the per-device `codeman:customModelLastUsed:<mode>:<endpointId>`
localStorage key. Written by `_runCustomModelEntryViaRestart` (claude)
and `_quickStartWithCustomModelConfirm` (every one-shot launch; the
`runCustomModelEntry` entry point itself only dispatches between the
two) only once the model is actually applied, never on the mere click —
declining the context-window warning means this exact model cannot work
with this CLI at all, so promoting it next time would be actively wrong,
not just premature.
Neither tag reorders anything past that one promoted row. The "Default"
pill is a separate span, not a third value of the same slot: a promoted
row that is also the endpoint's `defaultModelId` shows both tags (on a
single-purpose GPU box that is the common case, and an exclusive slot
silently dropped the Default marking for exactly that row), and a row
with neither promotion nor default shows no tag at all.
**How the launch itself applies the endpoint depends on the harness.** For
opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP (`runCustomModelEntry` →
`_runCustomModelEntryOneShot`), the endpoint/model is folded into the SAME
+2
View File
@@ -81,6 +81,8 @@ Antigravity (`agy`) and Grok (`grok`) are the two CLIs not installed from npm (G
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). OMP is the one CLI in this family where `sessions/` is the EXCEPTION rather than the rule: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded per-file (the dir also holds SQLite caches and `terminal-sessions/`), but `~/.omp/agent/sessions/` is shared RW like codex's, not seeded, because Codeman reads it host-side for history recovery and `--resume` pinning. See [`omp-integration.md`](./omp-integration.md).
The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated.
## Quickest path: one-click "Run in Docker"
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
+3 -1
View File
@@ -6,6 +6,8 @@ For the Compose configuration, environment settings, storage migration, and macv
The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image.
It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azure-devops` extension, wired in as Git credential helpers, so Clone Repo and `git clone` reach private GitHub and Azure DevOps repositories once they are signed in. Both are off by default; [Turning them on](../docker/README.md#turning-them-on) shows the `docker-compose.override.yml` settings.
## Prerequisites
- Docker Engine or Docker Desktop with Docker Compose v2
@@ -67,7 +69,7 @@ If that directory was created by an earlier root-running image, change its owner
Codeman updates itself from **App Settings → Updates**, as it does on a bare host. The checkout mounted at `/opt/codeman` is the same directory Compose builds from, so the update's `git checkout` and rebuild land on the host and survive container recreation; the restart is the server exiting, which `restart: unless-stopped` turns into a relaunch on the new build.
That applies application code only. A release that changes `docker/server.Dockerfile`, `docker/docker-compose.yaml`, or adds a key to `docker/.env.example` needs the image rebuilt or the container recreated, which a container cannot do to itself. The updater detects each case and refuses with a message naming what changed; run `docker/Start-Codeman.sh` on the host to apply those.
That applies application code only. A release that changes `docker/server.Dockerfile`, `docker/docker-compose.yaml`, or adds a key to `docker/.env.example` needs the image rebuilt or the container recreated, which a container cannot do to itself. The updater detects each case and refuses with a message naming what changed; run `docker/Start-Codeman.sh` on the host to apply those. For a major update, or a base-image change `Start-Codeman.sh` does not fully pick up, `docker/Update-Codeman.sh` rebuilds with no layer cache and clears the two build-artefact volumes before handing off to it (see "Major updates" in `docker/README.md`).
`CODEMAN_REPO_PATH` overrides which checkout is mounted. It defaults to the compose project's parent directory, so it normally needs no setting. Point it at a directory that is not a git checkout and in-app updates are reported as unavailable.
+14 -8
View File
@@ -10,15 +10,18 @@ this file covers only what the container changes.
## The short version
| Change in the release | Applied by |
| -------------------------------- | ------------------------------------------------ |
| Application code | The in-app updater |
| `docker/server.Dockerfile` | `docker/Start-Codeman.sh` on the host |
| `docker/docker-compose.yaml` | `docker/Start-Codeman.sh` on the host |
| New key in `docker/.env.example` | Add it to `docker/.env`, then `Start-Codeman.sh` |
| Change in the release | Applied by |
| ----------------------------------------- | ------------------------------------------------------------------------------- |
| Application code | The in-app updater |
| `docker/server.Dockerfile` | `docker/Start-Codeman.sh` on the host |
| `docker/docker-compose.yaml` | `docker/Start-Codeman.sh` on the host |
| New key in `docker/.env.example` | Add it to `docker/.env`, then `Start-Codeman.sh` |
| A major update, or a Node base-image bump | `docker/Update-Codeman.sh` on the host (no-cache rebuild + fresh build volumes) |
The in-app updater detects all three of the bottom rows itself and refuses with a
The in-app updater detects the three middle rows itself and refuses with a
message naming what changed, so you never have to work out which case you are in.
`Update-Codeman.sh` is the heavier option for when `Start-Codeman.sh` is not
enough: see "Major updates" in `docker/README.md`.
## Why the container needs its own path
@@ -224,7 +227,10 @@ the host and the in-app path works from then on.
**Resetting the build artefacts** — `docker compose down -v`, then
`Start-Codeman.sh`. This discards the named volumes and re-seeds them from a fresh
image.
image. `docker/Update-Codeman.sh` scripts the same reset by default for the two
build-artefact volumes (`codeman-node-modules`, `codeman-dist`) only, plus an
unconditional `--no-cache` rebuild, which a plain `Start-Codeman.sh` run does not
force on its own. See "Major updates" in `docker/README.md`.
## Disabling it
+380
View File
@@ -0,0 +1,380 @@
# Installer v2: three questions, then a URL you can open on your phone (Plan)
Status: **Phase 1 IMPLEMENTED (2026-09-20)**, phases 2 and 3 open. It builds on
`docs/tailscale-installer-plan.md` (implemented 2026-08-04), which made Tailscale a
guided option; this round makes it the thing the install ENDS on, and makes the whole
installer shorter to sit through. Owner decisions taken before implementation: rename
is opt-in and **defaults to no everywhere** (the machine name is used for other things);
the URL keeps the node name unless asked; `codeman-<hostname>` is the suggested name;
sub-path is the default for an occupied `:443`.
Verification record for phase 1 (all on the maintainer's box, 2026-09-20):
- `test/install-sh-invariants.test.ts` (28 tests, incl. the new Tailscale safety pins)
and the detection-parity test pass; `bash -n` passes.
- Every new decision function driven with stubbed tailscale state under **bash 5.2 and
bash 3.2** (the `bash:3.2` container CI uses): flags, the launch default, the serve
shape for free / ours / occupied `:443` (all four answers plus the non-interactive
default), the three serve commands, the rename question (Enter keeps the name; `--yes`
and non-interactive never rename; `codeman-*` nodes are skipped; `--name` is
sanitized), `run_step` success/failure/stdin, the unit round-trip of
`CODEMAN_BASE_URL`/`CODEMAN_PORT`/an escaped password, and the done screen.
- A full non-interactive install into a sandboxed `HOME` with `CODEMAN_TAILSCALE=1`:
preflight summary, kept the existing prod mapping (no serve mutation), clone 2 s,
`npm install` 18 s, build 23 s, symlink, done screen; `install.sh status` on a pty
renders the QR code. Nothing on the real system changed.
- **Sub-path mode end to end over the real tailnet**: an isolated Codeman
(`CODEMAN_INSTANCE`, port 3999, `--base-url /codeman`) behind
`tailscale serve --https=8445 --set-path /codeman 3999` answered `/codeman/api/status`,
`/codeman/` (with `<base href="/codeman/">` and `__CODEMAN_BASE__="/codeman"`), the
hashed CSS/JS, `/codeman` without a slash, and the SSE stream; mapping and server
removed afterwards. **Correction to section 2**: serve STRIPS the mount prefix
before proxying (a direct `/codeman/api/status` on the server is 404 while the same
path through serve is 200). That is fine because Codeman's ingress tolerates
unprefixed requests; `--base-url` is needed for the URLs Codeman EMITS, not for
what it receives.
- Not yet exercised on a fresh machine (unchanged from the previous plan): Tailscale
absent / logged out / HTTPS toggle off, the rename against a real node (the
off-rename-re-add order is implemented but only unit-driven), macOS, uninstall. The
Mac mini and a throwaway VM are the venues; see section 8.
- **Review fixes (2026-09-21)**, from the two reviews on PR #460 (DeepSeek Harness, then
Claude): the done screen's Start line is composed from every non-default value
(`start_command_hint`, shared with the exec branch as `export_bind_env`), so "do not
start" under a sub-path or a custom port no longer prints a bare `codeman web`; the
`--lan`/`--tailscale`/env preset paths keep an existing password instead of rewriting
the unit open; `--password`/`--port` flip `RECONFIGURE` so they reach the unit;
`install.sh name` re-syncs the unit's base URL after a rename; the sudo keepalive is
ended before the `exec` into the foreground server; Ctrl+C in the HTTPS-toggle poll
skips Tailscale instead of killing the run; `uninstall` asks before removing a
LaunchDaemon it never wrote; a foreign LaunchDaemon gets a restart hint and the done
screen stops claiming the new build is running; the preflight summary reads the
Tailscale state without node; the LAN security notice uses the configured port; a
bare re-run ends on the done screen; a build failure after a rename names the
`install.sh tailscale` recovery; `TS_JOINED_HERE` is gone.
Goal, in one sentence: a user runs the one-liner, answers at most three questions, walks
away during the build, and comes back to `https://<name>.<tailnet>.ts.net` printed with a
QR code, already answering, on every device in their tailnet. That is exactly the
maintainer's own production setup (`tnode.tailf80371.ts.net` fronting `127.0.0.1:3000`),
and the installer should produce it without the user knowing what `tailscale serve` is.
## 1. Where the installer is today
Facts from reading `install.sh` (2886 lines, 19 `prompt_yes_no` sites) and the live
Tailscale state on the maintainer's box (tailscale 1.102.2, user-owned node, MagicDNS +
HTTPS certs on, serve mapping `443 -> https+insecure://localhost:3000`).
**The order is backwards for a human.** The flow is: detect -> ask about git -> ask about
node -> ask about tmux -> ask about build tools -> AI CLI menu -> ask about cloudflared ->
clone -> `npm install` -> build (minutes) -> **then** the network-access question -> the
Tailscale sub-steps (install? login URL, sudo for operator, admin-console toggle loop) ->
the launch menu (no default; a bare Enter re-prompts) -> tunnel-service question. A fresh
Ubuntu server taking the Tailscale route answers roughly ten prompts plus two to four sudo
password prompts, split around a multi-minute build. The user cannot walk away at any
point, and the question that matters most (how do I reach it) comes last.
**The Tailscale flow works but was never exercised on a fresh machine.** The previous
plan's manual matrix still lists items 1-4, 7 and 10-12 (Tailscale absent, logged out,
HTTPS toggle off, port 443 occupied, macOS, uninstall, phone PWA) as untested. The
maintainer's own verification was the idempotent "kept as-is" path.
**The URL is the machine's name, full stop.** `setup_tailscale_serve` derives it from
`.Self.DNSName`, and nothing lets the user influence it. A second Codeman on the same
tailnet is `macminis-mac-mini.tailf80371.ts.net`, which tells you nothing about Codeman.
**Port 443 taken means give up or clobber.** If another app already owns the root of
`:443`, the only offer is "replace it?" (default no), and declining falls back to
local-only. Codeman already supports running under a sub-path (`--base-url`), and
Tailscale serve supports mounting a path (`--set-path`), so there is a third answer nobody
is offered.
**The result is invisible afterwards.** Once the terminal scrolls away, nothing in the app
or the CLI tells the user their Tailscale URL again. `codeman doctor` does not probe
Tailscale; App Settings -> Remote access shows only the Cloudflare tunnel.
**Two service writers exist.** `install.sh` carries its own plist/unit generator (~180
lines) next to `codeman service install` (`src/service-installer.ts`). They agree on the
job name by design, but the bash copy is the one that writes `CODEMAN_PASSWORD` into the
unit, so they cannot simply be merged. Left as-is in this plan (see section 9).
## 2. What Tailscale makes possible for the name (researched 2026-09-20)
| Option | Resulting URL | What it needs | Side effects | Verdict |
| ------ | ------------- | ------------- | ------------ | ------- |
| **A. Node name** (today) | `https://tnode.tailf80371.ts.net` | `tailscale serve --bg 3000` | none | **Default.** Zero admin-console work, matches the maintainer's prod. |
| **B. Rename the node** | `https://codeman-tnode.tailf80371.ts.net` | `tailscale set --hostname codeman-<host>` (operator or root) | Renames the machine tailnet-wide: ssh targets, other serve URLs, the admin console entry. Tailscale de-dups a clash as `-1`. The cert follows the new name. | **Opt-in, default NO everywhere** (owner decision 2026-09-20: the machine is used for other things, so a bare Enter never renames it). The proposal was YES when the installer itself had just joined the tailnet; rejected. |
| **C. Tailscale Service** | `https://codeman.tailf80371.ts.net` | tailscale >= 1.86 on the host; the host must have a **tag-based identity** ("You cannot use a device authenticated with a user account as a Service host"); the service is defined in the admin console first; the host is then approved there (or via `autoApprovers.services`). Public beta since 2025-10-28, all plans. | Re-authenticating a personal machine as a tagged node changes its identity (SSH ACLs, user attribution). Known daemon quirk: approval is not picked up until `serve clear` + re-advertise (tailscale/tailscale#18821). | **Detect and hint only** in this round. The maintainer's own node has `Self.Tags: null`, so it could not host one without re-tagging. Worth a real flow once someone with a tagged fleet asks. |
| **D. Sub-path** | `https://tnode.tailf80371.ts.net/codeman` | `tailscale serve --bg --set-path /codeman 3000` plus `--base-url /codeman` on the server | Codeman runs under a prefix. Hooks are unaffected (they hit the raw port with no prefix, which `rewriteUrl` already tolerates). Serve forwards the prefix unchanged, which is exactly the shape `--base-url` was built for. | **The answer when `:443` root is already taken.** Replaces today's replace-or-nothing prompt. |
| **E. Second port** | `https://tnode.tailf80371.ts.net:8443` | `tailscale serve --bg --https=8443 3000` | Port in the URL; the beta-preview recipe already uses this. | Fallback when the user rejects D. |
| Funnel (public internet) | `https://tnode.tailf80371.ts.net` from anywhere | `tailscale funnel` | Public exposure; different risk class. | **Out of scope**, as before. Docs only, with the password warning. |
Sources: Tailscale Services docs (`tailscale.com/docs/features/tailscale-services`), the
Services beta announcement (`tailscale.com/blog/services-beta`), machine names
(`tailscale.com/kb/1098/machine-names`), the serve CLI reference
(`tailscale.com/docs/reference/tailscale-cli/serve`), the macOS variants page
(`tailscale.com/docs/concepts/macos-variants`), and `tailscale serve --help` on 1.102.2
(which lists `--service`, `--set-path`, `--yes`, `advertise`, `get-config`/`set-config`).
**Trap for option B (verify on the Mac mini before shipping):** the serve config is keyed
by `host:port` using the DNS name at configuration time (`"Web": {"tnode.tailf80371.ts.net:443": ...}`
in `serve status --json`). Renaming a node after serve is configured most likely orphans that
entry: the handler lookup uses the current name and never matches the old key, and the only
tool that removes a stale key is `serve reset`, which this installer must never run. So the
order is **rename first, then configure serve** on a fresh install, and on a retrofit
(`install.sh name`) **turn our mapping off, rename, wait for `.Self.DNSName` to change,
re-add**.
## 3. Target UX
### 3.1 Three questions, then walk away
```
Codeman installer
Found: git, Node 22.14, tmux 3.4, build tools Missing: nothing
AI CLIs: Claude Code (~/.local/bin/claude)
Tailscale: connected as tnode (tailf80371.ts.net)
Existing: none
1/3 How should the dashboard be reachable?
1) Tailscale https://tnode.tailf80371.ts.net (recommended, already connected)
2) Any device on your network (0.0.0.0, password required)
3) This machine only (127.0.0.1)
Choose [1/2/3] (default 1):
2/3 Name this machine "codeman-tnode" on your tailnet? [y/N]
(only shown for option 1; default no, always)
3/3 Run Codeman as a background service that starts on boot? [Y/n]
Installing… this takes a few minutes. You can leave this running.
✓ dependencies ✓ clone ✓ build (2m 41s) ✓ service ✓ tailscale serve
```
Rules that make this work:
- **Every step that needs a human runs BEFORE the build.** The dependency consent, the
AI CLI menu, the Tailscale install consent, the `tailscale up` login URL, the operator
grant, and the tailnet HTTPS toggle all move into the question phase. The build, the
service, `tailscale serve` and the verification are unattended.
- **One consent for all missing system packages.** "Install git, Node 22 and build tools
now? [Y/n]" replaces four separate prompts. Each package still runs its own
distro-specific installer.
- **One sudo prompt.** When anything needs root (packages, the Tailscale installer,
`tailscale up`, the operator grant), the installer says so once, runs `sudo -v`, and keeps
the timestamp alive in a background loop until it exits. macOS needs no sudo for the
Tailscale GUI-app CLI and the pattern still holds for Homebrew packages.
- **Service is the default.** Enter on the last question installs the service; "run in
this terminal" and "don't start" stay reachable by answering, and by flag.
- **The cloudflared question is gone from the main flow.** It is optional, defaults to
no, and has an in-app toggle (App Settings -> Remote access). The done screen mentions it
only when `cloudflared` is already installed. The Linux tunnel-service prompt goes with it.
- **The HTTPS-certificates toggle no longer asks "re-check now?"** The installer prints the
admin URL, opens it in a browser when one is available (`xdg-open` / `open`, never on a
headless box), and polls `tailscale status --json` every 5 s for up to 5 minutes. Ctrl+C or
the timeout falls back exactly as today.
- **Progress, not silence.** `npm install` and `npm run build` run behind one line each
with elapsed time; their output goes to `~/.codeman/install.log` and is printed only on
failure, with the exact retry command.
### 3.2 The done screen
One block, the URL first, a QR code the phone can scan, and nothing the user does not need
right now.
```
✓ Codeman 1.31.0 is running
Your tailnet: https://codeman-tnode.tailf80371.ts.net (HTTPS, any of your devices)
This machine: http://localhost:3000
▄▄▄▄▄▄▄ ▄ ▄▄ ▄▄▄▄▄▄▄
█ ▄▄▄ █ ▄▄▀ ▄ █ ▄▄▄ █ scan with your phone
█ ███ █ ███▀▀ █ ███ █
█▄▄▄▄▄█ █ ▄ █ █▄▄▄▄▄█
Manage systemctl --user restart codeman-web · journalctl --user -u codeman-web -f
Update re-run the install line, or App Settings → System → Updates
Docs https://github.com/Ark0N/Codeman/wiki
Security: Codeman binds 127.0.0.1. Tailscale authenticates every device before a
packet reaches it. Details: docs/security-architecture.md
```
The QR comes from the `qrcode` package Codeman already depends on
(`node -e "require('qrcode').toString(url, {type:'terminal', small:true}, …)"` from
`$INSTALL_DIR`, verified locally: 17 rows by 45 columns). Skipped when the terminal has no
color support or fewer than 50 columns. The QR encodes the plain URL, not an auth token:
the tailnet is the login.
### 3.3 Express mode and flags
Env vars stay (`CODEMAN_TAILSCALE=1`, `CODEMAN_HOST`, `CODEMAN_PASSWORD`,
`CODEMAN_NONINTERACTIVE=1`, `CODEMAN_PORT`). Flags are added because they are
discoverable from the one-liner and pipe through `bash -s --`:
```bash
curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service
curl -fsSL https://getcodeman.com/install | bash -s -- --lan --password 'x' --service
curl -fsSL https://getcodeman.com/install | bash -s -- --local --run
curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --name codeman-build --yes
```
| Flag | Meaning |
| ---- | ------- |
| `--tailscale` / `--lan` / `--local` | Answer 1/3 (same semantics as `CODEMAN_TAILSCALE=1`, `CODEMAN_HOST=0.0.0.0`, `CODEMAN_HOST=127.0.0.1`) |
| `--name <n>` / `--no-rename` | Answer 2/3: rename the node to `<n>`, or never ask |
| `--service` / `--run` / `--no-start` | Answer 3/3 |
| `--yes` | Accept every default, still prompt for a login URL (a human must open it) |
| `--password <p>` | Same as `CODEMAN_PASSWORD` |
| `--port <n>` | Same as `CODEMAN_PORT`; the serve target follows it |
`--yes` differs from `CODEMAN_NONINTERACTIVE=1`: it is the interactive user saying "I trust
the defaults", so it may install software and may wait on a login URL. Non-interactive stays
the CI contract and never installs Tailscale.
## 4. The Tailscale flow, v2
The state machine from the previous plan stays; these are the changes.
1. **Preflight, before the build** (`tailscale_preflight`): installed? -> install
(Linux: official script; macOS: brew cask, else download link and wait). Logged in? ->
`tailscale up` with the URL printed prominently and a 5-minute poll. Operator (Linux):
grant once under the single sudo session. HTTPS certs: poll instead of ask (Ctrl+C
during the poll skips Tailscale for this run rather than ending the installer). The
rename default does not depend on whether this run performed the login (decided NO
everywhere), so nothing records it.
2. **Name** (`tailscale_choose_name`, question 2/3): shown only on the Tailscale route.
Default `codeman-<oshostname>` sanitized to `[a-z0-9-]`, max 63. Applied with
`ts_cmd_serve set --hostname`, then poll `.Self.DNSName` until it carries the new name
(up to 60 s). Order matters: this runs before any serve mutation (section 2 trap).
Declining keeps the node name. On a re-run against a node already named `codeman-*`,
the question is skipped.
3. **Serve, after the service is up** (`setup_tailscale_serve`): unchanged idempotent
"kept as-is" path first. When `:443` root belongs to another target, the new prompt is:
```
tailscale serve already sends https://tnode.tailf80371.ts.net to port 8080.
1) Add Codeman under a path: https://tnode.tailf80371.ts.net/codeman (default)
2) Use another port: https://tnode.tailf80371.ts.net:8443
3) Replace the existing mapping with Codeman
4) Skip Tailscale for now
```
Option 1 writes `--base-url /codeman` into the service unit (it is a `WebLaunchOptions`
field already, and `buildWebArgs` carries it) and runs
`tailscale serve --bg --set-path /codeman <port>`. Option 2 runs `--https=8443`.
`detect_tailscale_serve_url` learns to recognize all three shapes (root, path, port) so
uninstall, the security notice and the re-run default keep working.
4. **Warm the certificate.** Right after serve is configured, fire one background
`curl -sk https://<url>/api/status` so Let's Encrypt issuance overlaps the rest of the
install instead of adding 30 s to the verify step.
5. **Verify** as today (200 or 401 on `/api/status`), with the path-aware URL.
6. **Services hint** (option C): when `.Self.Tags` is non-empty and `serve --help`
lists `--service`, the done screen adds one line: "This is a tagged node, so it can also
host `https://codeman.<tailnet>.ts.net` as a Tailscale Service: see Remote Access in the
wiki." No flow, no prompt.
7. **macOS**: the App Store and Standalone variants cannot run before login, so a
LaunchAgent plus serve only comes back after someone logs in. The done screen says so on
macOS. The Mac mini (`arbbot`, headless, system LaunchDaemon) is the reference for the
"headless Mac" caveat, and `install.sh` must keep refusing to replace a LaunchDaemon it
did not write (today it removes one; that is a bug for the Mac mini and is fixed here:
detect `UserName` in the daemon plist and leave it alone with a message).
8. **Uninstall** additionally offers to restore the original node name when this installer
renamed it (the original is recorded in `~/.codeman/install.json`, the one marker file
this feature adds, because tailscaled does not remember previous names).
9. **Subcommands**: `install.sh tailscale` (unchanged purpose, now runs the v2 flow),
`install.sh name [<n>]` (rename with the off/rename/re-add dance), `install.sh status`
(prints the done screen again, URL and QR included, for the "what was my URL" moment).
## 5. In-app: the URL stays discoverable
Small, read-only, and the first server-side code this feature has ever needed.
- **`GET /api/system/remote-access`** returns
`{ tailscale: { installed, connected, dnsName, url, mode: 'root'|'path'|'port'|null } }`
by running `tailscale status --json` and `tailscale serve status --json` through
`execFile` with the existing exec timeout, cached 30 s, resolved through the same
`get_tailscale_path` search as the installer (PATH, then the macOS app bundle), and a
no-op under `VITEST` like every other IO probe. Never mutates serve config.
- **App Settings -> Remote access** gains a **Tailscale** row above the Cloudflare toggle:
the URL as a copy chip, a QR button reusing `showTunnelQR`'s modal, and when nothing is
configured a one-line hint with `bash ~/.codeman/app/install.sh tailscale`. The welcome
screen's "open on your phone" affordance shows the same QR.
- **`codeman doctor`** grows a `tailscale` entry under `other` in
`config/dependency-registry.ts`: installed, connected, serving Codeman (URL). Pure
engine, injectable probe host, like the existing rows.
- No new SSE event, no settings key, no state.json change.
## 6. Security posture
Nothing widens. The bind stays loopback; the tailnet is the authentication boundary;
`.ts.net` is already in `DEFAULT_TRUSTED_HOST_SUFFIXES`. New surfaces are read-only
probes. `install.sh` still never runs `tailscale serve reset`, still touches only the
mapping it created, and gains one more never: it never advertises a Tailscale Service or
runs `tailscale funnel`. The sudo keep-alive loop is killed by the existing `cleanup` trap.
The rename records the previous name locally and offers the reversal at uninstall.
## 7. Implementation inventory
| File | Change |
| ---- | ------ |
| `install.sh` | New `parse_flags`, `preflight_summary`, `ask_everything` (the three questions), `sudo_session`, `run_step` (spinner + log), `tailscale_preflight`, `tailscale_choose_name`, `tailscale_rename_node`, `print_done_screen`, `print_qr`, `status` subcommand, `name` subcommand. Modified: `main` (reordered into ask -> work -> done), `choose_network_binding` (question 1/3, same defaults), `setup_tailscale_serve` (path/port options), `detect_tailscale_serve_url` (three shapes), `setup_systemd_service`/`setup_launchd_service` (`--base-url`, LaunchDaemon guard), `uninstall` (rename reversal), header docs (flags). Removed from the main flow: the cloudflared prompt, the tunnel-service prompt. bash 3.2 rules unchanged. |
| `src/web/routes/system-routes.ts` | `GET /api/system/remote-access` |
| `src/tailscale-status.ts` (new) | Pure parser for the two JSON shapes + the IO wrapper; unit-tested against captured `serve status --json` fixtures (root, path, port, foreign target, none) |
| `src/config/dependency-registry.ts`, `src/utils/dependency-checker.ts` | `tailscale` doctor row |
| `src/web/public/index.html`, `settings-ui.js`, `panels-ui.js` | Tailscale row + QR, welcome-screen QR |
| `test/install-sh-invariants.test.ts` | Extend: flags documented in the header, no `serve reset`, no `funnel`, no `--service` advertise, every serve mutation goes through `ts_cmd_serve`, rename happens before serve in `main` (static order check) |
| `.github/workflows/ci.yml` | The bash 3.2 step additionally sources the script with stubbed `ts_cmd`/`ts_cmd_serve`/`read_reply` and drives `ask_everything` through all three answers and the 443-occupied menu |
| `test/tailscale-status.test.ts`, `test/routes/system-routes-remote-access.test.ts` | Parser + route |
| Docs | README install + remote-access sections, `docs/wiki/Installation.md`, `Remote-Access.md` (naming options table, Services caveat, path/port variants), `Mobile-Guide.md`, `Running-As-A-Service.md` (macOS login caveat), `FAQ.md`, `docs/security-architecture.md` §A, CLAUDE.md Scripts & Tunnel paragraph, `docs/tailscale-installer-plan.md` gets a pointer here. getcodeman.com copy lives outside the repo (maintainer handbook). |
Changeset: `minor` (new flags, new subcommands, new API route).
## 8. Test plan
Automated (the gate): the static invariants above, the bash 3.2 container drive of the
question phase, the JSON parser fixtures, the route test.
Manual matrix, on a fresh Ubuntu 24 VM and on the Mac mini, since the previous plan's
items never ran on a fresh machine:
1. Tailscale absent, declined -> local-only, done screen shows the retrofit command.
2. Tailscale absent, accepted -> install, login URL, operator, certs toggle polled, rename
question shown (default no), service, serve, URL verified, QR scans on a phone, PWA installs.
3. Tailscale present and logged in on a pre-existing node -> rename default NO, URL is the
node name, `serve status` gains exactly one entry.
4. `:443` root occupied -> path option -> `https://<node>/codeman` answers, hooks still
fire (raw port), `install.sh status` prints the path URL.
5. Rename on a node that already has our serve mapping (`install.sh name`) -> off, rename,
re-add, `serve status` has no stale key.
6. Re-run the one-liner -> quiet update, binding and name preserved, no prompts.
7. `--yes` end to end; `CODEMAN_NONINTERACTIVE=1` end to end (no software installed).
8. Uninstall -> mapping removed, other mappings intact, rename reversal offered.
9. Mac mini: LaunchDaemon left alone with the message; done screen carries the login caveat.
## 9. Phasing and open decisions
**Phase 1 (this round):** the reorder, the three questions, one consent + one sudo, flags,
the done screen with QR, Tailscale preflight-before-build, the path/port answer for an
occupied 443, the rename step, `status` and `name` subcommands, docs.
**Phase 2:** the in-app Tailscale row + QR, `codeman doctor` row, the `remote-access`
route. Independent of phase 1 and useful on its own for existing installs.
**Phase 3 (optional):** replace the bash service writers with `codeman service install`
once that command can carry `CODEMAN_PASSWORD` behind an explicit flag; and a Tailscale
Services flow if a tagged-fleet user asks for `codeman.<tailnet>.ts.net`.
Decisions for the maintainer:
1. **Rename default.** Decided 2026-09-20: always NO; the yes answer, `--name` and
`install.sh name` are the ways in. (The proposal was YES only when this run had joined
the tailnet, NO otherwise; rejected because the host is used for other things.)
2. **Name pattern.** `codeman-<hostname>` (proposed; unique per machine, and two Codemans
on one tailnet stay distinguishable) versus plain `codeman` (nicer once, collides on the
second install, Tailscale silently appends `-1`).
3. **Path versus port** as the default answer for an occupied 443. Proposed: path, because
the URL has no port and `--base-url` already exists for exactly this proxy shape.
4. **Whether Phase 2 ships in the same release.** It is the part that helps people who
installed months ago.
+11 -4
View File
@@ -270,9 +270,15 @@ tailscale serve --bg 3000 # HTTPS at https://<node>.<tailnet>.ts.net
Only devices on your tailnet can reach it; Tailscale handles identity and
terminates TLS with a real Let's Encrypt certificate (so PWA install and web
push work). No app password and no `0.0.0.0` bind required. (This is the
maintainer's production setup.) `CODEMAN_TAILSCALE=1` presets the choice for
automation; the installer never runs `tailscale serve reset` and never touches
serve mappings other than `443 -> Codeman's port`.
maintainer's production setup.) `CODEMAN_TAILSCALE=1` or `--tailscale` presets
the choice for automation. When `:443` on the node already belongs to another
app, the installer mounts Codeman under `/codeman` (`tailscale serve --set-path`
plus `--base-url`, which keeps the loopback bind and the same host guard) or on a
second port rather than replacing it. The installer never runs `tailscale serve
reset`, never touches serve mappings other than the one it created, never opens a
`tailscale funnel` (public internet, a different risk class) and never advertises
a Tailscale Service. Renaming the node (`--name`, `install.sh name`) is opt-in
and defaults to no, because the tailnet name is also the machine's SSH identity.
### B. Authenticated cloudflared tunnel + password
@@ -491,7 +497,7 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
Docker cases (1.4.0) run a session inside a per‑case container instead of on the host. The security posture:
- **Hardened create flags, always** — `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit` (fork‑bomb guard), `--memory` == `--memory-swap` (a real OOM cap), `--init`, and non‑root: `--user <hostUid>:0` on Linux (host uid → workspace files stay host‑owned; GID 0 keeps `$HOME` writable), `--userns=keep-id` on rootless Podman. **Never** `--privileged`, and **never** the docker socket — the pure builder in `docker-hosts.ts` cannot emit them and the schema cannot represent them.
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini` — which also carries Antigravity's `antigravity-cli/` state — `~/.config/{gcloud,opencode}`, five seeded files from `~/.pi/agent`, and three from `~/.grok`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini` — which also carries Antigravity's `antigravity-cli/` state — `~/.config/{gcloud,opencode}`, five seeded files from `~/.pi/agent`, three from `~/.grok`, and, only when their opt-in switches `CODEMAN_AGENT_IMAGE_INSTALL_GH` / `_AZ` are `1`, `~/.config/gh/{hosts.yml,config.yml}` and the sign-in files from `~/.azure`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
- **Blast radius — accept it explicitly** — the convenient profile mounts an arbitrary host workspace RW plus the host credential dirs RW into a network‑enabled container, so container‑run agent code can read/modify those host trees and reach the network at once. Still a net improvement over today's on‑host `--dangerously-skip-permissions` execution; use the sealed profile for genuinely untrusted work.
- **Import is untrusted‑bundle‑safe** — `/api/docker-cases/import` validates the manifest + per‑member SHA‑256 before extraction, rejects absolute / `..` tar members (traversal guard), and re‑tags the loaded image into a quarantined namespace so it can never overwrite `codeman/agent:base` or a pre‑existing tag.
- **Host guard & the bridge‑hooks listener** — in‑container hook callbacks carry `Host: host.docker.internal` / `host.containers.internal`; both are on the always‑on host‑header allowlist (`DOCKER_HOST_GATEWAY_ALIASES`) and resolve to the host only from inside a container netns, so they are not a browser DNS‑rebinding surface. On a loopback‑only server, in‑container hooks are opt‑in via `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, which binds a SECOND listener on the docker bridge gateway serving **only** the hook endpoints (every other path → `403`) into the same hook‑secret‑gated pipeline. The bridge is host‑internal (containers + host), not the LAN, so it does not widen network exposure; the hook secret is bind‑mounted read‑only and referenced by path.
@@ -510,6 +516,7 @@ Full feature guide: [`docker-cases.md`](docker-cases.md).
- **Auth is a parallel branch** (`middleware/auth.ts`) that leaves the single‑user path untouched: per‑user scrypt verify (`timingSafeEqual`, timing‑equalized against user enumeration), identity‑carrying cookies, a per‑username failure bucket (a botnet can't brute one account across IPs; one NATed user can't lock out the rest), and a `mustChangePassword` lockbox. The hook‑secret loopback bypass, host guard, and Origin/CSRF guard are unchanged (hooks authenticate the INSTANCE, not a user).
- **Ownership is enforced server‑side only** and fails closed: `req.authUser` (a synthetic admin in single‑user), `findSessionOrFail` returns NOT_FOUND (never 403) for a foreign session, list/SSE/WS/file‑preview/search all filter by `session.owner`, and SSE routing defaults session‑scoped events to their owner (unresolved owner → withheld). The load‑bearing rule is **non‑admin `workingDir` confinement**: a non‑admin's session/one‑shot working dir must realpath‑resolve inside `~/codeman-users/<name>/cases`, checked BEFORE any disk write.
- **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, and other CLIs' bypass flags. Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only.
- **Clone Repo does not lend the server's git sign-in to non-admins.** A clone writes only inside the caller's own case space, so it is not admin-gated, but the server account's git credential helpers (the Docker image's opt-in `gh`/`az` helpers, or any `gh auth setup-git`) are shared by every user. A non-admin's clone and preflight therefore run with `git -c credential.helper=`, which empties the helper list including the URL-scoped entries (`cloneWithoutCredentialHelpers` in `case-routes.ts`, argv pinned in `test/git-clone.test.ts`). This closes the Clone Repo path only: the account's SSH keys still apply to an `ssh://` URL, and a non-admin's agent sessions run as the same account, consistent with the first bullet above. Docker cases are a second route to the same sign-in: with `CODEMAN_AGENT_IMAGE_INSTALL_GH`/`_AZ` on, a non-admin's Docker case with credential seeding on (the default) receives a copy of the server account's `gh`/`az` sign-in, exactly as it receives the Claude and Codex credentials.
- **Admin actions are audited** append‑only to `~/.codeman/admin-audit.jsonl` (acting admin, action, target, IP). Passwords set by an admin create/reset are one‑time (returned once, force change). Under Basic auth, `logout` only truly ends QR‑issued sessions — to lock someone out, disable the account or reset the password (a proper login form is a deferred Phase 6).
---
+198
View File
@@ -0,0 +1,198 @@
# Split-Pane Sessions — Design Spec
**Status**: Implemented (v1)
**Author**: Claude (session with Tim), 2026-09-15
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
## Problem
Codeman's terminal area shows exactly one active session (pane) at a time —
switching panes re-binds the single xterm instance and the single WebSocket
to a different session. Multi-monitor spanning (`scripts/span-codeman.sh` /
`span-codeman.ps1`) turned out to solve a different problem: it makes one
browser window bigger, but that window still shows one session; floating
subagent windows are draggable overlays on top of it, not tiled panes. There
is no way today to see two live sessions (e.g. `w1-codeman` and
`w1-mcp-memory`) side-by-side in one window, even on a monitor wide enough to
fit both.
## Goal (v1)
From the active session, open a **second, independent, fully live session**
in a pane beside it — draggable divider, side-by-side only. Closing the
second pane collapses back to today's normal single-pane view. No
persistence: a page reload always returns to single-pane. Floating
subagent/Ultracode windows keep their current behavior unchanged (global,
unconstrained across the whole viewport, split or not).
Explicitly out of scope for v1 (v2 candidates, not designed here):
- More than 2 panes / grid layouts
- Vertical (stacked) splits
- Drag-a-tab-to-split as a trigger (v1 trigger is an explicit button + picker)
- Persisting the split layout across reload or across devices
- Mobile/tablet layouts (viewport is too narrow for this to make sense; gated
to desktop widths the same way `home-sessions.js`'s rail is)
- Feature parity between the two panes (see "Pane B is deliberately plainer"
below)
## Current architecture (why this isn't a CSS change)
`terminal-ui.js` is built entirely around **singleton** state: `this.terminal`
(one xterm instance), `this._ws`/`this._wsSessionId` (one WebSocket, rebound
on every pane switch via `_disconnectWs()` + `_connectWs(newId)`), a
`this._xtermSnapshots` map used only to restore scrollback into that one
terminal when switching back to a session. Roughly 280 references to this
singleton state exist across the file (input handling, resize/fit, sizing-
token claims, mobile touch gestures, CJK IME, local-echo overlay wiring,
keyboard accessory bar, link providers, etc.).
Showing two sessions at once therefore requires a second, independently
alive xterm + WebSocket pair running concurrently — not a layout change to
one shared instance.
**Related prior art**: `detachSession(id)` (app.js) already opens one session
in a genuinely separate browser window (`isSoloWindow` mode) with its own
independent WebSocket, and two of those can already be snapped side-by-side
today with zero new code. That covers "two sessions visible at once" but not
what this spec is for: one Codeman window with two panes and a divider you
can drag without leaving your seat, each still a full participant in that
window's floating subagent windows, header, and settings. This spec builds
past detach, not a duplicate of it.
**Server-side check (done, not just assumed)**: `MAX_WS_PER_SESSION = 5`
(`src/web/routes/ws-routes.ts`), scoped by `clientId:tabNonce`
(`ws-connection-registry.ts`). Splitting always opens a *different* session
in the second pane (self-splitting is disallowed, see below), so this is two
sessions each getting their normal one connection — the existing cap is
irrelevant here and needs no server change.
## Key design decision: Pane B is deliberately plainer than Pane A
Porting all ~280 singleton behaviors to a second, symmetric pane is not
worth it for v1 — most of that code is input-quality-of-life for **mobile/
touch** (local-echo overlay, CJK IME textarea, touch gesture handling,
keyboard accessory bar), and this feature is desktop-only by nature (a split
view needs a wide viewport). So:
- **Pane A** (the session that was already active when you opened the split)
stays exactly what it is today — `this.terminal`, `this._ws`, unchanged
code path, zero regression risk.
- **Pane B** is a new, smaller `SplitTerminalPane` object: its own xterm
instance + fit addon, its own WebSocket to `/ws/sessions/:id/terminal`,
resize-on-divider-drag, and plain keyboard input. It does **not** get the
local-echo overlay, CJK IME composition, touch/mobile handlers, or the
keyboard accessory bar. On a desktop, typing directly into an xterm
instance with no overlay is exactly how Codeman behaved before the local-
echo overlay existed for touch devices — normal, not degraded, for a
keyboard-and-mouse user.
If this asymmetry actually bothers you in daily use, promoting Pane B to full
parity is a scoped v2 (extract the shared logic already once you have two
call sites to compare, rather than guessing the right abstraction now).
One more asymmetry worth naming here rather than discovering by surprise:
while both panes accept keyboard input, the global capture-phase shortcut
handler (`app.js`) always resolves against Pane A — it has no notion of
which pane currently has focus. So Ctrl+L or Ctrl+W typed while Pane B has
focus clears or closes Pane A, not the session you were actually typing
into. Not fixed for v1, same reasoning as the rest of this section.
## Components
### 1. `SplitTerminalPane` (new, `terminal-split.js`)
A small class, one instance per secondary pane:
- `constructor(sessionId, mountEl)`
- `connect()` — creates the xterm instance (same theme/font config as the
primary, read from the same settings so it doesn't visually clash), opens
`/ws/sessions/:id/terminal`, wires input → WS, WS → terminal write
- `fit()` — calls the fit addon; called on divider drag (rAF-throttled) and
on window resize
- `destroy()` — disposes the xterm instance, closes the WS cleanly
No snapshot/scrollback-restore map is needed the way `_xtermSnapshots` exists
for Pane A — Pane B is destroyed on close, not hidden-and-restored, since
there's no persistence requirement.
### 2. Split container (layout)
```
.terminal-split-container (flex row, only rendered when split is active)
├── .terminal-wrap (existing element, Pane A — untouched)
├── .split-divider (new, draggable seam)
└── .terminal-pane-b (new, hosts SplitTerminalPane's xterm + a
small header: session name + × close button)
```
When not split, `.terminal-wrap` renders exactly as it does today (no
wrapping container at all, to keep the no-split path byte-identical to
current behavior). Splitting inserts the container and reparents
`.terminal-wrap` into it as the first child — same reparenting pattern
already used by `applySessionListLayout()` for `#sessionTabs`, so this isn't
a new pattern for the codebase.
Default split is 50/50 (`flex-basis: 50%` each). Divider drag updates both
panes' `flex-basis` live (rAF-throttled) and calls `fit()` on **both**
terminals per tick, clamped to 20%/80% so neither pane can be dragged into an
unusably thin sliver.
### 3. Trigger UI
A **"Split"** button (header, opt-in like the other header buttons —
`showSplitButton`, default off, same pattern as `showMultiMonitorButton`)
opens a small picker listing your other open sessions (reuses
`this.sessions`/`sessionOrder`, filtered to exclude the currently active
session — you cannot split a session against itself). Picking one:
1. Creates the split container, reparents `.terminal-wrap`
2. Instantiates `SplitTerminalPane` for the chosen session in `.terminal-pane-b`
3. Button state flips to "close split" (or Pane B's own header × does it)
Closing (via Pane B's × or the header button toggling off):
1. `SplitTerminalPane.destroy()`
2. Removes `.terminal-split-container`, reparents `.terminal-wrap` back to
its original location at 100% width
3. Fires a resize/fit on Pane A (same `ResizeObserver`-driven fit already in
place today — no new code needed here, it fires naturally once the
container's size changes)
v2 note (not designed): dragging a session tab onto the active pane as an
alternate trigger. You confirmed right-click doesn't work today (Codeman
doesn't intercept it) and declined a keybind, so v1 is button+picker only.
### 4. Failure / edge cases
- **The Pane B session ends or is deleted while split is active** → treat
identically to the user closing Pane B manually: destroy the pane, collapse
to Pane A at full width.
- **The Pane A session ends while split is active** → Pane B is promoted:
it becomes the new single full-width pane (reusing today's normal
single-pane code path means Pane B's `SplitTerminalPane` must hand off to
a real `this.terminal`/`this._ws` binding — simplest correct approach is
to just collapse the split and let normal session-select logic reopen
Pane B's session as the new primary, rather than trying to promote the
lightweight pane object in place).
- **Both end** → falls through to today's normal "no active session" /
welcome-screen state.
- **Subagent/Ultracode floating windows** → no design work needed; they're
already positioned independent of `.terminal-wrap`'s layout, so they
continue to float over whichever pane(s) are on screen, unconstrained,
exactly as today.
## Testing
- Unit: `SplitTerminalPane` connect/fit/destroy lifecycle (mock WS, like
existing terminal tests use `TEST_PTY_SCRIPT`).
- Route/integration: opening two WS connections to two different sessions
from one simulated client concurrently — confirms the existing per-session
cap and connection registry need no changes.
- Browser (Playwright, `test/browser` since this is desktop-viewport-gated
UI): open split via button+picker, verify both panes render live output
independently, drag divider and confirm both refit, close Pane B and
confirm Pane A returns to full width, kill the Pane B session externally
and confirm auto-collapse.
## Open questions for review
None blocking — the scope-narrowing decisions above (Pane B feature parity,
no persistence, side-by-side only, button+picker trigger) came directly from
your answers during brainstorming. Flag anything here you want reconsidered.
+5
View File
@@ -1,5 +1,10 @@
# Tailscale Setup in the Installer (Plan)
> Superseded in part by [`installer-v2-plan.md`](installer-v2-plan.md) (2026-09-20), which
> moved every human step before the build, added the sub-path / second-port answer for an
> occupied `:443`, the opt-in rename, flags, and the done screen with a QR code. The
> state machine and safety rules below still hold.
Goal: make "Codeman over Tailscale, with real HTTPS" a first-class, guided path in
`install.sh`, instead of a one-line hint pointing at the docs. Today the safest
recommended deployment (loopback bind + `tailscale serve`) is exactly what the
+8 -1
View File
@@ -20,12 +20,19 @@ Three ways to get one, all under **+** next to the case picker:
| How | Result |
| ----------------- | ------------------------------------------------------------------------------------------------------ |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`. |
| **Clone Repo** | A public repo cloned into `~/codeman-cases/<name>` and registered as a case. |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
Linked cases keep living where they are. Deleting a case in Codeman removes the
registration, and for a linked case that is all it removes.
**Clone Repo never asks for credentials.** It uses whatever the server's own git already has:
an ssh key, or a credential helper such as `gh auth setup-git`. The Docker image can include
helpers for GitHub (`gh`) and Azure DevOps (`az`), turned on in `docker-compose.override.yml`;
then signing those CLIs in once from a shell session is enough. See the private repositories
section of `docker/README.md`. Without credentials a private repo fails straight away with an
authentication error.
**Cases created from scratch are the only copy of that code.** Uninstalling Codeman does not
delete `~/codeman-cases/`, but treat that directory as real work, not scratch space.
+7 -1
View File
@@ -50,7 +50,13 @@ pointed at.
the session launches straight away on that model — nothing to choose. With two or more, a
small dialog asks which one to use for this launch before starting the session; the
endpoint's default model, if set, is marked but not auto-picked, so a launch can deliberately
use a different one without changing the saved default.
use a different one without changing the saved default. The list is not raw discovery order
either: the model llama-swap reports loaded and ready is moved to the top and tagged
**Currently loaded**, and when nothing is loaded, the model you last launched on this harness
and endpoint pair is moved up instead and tagged **Last used** (a per-device browser value, so
another device starts from its own history). The default model keeps its own **Default** pill
in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your
thumb.
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP, picking an entry launches
straight onto the endpoint** — no restart, because the endpoint is applied before the
+29
View File
@@ -118,6 +118,35 @@ invisible from the host (`pi -c` and `grok -c` inside a docker case see only tha
container's history). OMP's `sessions/` is the exception and is shared read-write, because
Codeman reads it host-side for history and resume.
**Git hosts.** The agent image can also include the GitHub CLI (`gh`) and the Azure CLI (`az`,
with the `azure-devops` extension), off by default, and its git then uses them as credential
helpers for github.com and Azure DevOps. When the matching switch is on, their sign-ins are
seeded like everything else, file by file: `~/.config/gh/hosts.yml` and `config.yml`, and the
sign-in files from `~/.azure` (not its logs or extensions). With a switch off they are never
copied in, even if the files exist. So once a switch is on and `gh auth login` / `az login`
have been run where Codeman runs, agents in a Docker case can clone and push private repos on
those hosts. Two limits:
- A token held in a desktop keyring or an encrypted token cache (Windows, macOS) is not
inside those files and does not carry in. Sign in inside the container instead. The Docker
server image and a headless Linux host keep it in the files, so they carry.
- The sign-ins are mounted when a case container is **created**, so an existing container
never picks them up. After turning a switch on, signing in, or rebuilding the agent image,
**recreate the case container**: remove it, and the next session in that case creates a
fresh one. (Or sign in inside the existing container instead.)
This hands a GitHub token and an Azure sign-in to every agent in a seeded Docker case, the
same trust you already give it with Claude, Codex or gcloud. Turn seeding off for a case that
should not have them.
Both CLIs are opt-in. To build the agent image with them, set
`CODEMAN_AGENT_IMAGE_INSTALL_GH=1` and/or `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` where the image
is built: in front of `node scripts/build-agent-image.mjs`, or in the Codeman server's
environment for the image it builds automatically (in the Docker deployment, `environment:`
in `docker-compose.override.yml`), then rebuild the image with `--no-cache`.
`docker/README.md` ("Private repositories") has the details and the matching switches for
the server image.
## Isolation
Every container runs hardened by default:
+47 -13
View File
@@ -24,16 +24,22 @@ This installs Node.js, tmux and a build toolchain if they are missing (node-pty
Linux prebuild, so it compiles from source), clones Codeman into `~/.codeman/app`, and
builds it.
What it asks you:
It starts by printing what it found (git, Node, tmux, build tools, agent CLIs, Tailscale,
an existing install), then asks everything it needs up front, then does the work
unattended. You can leave while it builds. What it asks you:
1. **Permission for every system change.** Package installs and agent CLI downloads are
prompted individually. Nothing is installed silently. If no agent CLI is found, a menu
offers to install any of them (DeepSeek excepted: its npm package installs only a
launcher with no runnable profile), or you skip and install one yourself later.
1. **One consent for the missing packages.** Git, Node.js, tmux and (on Linux) the build
toolchain are installed after a single yes, and sudo asks for your password once for
the whole run. Nothing is installed silently. If no agent CLI is found, a menu offers
to install any of them (DeepSeek excepted: its npm package installs only a launcher
with no runnable profile), or you skip and install one yourself later.
2. **How the dashboard should be reachable.** Three choices:
- **Tailscale** (recommended for phone access): keeps the loopback bind and walks you
through `tailscale serve`, including the tailnet HTTPS toggle, then verifies the result
end to end.
- **Tailscale** (recommended for phone access): keeps the loopback bind, installs
Tailscale if needed, logs in, enables the tailnet HTTPS toggle (it opens the admin
page for you and waits; Ctrl+C there skips Tailscale for this run), then configures `tailscale serve` after the build and
verifies the result end to end. If another app already owns `:443` on your node,
you choose between a sub-path (`https://<machine>.<tailnet>.ts.net/codeman`, the
default), a second port, replacing the other mapping, or skipping.
- **Your local network** (`0.0.0.0`): prompts for a password. Skipping the password takes
an explicit confirmation and ends on a loud warning.
- **This machine only** (`127.0.0.1`): the safest option, and the default for a bare
@@ -44,26 +50,54 @@ What it asks you:
Tailscale. An existing loopback install defaults to keeping loopback, or to Tailscale when
a serve mapping for Codeman is already there. A bare Enter never pulls in new software,
and a non-interactive run always keeps the safe loopback default.
3. **What to do when it finishes.** Run in this terminal, install as a background service
that starts on boot, or do nothing yet.
3. **What to call this machine on your tailnet** (Tailscale route only). By default the URL
uses the machine's existing name. Answer yes to rename it `codeman-<hostname>`; the
default is no, because the tailnet name is also what SSH and everything else on that
machine are reached by.
4. **Whether to run Codeman in the background.** Enter installs a systemd user service or a
macOS LaunchAgent that starts on boot; answering no offers to start it in this terminal
instead, or not at all.
It ends on a screen with the URL (your tailnet, your network, or this machine), a QR code to
scan with your phone, and the two commands you need to manage the service.
Re-running the same one-liner **updates an existing install in place**. Local changes in
`~/.codeman/app` are stashed rather than discarded, a running service is restarted and
verified, and your existing network binding is preserved. An interrupted first install
resumes instead of restarting.
Two other entry points exist:
Other entry points:
```bash
install.sh status # print the URLs, the QR code and the manage commands again
install.sh update # update only
install.sh uninstall # remove
install.sh uninstall # remove (offers to undo a rename it performed)
install.sh tailscale # retrofit Tailscale access onto an existing install
install.sh name [<n>] # rename this machine on your tailnet (default codeman-<hostname>)
install.sh cloudflared # install cloudflared for the in-app Cloudflare tunnel
```
**Flags** answer the questions from the command line and pipe through `bash -s --`:
```bash
curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service
curl -fsSL https://getcodeman.com/install | bash -s -- --lan --password 'x' --service
curl -fsSL https://getcodeman.com/install | bash -s -- --local --run
```
`--tailscale` / `--lan` / `--local` answer the access question, `--name <n>` / `--no-rename`
the name, `--service` / `--run` / `--no-start` the last one. `--yes` takes every default
(it still waits on a Tailscale login URL, and a network bind still asks for a password).
`--port <n>` moves Codeman off 3000; the service file and the serve mapping follow it. On an
existing install, `--port` and `--password` re-run the setup so the service file picks them up,
and a re-run with `--lan` or `--tailscale` keeps the password the service already has.
**Automation and CI**: with no terminal attached, any step that would change the system
aborts with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to
approve those steps. `CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
installs Tailscale itself non-interactively.
installs Tailscale itself non-interactively; a non-interactive run never renames the
machine and never starts a service. Everything the unattended steps print goes to
`~/.codeman/install.log`, and the last lines of it are shown when a step fails.
## Route B: npm
+12 -6
View File
@@ -60,14 +60,20 @@ On by default; it can be turned off in settings.
A row of keys above the virtual keyboard, and what it contains depends on the session.
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a clipboard key, `Esc`,
a path picker, an image key, and 🧠 when Read My Mind is on. Destructive commands need a
double press, so you cannot fire `/clear` with a stray thumb. On Codex sessions the bar also
shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to editing the last queued
message and walking the prompt stack.
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a Compose key, `Esc`,
a path picker, and 🧠 when Read My Mind is on. Compose opens a multiline editor with
autocorrect: Enter adds a new line, and only Send delivers the text, as one paste followed
by Enter, so your line breaks reach the agent intact. Anything already typed on the terminal
prompt moves into the editor when it opens. Drafts are kept per session and in memory only,
so switching tabs keeps them and a page reload forgets them; a dot on the key shows a draft
is parked. The editor's Image button attaches photos and puts their paths into the draft.
Destructive commands need a double press, so you cannot fire `/clear` with a stray thumb. On
Codex sessions the bar also shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to
editing the last queued message and walking the prompt stack.
**Shell sessions** automatically swap it for terminal controls: `Ctrl`, `Esc`, `Tab`, four
arrows, paste, and dismiss. Your normal preference is remembered and restored when you
arrows, a direct Paste key (shell input is not an agent prompt, so there is no Compose
there), and dismiss. Your normal preference is remembered and restored when you
switch back to an agent session, so a settings change during a shell session cannot strip
the bar away permanently.
+24
View File
@@ -103,6 +103,30 @@ locked phone and the agent continues.
With the inbox off, the buttons are stripped from the notification payload entirely rather
than being shown and failing.
## When a session is watching its own work
An agent that starts a monitor, puts a shell in the background or hands a task to a cloud
session is told by its CLI to end the turn and wait to be notified. The pane then goes
quiet, and the CLI's idle notification arrives about a minute later — for a session that
wants nothing from you.
Codeman reads what the CLI prints about its own background work and treats that prompt
differently. It raises no tab alert, no desktop notification and no push, the session stays
out of NEEDS YOU on every surface, and the row wears a blue **watching** badge instead. Hover
it, or read it on a phone through your screen reader, and it says what is running: "1
monitor", "2 shells", "1 background terminal".
The prompt itself is not thrown away. It sits in the Approvals drawer as an ordinary card,
still answerable, with a line reading "quiet, watching 1 monitor" where a card you had
already looked at would say nothing. The next time that session goes quiet for an ordinary
reason, it alerts you exactly as before.
Two limits are worth knowing. A permission prompt or a question dialog still goes red
whatever else the agent started, because that one blocks it outright. A question asked in
plain prose is not a dialog, so an agent that starts a monitor and then writes "which branch
should I target?" is quiet along with the rest — check a watching session yourself if it has
been quiet longer than the work it is waiting for should take.
## The phone overview
On phones, tapping the "C" logo gives a session overview with **NEEDS YOU** first, then
+1 -1
View File
@@ -43,7 +43,7 @@ To make a new one, click **+** next to the picker. The Add Case dialog has three
| Tab | Use it when |
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. |
| **Clone Repo** | Working on an existing public repo. Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
| **Clone Repo** | Working on an existing repo: public, or private once this machine's git can authenticate (the Docker image can include `gh`/`az` helpers for this). Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |
The gear next to the picker holds two per-case toggles: **Agent Teams** and
+24 -3
View File
@@ -33,11 +33,12 @@ Your devices join a private network, and Codeman stays bound to loopback. Nothin
published to the internet, and you get real HTTPS with a real certificate.
The installer sets this up for you, including installing Tailscale, logging in, enabling
tailnet HTTPS, and verifying the result end to end. To retrofit it onto an existing
install:
tailnet HTTPS, and verifying the result end to end. It ends on the URL with a QR code to
scan. To retrofit it onto an existing install, or to see the URL and QR code again:
```bash
install.sh tailscale
install.sh status
```
By hand:
@@ -49,6 +50,22 @@ tailscale serve status
Then open `https://<machine>.<tailnet>.ts.net` from any device on your tailnet.
### The name in the URL
The URL is the machine's MagicDNS name, so on a machine called `tnode` it is
`https://tnode.<tailnet>.ts.net`. Three ways to influence that, from least to most work:
| You want | How |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| The machine's existing name (default) | Nothing. This is what the installer does unless you say otherwise. |
| `https://codeman-<hostname>.<tailnet>.ts.net` | Answer yes to the installer's name question, pass `--name codeman-<hostname>`, or run `install.sh name`. This renames the machine tailnet-wide (SSH included), which is why the installer defaults to no. `install.sh uninstall` offers to rename it back. |
| `https://codeman.<tailnet>.ts.net` | A [Tailscale Service](https://tailscale.com/docs/features/tailscale-services). Only a **tagged** node can host one (a device signed in with a user account cannot), the service is defined and approved in the admin console, and the feature is in beta. The installer does not set this up; it is a `tailscale serve --service=svc:codeman --https=443 127.0.0.1:3000` on a tagged host once the service exists. |
If `:443` on your node already belongs to another app, the installer offers Codeman under
`https://<machine>.<tailnet>.ts.net/codeman` (the default, via `tailscale serve --set-path`
plus Codeman's `--base-url`), on a second port (`https://<machine>.<tailnet>.ts.net:8443`),
or replacing the other mapping. It never replaces anything without asking.
Notes:
- Keep the loopback bind. `tailscale serve` connects to `127.0.0.1:3000` locally, so
@@ -58,7 +75,11 @@ Notes:
- Codeman's Host-header allowlist already accepts `.ts.net`, so no extra configuration is
needed.
- The installer never resets or rewrites `serve` mappings other than the one pointing at
Codeman's port, so unrelated serve configuration is left alone.
Codeman's port, so unrelated serve configuration is left alone. It also never opens a
`tailscale funnel` (that is the public internet) and never advertises a Tailscale Service.
- On macOS, the App Store and standalone Tailscale apps only run once someone is logged in,
so a headless Mac needs the open-source `tailscaled` for the URL to come back after a
reboot on its own.
## Cloudflare tunnel
+8
View File
@@ -67,6 +67,14 @@ On Linux, if you want the service running while you are not logged in:
loginctl enable-linger $USER
```
On macOS, a LaunchAgent starts when you log in, not at boot. A headless Mac (no GUI login)
needs a system LaunchDaemon instead, written by hand as root. The installer recognises an
existing `/Library/LaunchDaemons/com.codeman.web.plist` and leaves it alone rather than
installing a LaunchAgent next to it, since the two would fight over the port; remove the
daemon first if you want to switch. The same login caveat applies to the App Store and
standalone Tailscale apps, so on a headless Mac the Tailscale URL only comes back after a
reboot if the open-source `tailscaled` is used.
### Writing the unit by hand
**Linux (systemd user unit):**
+5 -2
View File
@@ -46,6 +46,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
| Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. |
| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. |
| Auto Copy Selection | Off | Copies highlighted terminal text to the clipboard the moment you finish selecting it. Ctrl+C still copies on demand. |
| Trim The Pane Margin On Copy | On | Takes the left margin a full-screen agent CLI paints down its own edge off a copy, so the text pastes flush. Each CLI declares its own width, and the strip never exceeds the indent every selected line shares, so nesting is kept. Claude Code and Codex declare a margin; a shell does not. |
| Normal / Bold font weight | xterm defaults | Per device, each slot from 100 to 900. The bundled JetBrains Mono renders every step, so a lighter normal weight makes Claude's bold headings stand out. Applies live to the terminal, both echo overlays and open team panes. |
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
@@ -55,12 +56,14 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
Chips for every optional header control, with a live preview of the resulting header:
Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session
Manager, Attachments, File Viewer, Multi-monitor, Plan Usage, Lifecycle Log, Monitor,
Manager, Attachments, File Viewer, Multi-monitor, Split, Plan Usage, Lifecycle Log, Monitor,
Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents,
Ultracode Windows, Cron.
Most default to off. The stock desktop header is system stats, File Viewer, and the gear.
New header controls never appear on phones.
New header controls never appear on phones. Split is desktop-only regardless of this
setting — the button and the feature both stay off below a ~1180px viewport, where two
resizable panes plus their divider have nowhere to go.
This section also holds background-agent tracking, including whether to track agents for
every session or only the active tab.
+2
View File
@@ -48,6 +48,7 @@ One tab per session, in your order, and that order syncs across your devices.
| Yellow tab, blinking | The agent is waiting for input from you. |
| Red tab, blinking | A question or permission prompt is blocking the session. |
| No dot | The session is not running. |
| Muted grey dot plus an `exited (137)` badge | The agent inside the pane has exited, with that exit code (or `exited (signal 9)`). A bare `exited` means tmux saw the pane die but did not report how, which is not the same as a clean `exited (0)`. Detailed sidebar and rail rows read `exited` in their pill. |
![Tab alerts](https://raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/tab-alerts-20260815.png)
@@ -116,6 +117,7 @@ The right side of the header. Almost all of these are off until you enable them
| Lifecycle Log | Off | Session start, exit, and kill audit trail. |
| Cron ⏰ | Off | Scheduled jobs. |
| Multi-monitor | Off, macOS | Opens a window spanning every display. |
| Split | Off, desktop only | View a second session beside the active one, with a draggable divider. |
| Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. |
| Admin panel | Multi-user only | User administration. |
+1478 -553
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.31.0",
"version": "1.32.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.31.0",
"version": "1.32.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -55,6 +55,7 @@
"@types/web-push": "^3.6.4",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.1.8",
"@xterm/headless": "^6.0.0",
"agent-browser": "^0.6.0",
"esbuild": "^0.27.3",
"eslint": "^9.0.0",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.31.0",
"version": "1.32.1",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
@@ -123,6 +123,7 @@
"@types/web-push": "^3.6.4",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.1.8",
"@xterm/headless": "^6.0.0",
"agent-browser": "^0.6.0",
"esbuild": "^0.27.3",
"eslint": "^9.0.0",
+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 only inside a Codeman-managed session.",
"version": "1.31.0",
"version": "1.32.1",
"author": {
"name": "Ark0N",
"url": "https://github.com/Ark0N"
+34
View File
@@ -146,10 +146,44 @@ console.log('\n[build] content-hash cache busting');
html = html.replaceAll(`"${original}"`, `"${hashed}"`);
}
writeFileSync(join(distPublic, 'index.html'), html);
// Rewrite sw.js from the SAME manifest that just renamed the files.
//
// The service worker's precache list used to be maintained by hand with the
// pre-hash names, so after this step every entry in it pointed at a file that
// no longer existed and `cache.add(...).catch(() => {})` hid it. Deriving it
// here is the only way the two cannot drift.
//
// The cache key gets the build hash for the same reason: `activate` deletes
// every cache that is not the current one, so a constant key meant that
// cleanup never ran and hashed assets from every past release piled up.
const swPath = join(distPublic, 'sw.js');
let sw = readFileSync(swPath, 'utf8');
const hashedAssets = Object.values(manifest);
const buildId = createHash('md5').update(hashedAssets.join('|')).digest('hex').slice(0, 12);
// Rewrite the two declarations. Anchored on the full `const … = …;` text so
// each pattern occurs exactly once and cannot collide with prose in sw.js's
// own comments — an earlier cut used bare `__BUILD_ID__` sentinels and the
// first match landed in the comment that documented them, leaving the real
// constant untouched and still producing a plausible-looking cache key.
const swEdits = [
["const BUILD_ID = 'dev';", `const BUILD_ID = '${buildId}';`],
['const HASHED_ASSETS = [];', `const HASHED_ASSETS = [${hashedAssets.map((p) => JSON.stringify(p)).join(', ')}];`],
];
for (const [from, to] of swEdits) {
const hits = sw.split(from).length - 1;
if (hits !== 1) {
throw new Error(`sw.js: expected exactly one \`${from}\`, found ${hits} — precache would ship stale`);
}
sw = sw.replace(from, to);
}
writeFileSync(swPath, sw);
console.log(' Hashed files:');
for (const [orig, hashed] of Object.entries(manifest)) {
console.log(` ${orig} -> ${hashed}`);
}
console.log(` sw.js: cache bucket codeman-${buildId}, ${hashedAssets.length} precached assets`);
}
// 6. Compress with gzip + brotli
+30 -3
View File
@@ -55,9 +55,36 @@ export function agentImageNpmPackages(catalog) {
return packages;
}
/** The `--build-arg` pairs the agent image takes. PURE. */
export function agentImageBuildArgPairs(catalog) {
return [['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')]];
/**
* Environment variable → agent.Dockerfile ARG for the optional git-host CLIs (gh, az).
* ⚠️ Mirrored by `GIT_HOST_CLI_BUILD_ARGS` in `src/docker-hosts.ts`; the parity test pins them.
*/
export const GIT_HOST_CLI_BUILD_ARGS = [
['CODEMAN_AGENT_IMAGE_INSTALL_GH', 'CODEMAN_INSTALL_GH'],
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
];
/**
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
* as before these existed; anything other than 0/1 is refused rather than guessed at.
*/
export function gitHostCliBuildArgPairs(env) {
const pairs = [];
for (const [envName, argName] of GIT_HOST_CLI_BUILD_ARGS) {
const value = env[envName];
if (value === undefined || value === '') continue;
if (value !== '0' && value !== '1') {
throw new Error(`${envName} must be 0 or 1, got ${JSON.stringify(value)}`);
}
pairs.push([argName, value]);
}
return pairs;
}
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
export function agentImageBuildArgPairs(catalog, env = process.env) {
return [['CLI_NPM_PACKAGES', agentImageNpmPackages(catalog).join(' ')], ...gitHostCliBuildArgPairs(env)];
}
/** Read the committed catalogue. IO. */
+38
View File
@@ -294,6 +294,23 @@ const capabilitiesSchema = z
effort: z.boolean(),
agentSkillInjection: z.boolean(),
statusLineTelemetry: z.boolean(),
// How many columns this CLI indents its transcript body by, so a copy can take
// that much off the clipboard. Bounded, because it is the whole strip: a copy
// never removes more than this, nor more than every selected line shares.
//
// ⚠ DECLARED, not measured off the pane, and two measured attempts are why.
// Asking whether the pane painted spaces across the unused part of each row
// separates a TUI from a shell perfectly where it fires and never
// over-stripped, but it is a function of pane WIDTH: that padding exists
// only while a rendered line stops short of the CLI's own layout width, and
// Claude Code's prose wraps to fill it — the share of padded rows on one
// live transcript ran 44%, 6%, 6%, 7% and 87% at 123, 160, 198, 235 and 298
// columns, so the strip did nothing at any ordinary size. Taking the
// narrowest indent on screen instead fires everywhere and over-strips, since
// a file listing inside the transcript can be the narrowest thing on it.
// A declared width cannot do either. Absent means no strip, so a CLI whose
// transcript layout nobody has measured is never touched.
transcriptGutter: z.number().int().min(1).max(8).optional(),
workDetect: z
.object({
promptGlyph: z.string().min(1).max(8),
@@ -308,8 +325,29 @@ const capabilitiesSchema = z
(src) => compileVersionRegex(src) !== null,
'workingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
),
// Same guard, same reasons: this one runs over the foot of a pane capture every
// time a session settles, and ~/.codeman/clis.json can set it.
watchingLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null,
'watchingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
// Bounded hard: this is how far up the screen a config file may push the search,
// and every row it adds is one more row the agent itself may be able to write.
watchingLines: z.number().int().min(1).max(8).optional(),
})
.strict()
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
// time for the same reason `privilegedParams[].param` is checked against the params
// the entry declares: the failure is otherwise silent and looks like a feature that
// simply never fires.
.refine(
(v) => v.watchingLines === undefined || v.watchingLine !== undefined,
'watchingLines has nothing to bound without a watchingLine'
)
.optional(),
model: z
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
+82 -13
View File
@@ -75,11 +75,24 @@ function agentDefaults(): Pick<
};
}
// `accent` on every entry below (except SHELL, which the frontend renders no
// distinct color for) is measured from the actual `.btn-toolbar.btn-run.mode-<id>`
// CSS rule's `border-color` on the OG skin (styles.css) — the single cleanest
// representative hex each entry's own multi-stop gradient resolves around.
// Corrected 2026-09-21 after PR #458's review found several were simply wrong
// (e.g. claude was registered as Anthropic's brand orange, `#d97757`, but the
// button renders blue): `docs/cli-registry.md`'s own "transcribed, not
// authoritative, re-measure before wiring one up" warning for this
// DECLARED-FOR-LATER field, taken literally. The one exception is GEMINI, whose
// run-button border (#60a5fa) is the only one that disagrees with its own tab badge
// and run-mode dot (#8ab4f8); it takes the badge colour, so every accent names the
// same hex the frontend uses as that CLI's flat identity. This is a data-accuracy fix only —
// `accent` still has no reader, so nothing rendered changes because of it.
const CLAUDE: CliEntry = {
id: 'claude' as CliEntry['id'],
label: 'Claude',
shortBadge: 'CC',
accent: '#d97757',
accent: '#3b82f6',
enabled: true,
stock: true,
order: 0,
@@ -200,12 +213,31 @@ const CLAUDE: CliEntry = {
},
capabilities: {
external: false,
// Claude indents its transcript body two columns and puts its own ●/✻/❯ markers
// in them, so a copy can drop two and paste flush. Claude and codex are the only
// entries that declare this, because theirs are the only gutters that have been measured.
transcriptGutter: 2,
// The historical hard-coded pair, now stated as data. `workingLine` matches both the
// `✻ Actualizing… (39s · ↓ 2.0k tokens)` status line and the bare `esc to interrupt`
// footer, because tmux repaints partially and only one of the two may land in a chunk.
workDetect: {
promptGlyph: '❯',
workingLine: String.raw`…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b|esc to interrupt`,
// Claude prints what it started in the background on the footer row beneath its
// composer, as `⏵⏵ bypass permissions on · 1 monitor · ← for agents`. The labels are
// the CLI's own words for each kind of background task, and group 1 is the one
// Codeman badges the session with. Verified against a live 2.1.278 pane on
// 2026-09-21.
// ⚠️ Two things keep an agent from writing its own label here, and both matter.
// The footer is the LAST row, so the default one-row window (`WATCHING_TAIL_LINES`)
// holds nothing but Ink's own chrome — in particular it leaves out the status line
// directly above, whose content comes from a `statusLine` command a bypassed
// session can write into its own `.claude/settings.json`. And the leading `·` keeps
// the match on the footer's own item list rather than on any text that happens to
// carry a count. A footer that ever drew the chip as its only item would report no
// watching rather than open that door. See `watchingLabel()` in
// `session-activity.ts`.
watchingLine: String.raw`·\s*(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?|Artifact comment monitors?))`,
},
requiresMux: false,
// Claude installs Codeman's own hooks block into every workspace it runs in, so its
@@ -368,7 +400,7 @@ const OPENCODE: CliEntry = {
id: 'opencode' as CliEntry['id'],
label: 'OpenCode',
shortBadge: 'OC',
accent: '#f59e0b',
accent: '#10b981',
enabled: true,
stock: true,
order: 10,
@@ -454,7 +486,7 @@ const CODEX: CliEntry = {
id: 'codex' as CliEntry['id'],
label: 'Codex',
shortBadge: 'CX',
accent: '#6b7fd7',
accent: '#a855f7',
enabled: true,
stock: true,
order: 20,
@@ -518,7 +550,43 @@ const CODEX: CliEntry = {
// `Working (2m 49s • esc to interrupt)` above it while a turn runs. It animates no
// braille spinner, and it never prints `esc to interrupt` at rest, so that phrase
// alone separates a running turn from an idle one.
workDetect: { promptGlyph: '›', workingLine: '[Ee]sc to interrupt' },
// Codex pins a row of its own while a background terminal it started is still
// running: ` 1 background terminal running · /ps to view · /stop to close`. Unlike
// Claude's footer chip that row sits ABOVE the composer, which puts it third from the
// bottom once the status line and the composer are counted, hence `watchingLines`.
// Measured against a live codex-cli 0.154.0 pane on 2026-09-22: the row appears when
// the terminal starts, follows the composer down as the conversation grows, and is
// gone after `/stop`.
// ⚠️ This entry CANNOT promise what Claude's does, and the difference is Codex's
// layout rather than its pattern. The third row from the bottom is the chip only
// while a terminal runs; with none running it is the last row of the transcript,
// which the agent writes. Matching the complete row raises the bar — an assistant
// message has to end with this exact line, to the character — but nothing here makes
// forging it impossible, so do not read the Claude comment above as applying here.
// What contains it is that codex declares `hooks: 'none'`: no hook event from a codex
// session ever reaches `notePrompt()`, so there is no idle item to pre-acknowledge
// and a forged label costs a wrong badge and nothing else. A CLI that gains hook
// signals must not keep a pattern this soft.
// ⚠️ Background TERMINALS are the only background work codex advertises on screen.
// A sub-agent started without waiting outlives the turn just as a terminal does —
// measured 2026-09-22, the sandboxed process was still running — and the pane shows
// nothing at all for it: the last rows are the composer and the status line, and
// `Sub-agents running` lives in the on-demand `/subagents` panel, not above the
// composer. So a codex session waiting on a sub-agent reads as plainly idle here.
// Nothing is misfiled by that (codex raises no idle prompts), and there is no row to
// match until codex pins one.
workDetect: {
promptGlyph: '›',
workingLine: '[Ee]sc to interrupt',
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
watchingLines: 3,
},
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
// 160, 198, 235 and 282 columns the indents were 0, 2, 4, 6 and 8 at every one,
// never 1, so the width is not a function of the pane.
transcriptGutter: 2,
transcript: 'codex-rollout',
altScreen: 'strip-full',
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
@@ -565,7 +633,8 @@ const GEMINI: CliEntry = {
id: 'gemini' as CliEntry['id'],
label: 'Gemini',
shortBadge: 'GM',
accent: '#4285f4',
// The tab badge / run-mode-dot colour, not the run-button border (see the note above CLAUDE).
accent: '#8ab4f8',
enabled: true,
stock: true,
order: 30,
@@ -657,7 +726,7 @@ const ANTIGRAVITY: CliEntry = {
id: 'antigravity' as CliEntry['id'],
label: 'Antigravity',
shortBadge: 'AG',
accent: '#8b5cf6',
accent: '#22d3ee',
enabled: true,
stock: true,
order: 40,
@@ -727,7 +796,7 @@ const PI: CliEntry = {
id: 'pi' as CliEntry['id'],
label: 'Pi',
shortBadge: 'PI',
accent: '#10b981',
accent: '#f472b6',
enabled: true,
stock: true,
order: 50,
@@ -851,10 +920,10 @@ const GROK: CliEntry = {
shortBadge: 'GK',
// Upstream hand-authored a charcoal GRADIENT across 4+ CSS spots (welcome button, tab
// badge, run-mode dot, mobile skin overrides) rather than one flat colour; our registry's
// `accent` is a single hex, so this is the closest single value (the run-mode-dot colour,
// zinc-400). Nothing reads `accent` yet — the frontend is untouched in this change and
// keeps its own hand-authored CSS; the field is here so the entry is complete.
accent: '#a1a1aa',
// `accent` is a single hex, so this is the closest single value (zinc-300, the run-button
// border and tab-badge colour). Nothing reads `accent` yet: the frontend keeps its own
// hand-authored CSS; the field is here so the entry is complete.
accent: '#d4d4d8',
enabled: true,
stock: true,
order: 70,
@@ -986,7 +1055,7 @@ const DEEPSEEK: CliEntry = {
id: 'deepseek' as CliEntry['id'],
label: 'DeepSeek',
shortBadge: 'DS',
accent: '#4d6bfe',
accent: '#7c93ff',
enabled: true,
stock: true,
order: 80,
@@ -1151,7 +1220,7 @@ const OMP: CliEntry = {
id: 'omp' as CliEntry['id'],
label: 'OMP',
shortBadge: 'OM',
accent: '#7c9cf5',
accent: '#818cf8',
enabled: true,
stock: true,
order: 90,
+48 -1
View File
@@ -344,7 +344,48 @@ export interface CliCapabilities {
promptGlyph: string;
/** Source of a regex matching the status line this CLI draws while a turn runs. */
workingLine: string;
/**
* Source of a regex matching the row this CLI draws while work it started in the
* background is still running, e.g. Claude's `· 1 monitor ·` footer chip or Codex's
* `1 background terminal running · /ps to view`. Capture group 1 is the label Codeman
* shows, and the whole match stands in when the pattern declares no group. A CLI that
* omits this reports no background work, which is what every CLI did before the field
* existed.
*/
watchingLine?: string;
/**
* How many rows at the FOOT of the screen that row can appear in, counting non-blank
* rows only. Claude writes its chip on the last row and keeps the default; Codex pins
* its own above the composer, which puts it third from the bottom, so it declares
* more. Keep each number as small as that CLI's layout allows: every extra row is
* another row an agent might be able to write, and the label is what silences an
* alert. See `watchingLabel()` in `session-activity.ts`.
*/
watchingLines?: number;
};
/**
* How many columns this CLI indents its transcript body by, so a copy taken from its
* pane can drop that much and paste flush. Claude Code indents two and puts its own
* markers in those columns.
*
* ⚠ DECLARED rather than measured off the pane, and two measured attempts are why.
* Asking whether the pane painted real spaces across the unused part of each row
* separates a TUI from a shell perfectly where it fires and never over-stripped; it
* is also a function of pane WIDTH, because that padding exists only while a
* rendered line stops short of the CLI's own layout width and Claude Code's prose
* wraps to fill it. On one live transcript the share of padded rows ran 44%, 6%, 6%,
* 7% and 87% at 123, 160, 198, 235 and 298 columns, so at any ordinary window size
* the strip silently did nothing. Taking the narrowest indent on the surrounding
* rows instead fires at every width and over-strips on roughly 1% of selections,
* because a file listing inside the transcript can be the narrowest thing on screen.
*
* A declared width can do neither. The strip is the lesser of this and what every
* selected line shares, so a block can only ever shift as a unit, and it can never
* shift further than the CLI itself says its gutter is.
*
* Absent means no strip at all, the same fail-safe direction `workDetect` takes.
*/
transcriptGutter?: number;
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
requiresMux: boolean;
/**
@@ -651,7 +692,13 @@ export interface CliEntry {
label: string;
/** Two-ish character tab badge, e.g. 'OC'. */
shortBadge: string;
/** Single hex colour. CSS derives every per-CLI gradient from it via --cli-accent. */
/**
* Single hex colour, measured from the CLI's actual `.btn-toolbar.btn-run.mode-<id>`
* gradient in styles.css (see stock.ts's comment above `CLAUDE` for the exact
* methodology). DECLARED-FOR-LATER (above) — no code reads this yet; styles.css's
* gradients are still hand-authored per id, not derived from this field via any
* CSS custom property. There is no `--cli-accent` variable in the codebase.
*/
accent: string;
enabled: boolean;
/** Set by the loader from the shipped catalog; a user entry can never claim it. */
+76 -5
View File
@@ -606,9 +606,36 @@ export function agentImageNpmPackages(): string[] {
return packages;
}
/** The `--build-arg` pairs the agent image takes. */
export function agentImageBuildArgPairs(): Array<[string, string]> {
return [['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')]];
/**
* Environment variable → agent.Dockerfile ARG for the optional git-host CLIs (gh, az).
* ⚠️ Mirrors `GIT_HOST_CLI_BUILD_ARGS` in `scripts/lib/cli-catalog.mjs`; the parity test pins them.
*/
export const GIT_HOST_CLI_BUILD_ARGS: ReadonlyArray<readonly [string, string]> = [
['CODEMAN_AGENT_IMAGE_INSTALL_GH', 'CODEMAN_INSTALL_GH'],
['CODEMAN_AGENT_IMAGE_INSTALL_AZ', 'CODEMAN_INSTALL_AZ'],
];
/**
* The `--build-arg` pairs for the optional git-host CLIs. PURE. An unset or empty variable
* contributes NOTHING, so the Dockerfile's own default (off) applies and the argv is the same
* as before these existed; anything other than 0/1 is refused rather than guessed at.
*/
export function gitHostCliBuildArgPairs(env: NodeJS.ProcessEnv): Array<[string, string]> {
const pairs: Array<[string, string]> = [];
for (const [envName, argName] of GIT_HOST_CLI_BUILD_ARGS) {
const value = env[envName];
if (value === undefined || value === '') continue;
if (value !== '0' && value !== '1') {
throw new Error(`${envName} must be 0 or 1, got ${JSON.stringify(value)}`);
}
pairs.push([argName, value]);
}
return pairs;
}
/** The `--build-arg` pairs the agent image takes. PURE given `env`. */
export function agentImageBuildArgPairs(env: NodeJS.ProcessEnv = process.env): Array<[string, string]> {
return [['CLI_NPM_PACKAGES', agentImageNpmPackages().join(' ')], ...gitHostCliBuildArgPairs(env)];
}
// ========== Credential mount resolution (IO) ==========
@@ -785,6 +812,14 @@ interface CredStorePolicy {
seedFiles?: string[];
/** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */
seedWhole?: boolean;
/**
* Seed this store ONLY when this environment variable is exactly `1`, read when
* the container is created. For credentials that belong to an opt-in tool rather
* than to an agent CLI every case already trusts: they are not inert just because
* the image lacks the tool (a gh `hosts.yml` token or an Azure refresh token is
* usable by anything in the container, and the agent in it is prompt-injectable).
*/
enabledByEnv?: string;
}
const CRED_STORES: CredStorePolicy[] = [
@@ -829,6 +864,31 @@ const CRED_STORES: CredStorePolicy[] = [
},
{ rel: '.config/gcloud', seedWhole: true },
{ rel: '.config/opencode', seedWhole: true },
// GitHub CLI: `hosts.yml` holds the token wherever no system keyring exists (the
// Docker server image, a headless Linux host), `config.yml` the preferences. An
// agent image built with CODEMAN_INSTALL_GH=1 routes github.com git credentials
// through `gh`, so this seed is what lets an agent clone/push a private repo. A
// token that lives in a desktop keyring is not in `hosts.yml` and does not carry
// in; sign `gh` in inside the container. OPT-IN: seeded only when the same switch
// that builds gh into the agent image is on, never merely because the file exists.
{ rel: '.config/gh', seedFiles: ['hosts.yml', 'config.yml'], enabledByEnv: 'CODEMAN_AGENT_IMAGE_INSTALL_GH' },
// Azure CLI: only the sign-in state. `~/.azure` also accumulates `logs/`,
// `commands/`, telemetry and (on a bare host) `cliextensions/`, none of which is
// needed to authenticate; the agent image carries its own extensions outside HOME.
// `msal_token_cache.json` is plaintext only on Linux (Windows/macOS encrypt it), so
// this carries a sign-in from the Docker server image or a Linux host.
// OPT-IN like gh: the MSAL cache holds refresh tokens for the whole Azure account.
{
rel: '.azure',
enabledByEnv: 'CODEMAN_AGENT_IMAGE_INSTALL_AZ',
seedFiles: [
'azureProfile.json',
'msal_token_cache.json',
'service_principal_entries.json',
'clouds.config',
'config',
],
},
// OMP keeps its config in `~/.omp/agent` (config.yml/mcp.json/models.yml/
// settings.yml — small, no bigger than grok's config.toml/pager.toml), but
// that dir ALSO holds agent.db/history.db/models.db (SQLite caches) and
@@ -853,10 +913,14 @@ const CRED_STORES: CredStorePolicy[] = [
* session state back into the host). Every path is existsSync-gated (on most hosts
* only a subset exists). Pure-ish IO (no writes; just existence checks + mount specs).
*/
export function resolveDockerCredentialArtifacts(home: string = homedir()): DockerClaudeArtifacts {
export function resolveDockerCredentialArtifacts(
home: string = homedir(),
env: NodeJS.ProcessEnv = process.env
): DockerClaudeArtifacts {
const mounts: DockerMount[] = [];
const seedCopies: DockerSeedCopy[] = [];
for (const store of CRED_STORES) {
if (store.enabledByEnv && env[store.enabledByEnv] !== '1') continue;
const hostBase = join(home, store.rel);
if (!existsSync(hostBase)) continue;
const containerBase = `${CONTAINER_HOME}/${store.rel}`;
@@ -1136,10 +1200,17 @@ function buildAgentImage(
error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`,
});
}
let buildArgPairs: Array<[string, string]>;
try {
buildArgPairs = agentImageBuildArgPairs();
} catch (err) {
// A malformed CODEMAN_AGENT_IMAGE_INSTALL_* value: report it like any other build failure.
return Promise.resolve({ ok: false, built: false, alreadyPresent: false, error: String((err as Error).message) });
}
const argv = dockerEngineArgv(docker);
const args = [
...argv.slice(1),
...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache, agentImageBuildArgPairs()),
...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache, buildArgPairs),
];
return new Promise<EnsureImageResult>((resolve) => {
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
+30 -6
View File
@@ -195,6 +195,8 @@ export interface CloneOptions {
/** `--depth 1`: history-less but much faster on large repos. */
shallow?: boolean;
timeoutMs?: number;
/** Clear every git credential helper for this run (see `GIT_NO_CREDENTIAL_HELPERS`). */
withoutCredentialHelpers?: boolean;
}
export type CloneResult = { ok: true; stderr: string } | { ok: false; failure: GitFailure };
@@ -436,12 +438,27 @@ export function isSafeGitRef(ref: string): boolean {
// ─── Pure: argv + env ────────────────────────────────────────────────────────
/**
* Global git options that empty the credential-helper list for one run.
*
* Every Codeman user in multi-user mode runs git as the SAME OS account, so a
* helper that account has (the Docker image's opt-in `gh`/`az` helpers, or a
* user's own `gh auth setup-git`) would read private repositories on the
* signed-in admin's behalf for anyone who can reach Clone Repo. An empty
* `credential.helper` resets the helper list, and a command-line `-c` is read
* last, so it also drops the URL-scoped `credential.<url>.helper` entries the
* image configures (verified against a real private repo: refs with the helper,
* `could not read Username` with it cleared). Public repositories are
* unaffected. It must precede the subcommand.
*/
export const GIT_NO_CREDENTIAL_HELPERS: readonly string[] = ['-c', 'credential.helper='];
/**
* argv for the clone. `--` separates flags from operands so neither the
* repository nor the destination can ever be read as an option.
*/
export function buildCloneArgs(opts: CloneOptions): string[] {
const args = ['clone'];
const args = [...(opts.withoutCredentialHelpers ? GIT_NO_CREDENTIAL_HELPERS : []), 'clone'];
// `--single-branch` is what makes "just this tag/branch" cheap on a big repo.
if (opts.ref) args.push('--single-branch', '--branch', opts.ref);
if (opts.shallow) args.push('--depth', '1');
@@ -450,8 +467,14 @@ export function buildCloneArgs(opts: CloneOptions): string[] {
}
/** argv for the preflight. `--symref` is what reveals the remote's default branch. */
export function buildLsRemoteArgs(repository: string): string[] {
return ['ls-remote', '--symref', '--', repository];
export function buildLsRemoteArgs(repository: string, opts: { withoutCredentialHelpers?: boolean } = {}): string[] {
return [
...(opts.withoutCredentialHelpers ? GIT_NO_CREDENTIAL_HELPERS : []),
'ls-remote',
'--symref',
'--',
repository,
];
}
/**
@@ -579,7 +602,7 @@ export function classifyGitFailure(stderr: string, timedOut: boolean, spawnError
return {
code: 'AUTH_REQUIRED',
message:
'That repository needs authentication. Codeman clones without credentials, so private repositories have to be cloned outside Codeman and added with Link Existing.',
"That repository needs authentication. Codeman never asks for credentials, so sign this server's git in first (for example `gh auth login` or `az login` from a shell session; the Docker image can include both, see docker/README.md), or clone it outside Codeman and add it with Link Existing.",
stderr: clean,
};
}
@@ -790,7 +813,8 @@ export function isGitAvailable(): boolean {
*/
export async function probeGitRemote(
repository: string,
timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS
timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS,
opts: { withoutCredentialHelpers?: boolean } = {}
): Promise<GitRemoteProbe> {
if (!isGitAvailable()) {
return {
@@ -800,7 +824,7 @@ export async function probeGitRemote(
failure: classifyGitFailure('', false, 'ENOENT: git not found'),
};
}
const run = await runGit(buildLsRemoteArgs(repository), timeoutMs, MAX_LS_REMOTE_BYTES);
const run = await runGit(buildLsRemoteArgs(repository, opts), timeoutMs, MAX_LS_REMOTE_BYTES);
if (run.code !== 0 || run.spawnError) {
return {
reachable: false,
+37
View File
@@ -24,6 +24,7 @@ import type {
OmpConfig,
SessionRemote,
SessionDocker,
PaneExit,
} from './types.js';
/**
@@ -56,6 +57,23 @@ export interface MuxSession {
respawnConfig?: PersistedRespawnConfig;
/** Whether Ralph / Todo tracking is enabled */
ralphEnabled?: boolean;
/**
* This record was rebuilt from the tmux socket rather than from Codeman's own
* bookkeeping, so everything on it but the name and the pid is a guess. Its
* synthetic `restored-<fragment>` id cannot find the session's `state.json`
* entry either, which means a remote or docker session rediscovered this way
* arrives with no `remote`/`docker` metadata and looks local. Anything that
* would be WRONG about such a session rather than merely vague must fail
* closed on this flag.
*
* ⚠ It is PERMANENT, not merely true for the boot that rediscovered the
* session: `saveSessions()` serializes the whole record to
* `mux-sessions.json` and `loadSessions()` restores it, so a genuinely local
* session rediscovered once stays opted out of everything keyed on this for
* the life of that record. That is the safe direction to fail, and it costs
* only the guess Codeman is declining to make.
*/
discovered?: boolean;
}
/**
@@ -180,6 +198,7 @@ export interface PaneCaptureOptions {
* - `sessionKilled` (data: { sessionId: string }) - Session terminated
* - `sessionDied` (data: { sessionId: string }) - Session died unexpectedly
* - `statsUpdated` (sessions: MuxSessionWithStats[]) - Stats refreshed
* - `paneExitsUpdated` () - A pane read finished; ask `getPaneExit()` per session
*/
export interface TerminalMultiplexer extends EventEmitter {
/** Which backend this instance uses */
@@ -294,6 +313,24 @@ export interface TerminalMultiplexer extends EventEmitter {
/** Check if the pane in a session is dead (command exited but remain-on-exit keeps it alive) */
isPaneDead(muxName: string): boolean;
/**
* What the last pane read saw of this session's agent, or `undefined` for
* UNKNOWN (Ark0N/Codeman#446). Unlike `isPaneDead()` this costs nothing: it
* reads a map the batched watcher fills, so it answers no fresher than that
* watcher's interval and the three synchronous `isPaneDead()` callers still
* need their own probe. See {@link PaneExit}.
*/
getPaneExit?(muxName: string): PaneExit | undefined;
/** Forget a session's exit observation, e.g. once its pane has been respawned. */
clearPaneExit?(muxName: string): void;
/** Start polling every pane on the socket for an exited agent. */
startPaneExitWatcher?(intervalMs?: number): void;
/** Stop the pane-exit watcher. */
stopPaneExitWatcher?(): void;
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
+67
View File
@@ -21,6 +21,8 @@
* in 12/12 windows and the four idle ones in 0/12.
*/
import { stripAnsi } from './utils/regex-patterns.js';
/**
* A gap longer than this ends a run of continuous output. Claude repaints at
* least once a second while working, so this leaves generous headroom.
@@ -91,3 +93,68 @@ export function isSustainedActivity(streak: ActivityStreak | null, streakMs: num
export function isPaneQuiet(lastActivityAt: number, now: number, silenceMs: number = IDLE_SILENCE_MS): boolean {
return now - lastActivityAt >= silenceMs;
}
/**
* How many rows at the foot of a pane capture may hold the background-work row, for a
* CLI that declares no number of its own (`capabilities.workDetect.watchingLines`).
*
* One, because the tightest window is the right default and Claude Code needs no more:
* it draws its chip on the LAST row of the screen. Blank rows are dropped before the
* window is taken, so a trailing blank costs nothing, and a CLI that ever prints a row
* BELOW its chip loses the badge rather than gaining a hole.
*
* ⚠️ The size of this window is a trust boundary, not a tidiness measure, and the row
* it excludes first is the one that taught us so: Claude's status line sits directly
* above the footer, its content comes from a `statusLine` command, and a session running
* with permissions bypassed can write that command into `.claude/settings.json` in its
* own workspace. A window of two therefore let an agent print `· 1 monitor ·` onto a row
* of its own and silence its own idle alert. Every row added here is another row
* somebody may be able to write, so widen this only for a CLI whose layout forces it,
* and never to a whole-pane search.
*/
export const WATCHING_TAIL_LINES = 1;
/** Longest label a badge will carry. A footer chip is a handful of words. */
export const MAX_WATCHING_LABEL_CHARS = 40;
/**
* What a pane says is still running in the background, e.g. `1 monitor` or `2 shells`.
*
* The CLI writes that chip while a monitor, a backgrounded shell or a cloud session it
* started is still going, which is exactly the case where the agent has ended its turn
* without wanting anything from the user. `pattern` comes from the CLI's own registry
* entry (`capabilities.workDetect.watchingLine`); group 1 is the label when the pattern
* declares one, and the whole match stands in when it does not.
*
* Each candidate row is tested on its own, bottom row first, so a pattern can anchor
* itself with `^` or `$` against a single row rather than against a joined block. Blank
* rows are dropped before the window is taken, because a CLI that leaves a blank line
* between its chrome rows would otherwise spend the window on nothing. The answer is
* stripped of ANSI and capped, because it ends up on a badge and in an approval card.
*
* @param tailLines how many non-blank rows from the bottom to look at, defaulting to
* `WATCHING_TAIL_LINES`; a CLI declares its own when its row is not the last one
* @returns the label, or null when the pane shows no background work
*/
export function watchingLabel(
paneText: string | null | undefined,
pattern: RegExp,
tailLines: number = WATCHING_TAIL_LINES
): string | null {
if (!paneText) return null;
const lines = stripAnsi(paneText)
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line !== '');
for (const line of lines.slice(-Math.max(1, tailLines)).reverse()) {
// A pattern compiled by compileVersionRegex() never carries the `g` flag, but a
// caller reaching in from a test or a config reload might, and a stale lastIndex
// would make the same screen match every other call.
pattern.lastIndex = 0;
const match = pattern.exec(line);
if (!match) continue;
const label = (match[1] ?? match[0]).trim().slice(0, MAX_WATCHING_LABEL_CHARS);
if (label) return label;
}
return null;
}
+416 -22
View File
@@ -60,8 +60,11 @@ import {
type SessionDocker,
type SessionNameSource,
type SessionWriteOptions,
type PaneExit,
} from './types.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { claudeTranscriptExists } from './utils/claude-transcript.js';
import { matchesPattern } from './config/cli-registry/patterns.js';
import { probeDockerCliVersion } from './docker-hosts.js';
import { probeRemoteCliVersion } from './remote-hosts.js';
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
@@ -81,6 +84,8 @@ import {
trackActivityStreak,
isSustainedActivity,
isPaneQuiet,
watchingLabel,
WATCHING_TAIL_LINES,
IDLE_RECHECK_MS,
PANE_PROBE_MIN_INTERVAL_MS,
PANE_PROBE_RECHECK_MS,
@@ -497,8 +502,35 @@ export class Session extends EventEmitter {
private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection)
private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe
private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read)
/**
* Background work the pane's own footer reports, e.g. `1 monitor`; null for none.
*
* Cached BESIDE `_lastPaneProbeWorking` and refreshed only by a capture that really
* happened, so it goes stale exactly as that verdict does. The probe returns its
* cached boolean without re-capturing inside `PANE_PROBE_MIN_INTERVAL_MS`, and a
* label derived from a capture nobody took would be a guess wearing a fact's clothes.
*
* ⚠️ It then FREEZES once `_confirmIdle()` concludes: `activityTimeout` is null from
* there, and nothing looks at the pane again until it produces output. That is
* correct rather than merely tolerable, because work ending repaints the pane either
* way — a monitor firing wakes the agent, and codex drops its background-terminal row
* on its own. Do not add a timer to keep this fresh; it would spend a `capture-pane`
* per idle session per tick to learn nothing.
*
* A server restart is not a hole in that either, though it looks like one: this field
* is live state and starts empty. Reconciliation re-attaches the pane, the attach
* repaint carries the composer glyph, and the idle confirmation that arms on it probes
* and re-reads the label with no input from anyone — measured 2026-09-23 on a restarted
* instance, back within ~20 s for a session whose background terminal was still
* running. A session that comes back with no label has no chip on its screen.
*/
private _watching: string | null = null;
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
private _workingLineRe: RegExp | undefined = undefined;
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
private _watchingLineRe: RegExp | null | undefined = undefined;
/** Resolved with the pattern above: how many rows at the foot of the screen to search. */
private _watchingWindow = WATCHING_TAIL_LINES;
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
@@ -542,6 +574,20 @@ export class Session extends EventEmitter {
private _mux: TerminalMultiplexer | null = null;
private _muxSession: MuxSession | null = null;
private _useMux: boolean = false;
/**
* The agent in this session's local tmux pane has exited (Ark0N/Codeman#446).
* `null` is the UNKNOWN arm of the tri-state and is what {@link setPaneExit}
* stores for every session shape the field does not apply to. See
* {@link PaneExit} for the shapes and for why an unknown answer must never be
* rendered as "alive".
*/
private _paneExit: PaneExit | null = null;
/**
* This session was rebuilt from the tmux socket rather than from Codeman's
* own records, so its `remote`/`docker` metadata is missing rather than known
* to be absent. See {@link MuxSession.discovered}.
*/
private _discoveredMuxSession = false;
// Flag to prevent new timers after session is stopped
private _isStopped: boolean = false;
@@ -718,6 +764,10 @@ export class Session extends EventEmitter {
lastSubmitAt?: number;
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
claudeSessionChain?: string[];
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
paneExit?: PaneExit;
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
discoveredMuxSession?: boolean;
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
lastActivityAt?: number;
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
@@ -870,6 +920,16 @@ export class Session extends EventEmitter {
this._remote = config.remote;
this._docker = config.docker;
this._owner = config.owner;
this._discoveredMuxSession = config.discoveredMuxSession === true;
// Restored so a record that says the agent exited survives a server restart
// rather than being blanked by the first persist after boot. It runs here
// because the scoping reads `_remote`, `_docker` and the mux fields, all of
// which are set by now. It is a claim about a pane this process has not
// looked at yet, so every path that starts or re-attaches a pane drops it
// (see `_setupOrAttachMuxSession`) and the pane-exit watcher's own tick
// replaces it with a first-hand reading. NOT the stats collector, which a
// browser panel arms and disarms — see `startPaneExitWatcher`.
this.setPaneExit(config.paneExit);
// Never self-parent: a session pointing at itself would draw a zero-length
// lineage arc under its own tab. Only reachable via the recovery path, where
// both the id and the saved parent come from disk.
@@ -1097,6 +1157,75 @@ export class Session extends EventEmitter {
return this._muxSession?.muxName ?? null;
}
/**
* True when a tmux pane's death would mean THIS session's agent has exited.
*
* Four shapes fail the test, and each would otherwise publish a death that is
* not the agent's. A direct-PTY session owns no pane at all. A remote SSH
* session's local pane holds the ssh client, whose death means a transport
* drop OR an exit, which is the ambiguity PR #355 was about. A docker case's
* local pane holds a `docker exec` into the container's own tmux.
*
* The fourth is a session rebuilt from the socket. Absent `remote`/`docker`
* normally means "this is local", but on a discovered record it only means
* "Codeman never found the metadata": the synthetic `restored-<fragment>` id
* matches no `state.json` entry, so a remote session rediscovered after
* `mux-sessions.json` was lost arrives looking local, and its next transport
* drop would be published as an agent exit. Unproven locality fails closed.
*/
private get paneExitApplies(): boolean {
if (this._discoveredMuxSession) return false;
return this._useMux && this._muxSession !== null && !this._remote && !this._docker;
}
/** What Codeman last observed of this pane's agent, or undefined for UNKNOWN. */
get paneExit(): PaneExit | undefined {
return this._paneExit ?? undefined;
}
/**
* Forget this pane's exit, on both this record and the mux layer's cache.
* Every path that starts or relaunches a command in the pane calls it, and
* the mux half also invalidates a pane read already in flight.
*
* It does not persist or broadcast by itself; the caller owns both. ⚠ That
* caller MUST persist, and the pane-exit watcher is not a fallback for it:
* the watcher's next tick reads UNKNOWN, finds this field already cleared,
* reports no change and therefore writes nothing, so a caller that only
* broadcasts leaves `state.json` saying the agent exited for as long as the
* session stays quiet. `/interactive` and `/shell` did exactly that until
* Ark0N/Codeman#446 review; both now persist on their success path.
*/
private clearPaneExitForNewPane(): void {
this.setPaneExit(undefined);
if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName);
}
/**
* Record what the mux layer observed of this pane's agent, and say whether
* that changed the answer. The caller persists and broadcasts on a true.
*
* A session the field does not apply to is forced to UNKNOWN here rather than
* at the reporting end, so the rule lives in one place and the mux layer stays
* free to report the raw pane reading its own remote-reconnect watcher needs.
*/
setPaneExit(next: PaneExit | undefined): boolean {
const resolved = this.paneExitApplies ? (next ?? null) : null;
const prev = this._paneExit;
if (prev === resolved) return false;
if (
prev !== null &&
resolved !== null &&
prev.status === resolved.status &&
prev.signal === resolved.signal &&
prev.at === resolved.at
) {
return false;
}
this._paneExit = resolved;
return true;
}
/**
* True when this session's PTY is a tmux client rather than the program itself.
* Read by the replay-side alt-screen strip, which must apply the same
@@ -1118,6 +1247,16 @@ export class Session extends EventEmitter {
return this._isWorking;
}
/**
* What the pane says is still running in the background, e.g. `1 monitor`, or null when
* nothing is. A session with a label here has ended its turn without wanting anything
* from the user, so a surface that would otherwise file it under "needs you" can say
* what it is waiting for instead.
*/
get watching(): string | null {
return this._watching;
}
/**
* Check if the session's process tree has active child processes beyond Claude itself.
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
@@ -1620,6 +1759,10 @@ export class Session extends EventEmitter {
// by the constructor: a Codeman restart starts with a fresh breaker so boot
// recovery can re-attach.
respawnBlocked: this._respawnBlocked || undefined,
// Ark0N/Codeman#446 — the agent in this pane has exited, published here so
// it rides the existing `session:updated` broadcast and lands in state.json
// through the same persist. `status` and `pid` above stay untouched by it.
paneExit: this._paneExit ?? undefined,
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
lastSubmitAt: this._lastSubmitAt || undefined,
// Only a chain the CLI's own hooks vouched for is persisted, and only when
@@ -1675,6 +1818,7 @@ export class Session extends EventEmitter {
totalCost: this._totalCost,
messageCount: this._messages.length,
isWorking: this._isWorking,
watching: this._watching,
lastPromptTime: this._lastPromptTime,
// Buffer statistics for monitoring long-running sessions
bufferStats: {
@@ -1731,41 +1875,82 @@ export class Session extends EventEmitter {
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
spawnErrLabel: string;
}): Promise<{ isRestored: boolean }> {
}): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> {
const mux = this._mux!;
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally).
// A session that HAD a mux session relaunches its CLI below just like a failed
// respawn does (tmux kill-server, a tmux crash, an external kill-session), so
// its transcript collides with the bare `--session-id` the same way. A
// genuinely new session starts with `_muxSession` null and never sets this.
let muxSessionVanished = false;
if (this._muxSession && !mux.muxSessionExists(this._muxSession.muxName)) {
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
this._muxSession = null;
muxSessionVanished = true;
}
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
let needsNewSession = false;
let respawnedDeadPane = false;
let respawnedResumeId: string | undefined;
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
// Confirmed dead — safe to resolve/pin now (see `_pinOmpRespawnId()`).
// `options.respawnPaneOptions` was built eagerly before this dead-pane
// check ran, so it still carries the pre-pin ompConfig; rebuild it.
this._pinOmpRespawnId();
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
const respawnOptions = await this._buildRespawnPaneOptionsWithResumePin();
const newPid = await mux.respawnPane(respawnOptions);
if (!newPid) {
console.error('[Session] Failed to respawn pane, will create new session');
needsNewSession = true;
} else {
respawnedDeadPane = true;
respawnedResumeId = respawnOptions.resumeSessionId;
this._pendingEnvUnsets.clear();
// Wait a moment for the respawned process to fully start
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
}
}
// Whatever the last reading said about the OLD command in this pane is now
// history: the branch above either respawned the pane or found it alive, and
// the branch below creates a new one. The paths that reach here are boot
// recovery and an explicit start, NOT a click on an exited tab — the browser
// re-attaches only on a null pid, and the premise of Ark0N/Codeman#446 is
// that an exited pane keeps its pid. `restartCli()` clears separately.
this.clearPaneExitForNewPane();
// Check if we already have a mux session (restored session)
const isRestored = this._muxSession !== null && !needsNewSession;
if (isRestored) {
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
} else {
// Create a new mux session
// Create a new mux session. When this is the FALLBACK after a failed
// respawn, the eagerly-built create options still carry the unpinned
// launch seed, so a session whose transcript exists would meet the same
// `--session-id ... already in use` refusal the respawn just lost to —
// the recovery of last resort failing for the very reason it was needed.
// A genuinely new session has no transcript under any of its candidate
// ids, so nothing is pinned and its command shape is unchanged.
//
// `_resumeSessionId` is written alongside, not just the create options:
// this branch leaves `isRestored` false, so the block that sets
// `_claudeSessionId` below reads that field and would otherwise settle on
// `this.id` while the CLI resumes the chain tail. The response viewer,
// Read My Mind and the unified-list alias map all read `_claudeSessionId`
// until the next first-hand hook, so the two have to name the same
// conversation. The vanished-tmux-session branch above relaunches for the
// same reason and takes the same pin.
if (needsNewSession || muxSessionVanished) {
const pinned = (await this._buildRespawnPaneOptionsWithResumePin()).resumeSessionId;
if (pinned) {
options.createSessionOptions.resumeSessionId = pinned;
this._resumeSessionId = pinned;
}
}
this._muxSession = await mux.createSession(options.createSessionOptions);
console.log('[Session] Created mux session:', this._muxSession.muxName);
// No extra sleep — createSession() already waits for tmux readiness
@@ -1800,13 +1985,14 @@ export class Session extends EventEmitter {
env: buildMuxAttachEnv(cliExportsTruecolor(this.mode)),
})
);
this._notePtySpawnGeometry(ptyCols, ptyRows);
} catch (spawnErr) {
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
throw spawnErr;
}
return { isRestored };
return { isRestored, respawnedResumeId, respawnedDeadPane };
}
/**
@@ -1844,6 +2030,9 @@ export class Session extends EventEmitter {
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
return false;
}
// No-op for the record (a remote session's field is always UNKNOWN), but the
// mux layer's cache is keyed by muxName and this pane now runs a new client.
this.clearPaneExitForNewPane();
console.log('[Session] reattachRemote: reattached remote session', this._muxSession.muxName, 'pid', newPid);
return true;
}
@@ -1878,26 +2067,16 @@ export class Session extends EventEmitter {
}
this._pinOmpRespawnId();
const options = this._buildRespawnPaneOptions();
// Unlike the dead-pane respawn, this one kills a WORKING pane whose conversation
// already has a transcript, and a CLI that launches with `--session-id <id>` refuses
// an id that is already in use (claude: `Error: Session ID ... is already in use.`),
// which turned an endpoint switch into a dead pane and a lost session. A launch that
// declares a `fallback` chain renders `resume || new` once a resume id is set, the
// same `--resume <id> || --session-id <id>` shape the docker and remote pane commands
// already use, so pin the live conversation id for THIS respawn only. The registry
// shape is the gate, not the CLI's name: an entry whose resume id is minted by the
// CLI itself (codex/pi/omp/grok) never declares that chain, and its resume field is
// read from its own `<Mode>Config` rather than this top-level one anyway.
if (!options.resumeSessionId && getCli(this.mode)?.launch.chain === 'fallback') {
options.resumeSessionId = this._claudeSessionId ?? this.id;
}
const newPid = await mux.respawnPane(options);
const newPid = await mux.respawnPane(await this._buildRespawnPaneOptionsWithResumePin());
if (!newPid) {
console.error('[Session] restartCli: respawnPane failed for', this._muxSession.muxName);
return false;
}
this._pendingEnvUnsets.clear();
// A relaunch in the same pane, so any exit observed of the previous command
// is history. Without this the caller's persist-and-broadcast writes the old
// exit straight back onto a session that is running again.
this.clearPaneExitForNewPane();
console.log('[Session] restartCli: restarted CLI for', this._muxSession.muxName, 'pid', newPid);
return true;
}
@@ -1947,6 +2126,112 @@ export class Session extends EventEmitter {
return this._withCustomModelLaunchModel(options);
}
/**
* Respawn options for a pane whose command is being REPLACED, with the
* conversation pinned so the relaunch resumes rather than collides.
*
* A CLI that launches with `--session-id <id>` refuses an id that is already
* in use (claude: `Error: Session ID ... is already in use.`), and every
* session whose agent has been prompted owns a transcript under that id. So
* relaunching such a pane with the bare launch line fails, the pane dies
* again immediately, and the user's conversation is stranded. A launch that
* declares a `fallback` chain renders `resume || new` once a resume id is
* set, which is the shape that survives both cases.
*
* Three candidates are tried in priority order — the conversation chain's
* tail, the launch seed, then the session's own id — and the first one a
* transcript backs is pinned. Four conditions gate that walk, each protecting
* against a way of resuming the WRONG conversation or of making a working
* relaunch fail.
*
* ⚠️ **A remote or docker session is never pinned.** Unlike `restartCli()`,
* whose route refuses both, the dead-pane respawn is reached by every session
* shape. Their pane commands (`buildRemoteLaunchCommand`,
* `claudeDockerPaneCommand`) already render a SELF-HEALING
* `--session-id <sid> || --resume <sid>`, and both flip to resume-first the
* moment the resume id differs from the session id. The conversation lives on
* the far side, so a local id pinned onto it resolves to nothing there, the
* resume fails, and the `--session-id` fallback then collides with the
* transcript the far side really does hold — both branches fail and the pane
* dies. `_pinOmpRespawnId()` refuses remote for the same reason.
*
* ⚠️ **The candidates come from the conversation CHAIN, never from
* `_claudeSessionId`.** That field holds either a first-hand id from the
* CLI's own hook payload or a history correlation, which is a guess keyed on
* the working directory. `_recordClaudeSessionInChain()` refuses a guess
* precisely so it cannot "write a foreign conversation into this pane's
* permanent record", and launching from one would do worse than the display
* bug that rule exists to prevent: the relaunched CLI would open and WRITE to
* a conversation that was never this pane's. The chain's tail is the live
* conversation and is hook-vouched, so it leads the walk, ahead of the launch
* seed, which is written once at construction and never moves off a `/clear`.
*
* ⚠️ **Every candidate must be backed by a transcript, the session's own id
* included, and a candidate that has none is passed over rather than ending
* the walk.** A pin that differs from the session id leaves
* `--session-id <this.id>` in the fallback branch, so a resume that finds
* nothing collides there and the pane dies exactly as it did before this
* pinning existed. Pinning `this.id` renders the self-healing
* `--resume <id> || --session-id <id>`, which is correct whether or not a
* transcript exists, but a pane that has none pays for the shape twice:
* claude prints "No conversation found" into the scrollback of a session that
* is brand new, and `wrapWithNice()` prefixes only the FIRST branch of the
* rendered `a || b`, so the branch that actually runs loses its priority for
* the life of the session. Falling off the end of the walk therefore adds
* no pin (the options keep any launch seed they already carried), which is
* the right answer: with no transcript anywhere there is nothing for the
* bare `--session-id <this.id>` to collide with.
*
* The create route pre-validates a resume id for the same reason, though it
* additionally requires the transcript be substantial — here mere existence
* is the question, because a one-line transcript still makes `--session-id`
* collide.
*
* The registry shape is the last gate, not the CLI's name: an entry whose
* resume id is minted by the CLI itself (codex/pi/omp/grok) declares no
* `fallback` chain and reads its resume field from its own `<Mode>Config`.
*
* `reattachRemote()` deliberately does NOT call this. It re-runs the remote
* session command, which attaches to the durable remote tmux with the agent
* still inside it and renders no local `--session-id` to collide.
*/
private async _buildRespawnPaneOptionsWithResumePin(): Promise<import('./mux-interface.js').RespawnPaneOptions> {
const options = this._buildRespawnPaneOptions();
if (this._remote || this._docker) return options;
const entry = getCli(this.mode);
if (entry?.launch.chain !== 'fallback') return options;
const resumeIdPattern = entry.launch.params?.resumeId;
const configDir = this._claudeConfigDir();
const chainTail = this._claudeSessionChain[this._claudeSessionChain.length - 1];
const candidates = [chainTail, options.resumeSessionId, this.id].filter((v): v is string => !!v);
for (const candidate of candidates) {
// A session Codeman DISCOVERED on the socket rather than created carries a
// synthetic `restored-<fragment>` id, which fails claude's `uuid` token
// pattern. The renderer would silently drop the resume flag and emit the
// unpinned command, so say so here rather than letting the caller believe
// the pane was pinned.
if (resumeIdPattern?.type === 'token' && !matchesPattern(resumeIdPattern.pattern, candidate)) {
console.log(`[Session] Not pinning resume id ${candidate} for relaunch: the CLI cannot accept that id shape`);
continue;
}
if (!(await claudeTranscriptExists(candidate, configDir))) continue;
options.resumeSessionId = candidate;
return options;
}
// Nothing on disk to collide with, so the bare `--session-id <this.id>` the
// unpinned options already carry is the correct command.
return options;
}
/** The session's Claude config dir when it has been relocated (#255), else undefined. */
private _claudeConfigDir(): string | undefined {
// Trimmed like `claudeProjectsDir()` trims the process-wide override: the
// envOverrides schema validates keys only, and a whitespace-only value would
// otherwise resolve to a relative path and read "no transcript" for everything.
return this._envOverrides?.CLAUDE_CONFIG_DIR?.trim() || undefined;
}
/**
* Force the custom-model selection's `launchModel` (pi/omp `custom/<id>`, grok's
* `[model.<name>]` block name) onto the CLI's `model` launch param. Where that param
@@ -2268,7 +2553,7 @@ export class Session extends EventEmitter {
// If mux wrapping is enabled, create or attach to a mux session
if (this._useMux && this._mux) {
try {
const { isRestored } = await this._setupOrAttachMuxSession({
const { isRestored, respawnedResumeId, respawnedDeadPane } = await this._setupOrAttachMuxSession({
// Single source of truth shared with reattachRemote() (COD-108).
respawnPaneOptions: this._buildRespawnPaneOptions(),
createSessionOptions: {
@@ -2315,7 +2600,18 @@ export class Session extends EventEmitter {
// persisted chain's tail is that conversation, reported first-hand by
// the CLI's own hook, so it outranks every fallback here. A NEW pane has
// an empty chain and falls through to the resume/alias fallbacks.
restoredConversation = isRestored ? this._claudeSessionChain[this._claudeSessionChain.length - 1] : undefined;
//
// A dead-pane respawn is NOT that case for a CLI whose relaunch the resume
// pin walk governs (`launch.chain === 'fallback'`): the CLI did stop, and
// the walk may have passed over a chain tail with no transcript behind it,
// so the conversation is whatever the respawn actually resumed. Undefined
// there means the pane launched unpinned, which the fallbacks below name.
const pinGovernsRespawn = respawnedDeadPane && getCli(this.mode)?.launch.chain === 'fallback';
restoredConversation = pinGovernsRespawn
? respawnedResumeId
: isRestored
? this._claudeSessionChain[this._claudeSessionChain.length - 1]
: undefined;
this._claudeSessionId =
restoredConversation ||
this._resumeSessionId ||
@@ -2416,6 +2712,7 @@ export class Session extends EventEmitter {
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
})
);
this._notePtySpawnGeometry(120, 40);
} catch (spawnErr) {
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
this._status = 'stopped';
@@ -2709,9 +3006,63 @@ export class Session extends EventEmitter {
this._lastPaneProbeAt = now;
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
this._lastPaneProbeWorking = text === null ? null : this._workingLinePattern().test(text);
this._readWatching(text);
return this._lastPaneProbeWorking;
}
/**
* Read the background-work chip off the same capture the working probe just took.
*
* The two questions are different. A turn that is running is work the user is waiting
* for; a monitor, a backgrounded shell or a cloud session the agent started is work
* the AGENT is waiting for, and it is the reason a pane can sit at its composer with
* nothing to say and still not want anything from the user. `_confirmIdle` takes this
* capture at exactly the moment the turn ends, which is the moment the answer starts
* mattering.
*
* A capture that could not be read CLEARS the label rather than keeping the last one.
* The two wrong answers are not symmetric: a stale label opens the next idle prompt
* already acknowledged, so a failed capture would silence a real alert, while a dropped
* label only costs a card and an alert that the next readable capture takes back.
* Degrading toward the alert is the rule the whole signal is built on.
*/
private _readWatching(paneText: string | null): void {
const pattern = this._watchingLinePattern();
if (!pattern) return;
// `null` is "the screen could not be read". That is no evidence either way, so the
// label falls to null (and the change is announced below like any other).
const label = paneText === null ? null : watchingLabel(paneText, pattern, this._watchingWindow);
if (label === this._watching) return;
this._watching = label;
// ⚠️ This CHANGES while the session's status does not, so it needs an event of its
// own. The label is usually set on the idle transition, which broadcasts anyway, but
// it CLEARS when the work ends — and for a CLI whose background work ends without
// taking a turn (measured on codex: a background terminal finishing repaints the row
// away and nothing else happens) the session is idle before and after. Without this,
// the server knew the badge was gone and every open page went on drawing it until
// some unrelated event arrived.
this.emit('watchingChanged');
}
/**
* The regex matching this CLI's background-work chip, or null for a CLI whose registry
* entry declares none. Compiled once per session, like the working-line pattern, and
* null rather than a fallback: no other CLI has been measured drawing such a chip, and
* guessing one would badge sessions on the strength of an unread screen.
*/
private _watchingLinePattern(): RegExp | null {
if (this._watchingLineRe === undefined) {
// The pattern and the window it runs over are one decision, so they are resolved
// together: how far up the screen a CLI's row can sit is as much a property of its
// layout as the row itself. Claude writes on the last row and keeps the default,
// Codex pins one above its composer and declares more.
const detect = getCli(this.mode)?.capabilities.workDetect;
this._watchingLineRe = detect?.watchingLine ? compileVersionRegex(detect.watchingLine) : null;
this._watchingWindow = detect?.watchingLines ?? WATCHING_TAIL_LINES;
}
return this._watchingLineRe;
}
/**
* The regex matching this CLI's "a turn is running" status line.
*
@@ -2984,6 +3335,7 @@ export class Session extends EventEmitter {
env: buildShellEnv(this.id),
})
);
this._notePtySpawnGeometry(120, 40);
} catch (spawnErr) {
console.error('[Session] Failed to spawn shell PTY:', spawnErr);
this._status = 'stopped';
@@ -3093,6 +3445,7 @@ export class Session extends EventEmitter {
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
})
);
this._notePtySpawnGeometry(120, 40);
} catch (spawnErr) {
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
this.emit(
@@ -3779,6 +4132,40 @@ export class Session extends EventEmitter {
private _ptyCols = 120;
private _ptyRows = 40;
/**
* Record the geometry a PTY was just spawned at. A reattached pane keeps the
* tmux window's size, not the constructor's 120x40, and without this
* `ptyGeometry` reported the old numbers for a live pane and the dedupe in
* `resize()` skipped a real resize that happened to match them.
*/
private _notePtySpawnGeometry(cols: number, rows: number): void {
this._ptyCols = cols;
this._ptyRows = rows;
}
/**
* The geometry the CLI is actually drawing for, or null when nothing is
* drawing.
*
* Exposed because `resize()` can decline a request outright (arbitration
* below) and the asking client has no other way to find out: a browser
* terminal that keeps a shape the PTY refused renders garbled output rather
* than wrong-sized output, because Claude Code's repaints are computed from
* the width it was told (issue #464). Both transports report this back.
*
* ⚠️ NULL WITHOUT A PANE, never the field values. The fields are seeded at
* spawn (`_notePtySpawnGeometry`) and moved by `resize()`, but a session with
* a dead pane (or one created through the API and never started) still
* holds the constructor defaults of 120x40, or the size of a pane that is
* gone. Reporting those made a client adopt a size no process had ever
* been told, and on anything narrower than 120 columns it claimed another
* device owned the pane when none existed. `reconcilePtyGeometry` treats a
* report with no finite numbers as no evidence, which is the truth here.
*/
get ptyGeometry(): { cols: number; rows: number } | null {
return this.ptyProcess ? { cols: this._ptyCols, rows: this._ptyRows } : null;
}
/**
* Live WebSocket connections that have announced a desktop viewport for this
* session. While at least one is registered, small-viewport (mobile/tablet)
@@ -3864,6 +4251,10 @@ export class Session extends EventEmitter {
}
if (isSmallViewport && this._desktopSizeClaims.size > 0) {
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
// Declined. The caller is told nothing here on purpose — the decision
// belongs to the session, not the socket — but the caller MUST report
// `ptyGeometry` back afterwards so the asking client can adopt
// the shape it did not get. Both transports do; see issue #464.
return;
}
this._mobileSizeOverride = true;
@@ -3963,6 +4354,9 @@ export class Session extends EventEmitter {
async stop(killMux: boolean = true): Promise<void> {
// Set stopped flag first to prevent new timers from being created
this._isStopped = true;
// A pane that is gone is watching nothing. Nothing probes a stopped session, so
// without this the last chip it drew would ride along on its row forever.
this._watching = null;
this._clearAllTimers();
+365 -15
View File
@@ -58,6 +58,7 @@ import {
type SessionRemote,
type SessionDocker,
type DockerCommandMode,
type PaneExit,
} from './types.js';
import { getCli } from './config/cli-registry/registry.js';
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
@@ -152,6 +153,16 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
/** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000;
/**
* How often the pane-exit watcher re-reads every pane on the socket. The
* watcher owns this cadence: it does NOT ride `startStatsCollection()`, whose
* lifetime a browser panel controls (see {@link TmuxManager.startPaneExitWatcher}).
* Matched to the stats cadence above because both cost one batched tmux read.
* ⚠ It does NOT bound a read: EXEC_TIMEOUT_MS is 5000 ms, so a slow read can
* outlive two ticks, which is exactly why `paneExitReadInFlight` exists.
*/
const DEFAULT_PANE_EXIT_INTERVAL_MS = 2000;
/** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */
const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000;
@@ -219,8 +230,18 @@ const DEFAULT_CODEMAN_TMUX_SOCKET = DEFAULT_TMUX_SOCKET;
*/
const PANE_LIST_SEP = '|';
/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
/**
* Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneRows}.
*
* The three `pane_dead*` fields carry the agent-exit signal of Ark0N/Codeman#446.
* Appending them is backward compatible in both directions. A tmux that does not
* know a variable substitutes the empty string rather than failing, which is how
* tmux 3.2a answers `#{pane_dead_signal}` (added in 3.4), and the parser reads a
* short row as "pid known, deadness unknown" rather than discarding it.
*/
const PANE_LIST_FORMAT =
`#{session_name}${PANE_LIST_SEP}#{pane_pid}` +
`${PANE_LIST_SEP}#{pane_dead}${PANE_LIST_SEP}#{pane_dead_status}${PANE_LIST_SEP}#{pane_dead_signal}`;
/**
* 构建 pane 启动前的 nofile 修复命令。
@@ -235,26 +256,141 @@ export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT):
return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`;
}
/** One pane of one tmux session, as {@link parsePaneRows} reads it off the wire. */
export interface PaneRow {
/** tmux session this pane belongs to. Repeats once per pane of a split session. */
sessionName: string;
/** `#{pane_pid}` — the process tmux started in the pane. */
pid: number;
/** `#{pane_dead}` — true for 1, false for 0, undefined when tmux said nothing. */
dead?: boolean;
/** `#{pane_dead_status}` — the exit code, absent when tmux reported none. */
exitStatus?: number;
/** `#{pane_dead_signal}` — the killing signal, absent before tmux 3.4 and when unsignalled. */
exitSignal?: number;
}
/**
* Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'`
* into a Map of session-name → pane pid. Exported for unit testing.
* One pane-exit reading, with the pane pid that produced it.
*
* The pid never leaves this module. It is what distinguishes "the same dead
* pane, seen again" from "a second command in the same pane that also exited
* with the same status", so the `at` stamp can hold across the first and must
* not across the second. {@link PaneExit} itself stays free of it: the pid on
* the session record is the attach client's, and a second pid there would
* invite exactly the confusion Ark0N/Codeman#446 is about.
*/
export interface PaneExitObservation {
/** `#{pane_pid}` of the pane this reading came from. */
panePid: number;
/** What to publish on the session record. */
exit: PaneExit;
}
/** Read one optional numeric field; a blank or non-numeric value is "not reported". */
function paneField(fields: string[], index: number): number | undefined {
const raw = fields[index];
if (raw === undefined || raw === '') return undefined;
const value = parseInt(raw, 10);
return Number.isNaN(value) ? undefined : value;
}
/**
* Parse the output of `tmux list-panes -a -F` under {@link PANE_LIST_FORMAT}
* into one row per pane, in tmux's own order. Exported for unit testing.
*
* - Skips empty lines and lines without the separator.
* - Skips entries with a non-numeric pid or empty name.
* - Leaves every field after the pid undefined when it is blank or absent, so a
* row from an older tmux still yields its pid.
*/
export function parsePaneList(output: string): Map<string, number> {
const result = new Map<string, number>();
export function parsePaneRows(output: string): PaneRow[] {
const rows: PaneRow[] = [];
for (const line of output.split('\n')) {
if (!line) continue;
const sep = line.indexOf(PANE_LIST_SEP);
if (sep === -1) continue;
const name = line.slice(0, sep);
const pid = parseInt(line.slice(sep + 1), 10);
if (name && !Number.isNaN(pid)) {
result.set(name, pid);
}
if (!line.includes(PANE_LIST_SEP)) continue;
const fields = line.split(PANE_LIST_SEP);
const sessionName = fields[0];
const pid = parseInt(fields[1] ?? '', 10);
if (!sessionName || Number.isNaN(pid)) continue;
const deadFlag = fields[2];
rows.push({
sessionName,
pid,
dead: deadFlag === '1' ? true : deadFlag === '0' ? false : undefined,
exitStatus: paneField(fields, 3),
exitSignal: paneField(fields, 4),
});
}
return result;
return rows;
}
/**
* Decide, from every pane tmux listed, which tmux sessions have an exited agent.
* Exported for unit testing. Returns one entry per session with a known answer;
* a session absent from the map is UNKNOWN, which must never render as alive.
*
* Two rules make a positive answer trustworthy:
*
* A session answers only when tmux listed EXACTLY ONE pane for it. Codeman
* creates one pane per session and `isPaneDead()` reads one pane, so a session
* the user has split by hand has no single "the agent" to report on, and
* guessing which of its panes speaks for the session could report a live
* session as exited.
*
* A pane answers only when `#{pane_dead}` said 1 or 0. An empty field is a tmux
* that did not answer, not a live pane.
*
* `status` and `signal` stay absent when tmux did not report them. Measured on
* tmux 3.2a, a SIGKILLed pane reports neither, so folding an absent status into
* 0 would turn an unexplained death into a clean exit.
*/
export function derivePaneExits(rows: PaneRow[], now: number): Map<string, PaneExitObservation> {
const panesPerSession = new Map<string, number>();
for (const row of rows) {
panesPerSession.set(row.sessionName, (panesPerSession.get(row.sessionName) ?? 0) + 1);
}
const exits = new Map<string, PaneExitObservation>();
for (const row of rows) {
if (panesPerSession.get(row.sessionName) !== 1) continue;
if (row.dead !== true) continue;
exits.set(row.sessionName, {
panePid: row.pid,
exit: {
...(row.exitStatus !== undefined ? { status: row.exitStatus } : {}),
...(row.exitSignal !== undefined ? { signal: row.exitSignal } : {}),
at: now,
},
});
}
return exits;
}
/**
* Could any of these tmux sessions ever produce a pane-exit answer? Exported
* for unit testing.
*
* Mirrors `Session.paneExitApplies`, which is where the rule is enforced. A
* remote session's local pane holds the ssh client, a docker case's holds a
* `docker exec` into the container's own tmux, and a record rebuilt from the
* socket carries no provenance at all, so the session end forces all three to
* UNKNOWN whatever tmux reports. A tick that sees only those has nothing to
* learn, and `refreshPaneExits()` skips its tmux read rather than paying for
* the answer.
*
* ⚠ This gates the READ, never the watcher. The watcher is always-on by
* design (see {@link TmuxManager.startPaneExitWatcher}), so it keeps ticking
* with nothing to observe and picks the read straight back up as soon as one
* local session exists.
*/
export function hasObservablePaneSession(sessions: Iterable<MuxSession>): boolean {
for (const session of sessions) {
if (session.remote) continue;
if (session.docker) continue;
if (session.discovered === true) continue;
return true;
}
return false;
}
/**
@@ -1527,6 +1663,30 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
private mouseSyncInterval: NodeJS.Timeout | null = null;
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
private lastPaneCount: Map<string, number> = new Map();
/**
* muxName → the exited agent the pane-exit watcher last observed
* (Ark0N/Codeman#446). Absence is the UNKNOWN arm of the tri-state, so an
* entry goes the moment tmux stops reporting the pane dead, and the map is
* empty until the first read runs. The manager reports what tmux says and
* nothing more: the scoping that hides this for remote and docker sessions
* lives on `Session`, because the remote-reconnect watcher above needs the
* raw pane reading.
*/
private paneExits: Map<string, PaneExitObservation> = new Map();
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
private paneExitInterval: NodeJS.Timeout | null = null;
/**
* True while a pane read is in flight. `EXEC_TIMEOUT_MS` is 5000 ms against a
* poll interval of 2000 ms, so without this a slow read overlaps the next two
* and the older one can resolve last and win.
*/
private paneExitReadInFlight = false;
/**
* Bumped by every deliberate {@link clearPaneExit}. A read that started before
* a clear carries the older generation and is discarded rather than writing
* the death back over the pane that has just replaced it.
*/
private paneExitGeneration = 0;
// ── COD-108 remote-reconnect watcher state ────────────────────────────────
/** Periodic watcher that re-establishes dropped remote sessions. */
@@ -2297,6 +2457,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
);
// Wait for the respawned process to start
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
// The pane now runs a fresh command, so whatever the last read observed of
// the old one is history. Clearing it here rather than waiting for the next
// poll also invalidates any read already in flight, which would otherwise
// write the old death back over the pane that just replaced it.
this.clearPaneExit(muxName);
const pid = this.getPanePid(muxName);
if (pid) session.pid = pid;
return pid;
@@ -2508,6 +2673,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
this.lastPaneCount.delete(session.muxName);
this.clearPaneExit(session.muxName);
this.sessions.delete(sessionId);
this.clearRemoteReconnectState(sessionId);
this.saveSessions();
@@ -2618,6 +2784,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
this.lastPaneCount.delete(session.muxName);
this.clearPaneExit(session.muxName);
this.sessions.delete(sessionId);
this.clearRemoteReconnectState(sessionId);
this.saveSessions();
@@ -2671,7 +2838,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
active = parsePaneList(output);
const rows = parsePaneRows(output);
active = new Map(rows.map((row) => [row.sessionName, row.pid]));
// The same read answers both questions, so recovery starts with a pane-exit
// reading rather than waiting for the first stats tick — which may never
// come, since the collector only starts when boot found a live session.
if (rows.length > 0) {
this.applyPaneExits(derivePaneExits(rows, Date.now()));
}
} catch (err) {
console.error('[TmuxManager] Failed to list tmux panes:', err);
active = new Map();
@@ -2686,6 +2860,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} else {
dead.push(sessionId);
this.sessions.delete(sessionId);
this.clearPaneExit(session.muxName);
this.clearRemoteReconnectState(sessionId);
this.emit('sessionDied', { sessionId });
}
@@ -2722,6 +2897,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
mode: 'claude',
attached: false,
name: `Restored: ${sessionName}`,
// Every field above except the name and the pid is a guess: this record
// was rebuilt from the socket because Codeman's own bookkeeping did not
// have it. The synthetic id also cannot find the session's state.json
// entry, so a remote or docker session rediscovered this way arrives
// looking local. Consumers that would be wrong about such a session
// read this flag and fail closed — see `Session.paneExitApplies`.
discovered: true,
};
this.sessions.set(sessionId, session);
knownMuxNames.add(sessionName);
@@ -2882,6 +3064,172 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}));
}
/**
* What the last pane read saw of this tmux session's agent. `undefined` is
* the UNKNOWN answer and must never be rendered as "alive": it covers a pane
* that is running, a tmux session that no longer exists, a probe that failed,
* and every poll that has not run yet. See {@link PaneExit}.
*/
getPaneExit(muxName: string): PaneExit | undefined {
return this.paneExits.get(muxName)?.exit;
}
/**
* Re-read every pane on the socket and refresh {@link paneExits}. ONE batched
* `tmux list-panes -a` answers for every session at once, which is why this
* polls rather than probing per session.
*
* A failed or empty probe leaves the previous answers ALONE rather than
* clearing them, because the two cannot be told apart: the command ends in
* `|| true`, so a tmux that errored and a socket with genuinely no panes both
* arrive as empty output. Treating that as "tmux did not answer" is the
* conservative reading — clearing on it would turn a transient failure into a
* silent retraction of a death Codeman had already observed, and the cost of
* being wrong the other way is one stale entry for a socket that no longer
* has the pane. A NON-empty read is different: `list-panes -a` lists
* every pane on the socket, so it is authoritative and {@link applyPaneExits}
* prunes against it.
*
* Two guards keep a slow read from undoing a fast one. A read already in
* flight suppresses the next poll, and a read that started before a
* {@link clearPaneExit} is discarded when it lands.
*
* A third guard skips the read entirely while no session on this manager
* could produce an answer ({@link hasObservablePaneSession}). Skipping
* retracts nothing, for the same reason a failed read does not: the map
* still holds what the last real read saw, and every path that puts a new
* command in a pane calls {@link clearPaneExit} itself.
*/
async refreshPaneExits(now: number = Date.now()): Promise<void> {
// Nothing on this socket could answer, so do not read tmux to find that
// out. See `hasObservablePaneSession`: the watcher above still ticks.
if (!hasObservablePaneSession(this.sessions.values())) return;
if (this.paneExitReadInFlight) return;
const generation = this.paneExitGeneration;
this.paneExitReadInFlight = true;
let rows: PaneRow[];
try {
rows = await this.readPaneRows();
} finally {
this.paneExitReadInFlight = false;
}
if (rows.length === 0) return;
// A pane was respawned or killed while this read was out, so what it saw is
// already history. Dropping it is what stops a freshly respawned pane from
// being republished as exited.
if (generation !== this.paneExitGeneration) return;
this.applyPaneExits(derivePaneExits(rows, now));
}
/**
* Read every pane on the socket. The ONLY part of the pane-exit watcher that
* touches tmux, which is what lets a test subclass drive the guards in
* {@link refreshPaneExits} — the in-flight suppression, the generation
* check, the empty-read retraction rule and the read gate — against rows it
* chooses. Split out for the reason `runRemoteReconnectTick` is: a guard no
* test can reach is a guard that can be deleted without anything failing.
*
* A failed read answers with NO rows, which the caller treats as "tmux did
* not answer" and which therefore retracts nothing.
*/
protected async readPaneRows(): Promise<PaneRow[]> {
// The test-mode gate lives HERE rather than at the top of the tick, so that
// what tests cannot do is spawn a process, not exercise the bookkeeping.
if (IS_TEST_MODE) return [];
try {
// execAsync, not execSync: this runs on a 2000 ms timer, and a synchronous
// exec freezes the port while the process stays alive (see the
// event-loop-monitor note in CLAUDE.md). The three `isPaneDead()` callers
// stay synchronous because each is answering one request right then.
const { stdout } = await execAsync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
return parsePaneRows(stdout.trim());
} catch (err) {
console.error('[TmuxManager] Failed to read pane exit state:', err);
return [];
}
}
/**
* Fold one authoritative observation into {@link paneExits}. Split out from
* the tmux call so the merge rules are unit-testable.
*
* `observed` comes from a read of EVERY pane on the socket, so a session
* missing from it has no exit to report and its entry goes. That is what
* keeps the map from growing without bound as tmux sessions come and go
* outside `killSession()`. Only the caller may decide a read is authoritative:
* a failed or empty one never reaches here.
*
* An entry keeps the `at` of the FIRST read that saw that exit, so the stamp
* says when the agent was found gone rather than when the last poll ran. A
* changed status, a changed signal, or a different pane pid all start a new
* observation — the pid is what catches a second command in the same pane
* that happened to exit the same way.
*/
applyPaneExits(observed: Map<string, PaneExitObservation>): void {
for (const muxName of [...this.paneExits.keys()]) {
if (!observed.has(muxName)) this.paneExits.delete(muxName);
}
for (const [muxName, next] of observed) {
const prev = this.paneExits.get(muxName);
const sameExit =
prev !== undefined &&
prev.panePid === next.panePid &&
prev.exit.status === next.exit.status &&
prev.exit.signal === next.exit.signal;
this.paneExits.set(muxName, sameExit ? prev : next);
}
}
/**
* Forget a session's exit observation, e.g. once its pane has been respawned.
* Also invalidates any read already in flight, so the answer this retracts
* cannot be written back a moment later.
*/
clearPaneExit(muxName: string): void {
this.paneExits.delete(muxName);
this.paneExitGeneration++;
}
/**
* Poll for exited agents, on the manager's own interval.
*
* Deliberately NOT part of `startStatsCollection()`. That collector is armed
* when the browser opens the Monitor panel and DISARMED when it closes it
* (`panels-ui.js`), and it is skipped at boot entirely when no session was
* recovered — so riding it would leave a session created on a freshly booted
* server reporting nothing at all, and would let one browser turn exit
* detection off for every other. Started unconditionally, like the mouse-mode
* sync and the remote-reconnect watcher below.
*
* The `paneExitsUpdated` event is internal to the server; nothing here adds an
* SSE event, and the field reaches the browser on `session:updated`.
*/
startPaneExitWatcher(intervalMs: number = DEFAULT_PANE_EXIT_INTERVAL_MS): void {
if (this.paneExitInterval) {
clearInterval(this.paneExitInterval);
}
this.paneExitInterval = setInterval(() => {
// No IS_TEST_MODE guard: `readPaneRows()` is the only thing that would
// spawn a process and it refuses under test, so a test can drive this
// whole loop with fake timers instead of being locked out of it.
void this.refreshPaneExits()
.then(() => this.emit('paneExitsUpdated'))
.catch((err) => console.error('[TmuxManager] Pane exit watcher error:', err));
}, intervalMs);
}
stopPaneExitWatcher(): void {
if (this.paneExitInterval) {
clearInterval(this.paneExitInterval);
this.paneExitInterval = null;
}
}
startStatsCollection(intervalMs: number = DEFAULT_STATS_INTERVAL_MS): void {
if (this.statsInterval) {
clearInterval(this.statsInterval);
@@ -3101,6 +3449,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
destroy(): void {
this.stopStatsCollection();
this.stopPaneExitWatcher();
this.paneExits.clear();
this.stopMouseModeSync();
this.stopRemoteReconnectWatcher();
this.reconnectState.clear();
+18 -5
View File
@@ -28,8 +28,11 @@
import type { ApprovalItem, ApprovalOption } from '../web/approval-inbox.js';
import type { TuiApprovalAnswer } from './tui-client.js';
/** Card severity, in the same red/yellow vocabulary the web inbox uses. */
export type TuiApprovalTone = 'err' | 'warn';
/**
* Card severity, in the same red/yellow vocabulary the web inbox uses, plus the quiet
* third case: an item that opened acknowledged asks for nothing and reads grey.
*/
export type TuiApprovalTone = 'err' | 'warn' | 'info';
export interface TuiApprovalCard {
tone: TuiApprovalTone;
@@ -50,8 +53,14 @@ function clean(text: string | undefined): string {
return (text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_CARD_TEXT);
}
/**
* How loud the card is. An idle prompt the inbox opened ALREADY acknowledged is not
* asking for anything — the session is watching work it started itself — so it drops to
* `info` and out of the warning vocabulary the other two share with the web inbox.
*/
export function approvalTone(item: ApprovalItem): TuiApprovalTone {
return item.kind === 'idle' ? 'warn' : 'err';
if (item.kind !== 'idle') return 'err';
return item.acknowledgedReason ? 'info' : 'warn';
}
/**
@@ -65,9 +74,13 @@ export function approvalCard(item: ApprovalItem): TuiApprovalCard {
const summary = clean(item.toolSummary) || clean(item.toolName);
if (item.kind === 'idle') {
// Say what it is waiting for rather than asking for a reply, in the same words the
// web drawer uses for the same item. The prompt is still answerable, so the hint
// stays either way.
const quiet = clean(item.acknowledgedReason);
return {
tone: 'warn',
title: message || 'waiting for your reply',
tone: approvalTone(item),
title: quiet ? `quiet, ${quiet}` : message || 'waiting for your reply',
detail: [],
options: [],
hint: 'p to reply',
+18 -2
View File
@@ -74,13 +74,25 @@ export function isLiveRow(session: TuiSessionRow): boolean {
* outranks a stale `busy` status because the hook is the newer signal. An
* errored session has no state of its own here and joins the waiting tier,
* since it is equally something only a human can clear.
*
* ⚠️ An ACKNOWLEDGED item no longer decides the row. `acknowledgedAt` means the
* alert this prompt armed has been spent, either because somebody opened the
* session on another device or because the inbox opened the item that way for a
* session watching its own background work. The item itself stays pending and
* answerable, so the row keeps carrying it and the approval card still renders;
* it simply stops dragging the session into NEEDS YOU. The web has honoured
* that since acknowledgement existed (`approvals-ui.js` clears the pending hook
* that `_mobileOverviewState` reads), and this gate is where the TUI had been
* reading past it: acknowledging on a phone cleared the alert everywhere except
* here. Only `idle` can be acknowledged, so a permission or question dialog is
* unaffected by construction, and both are checked ahead of the flag anyway.
*/
export function classifySession(session: TuiSessionRow, approval?: ApprovalItem): TuiSessionState {
if (!isLiveRow(session)) return 'recent';
if (approval) {
if (approval.kind === 'permission') return 'blocked-permission';
if (approval.kind === 'question') return 'blocked-question';
return 'waiting';
if (!approval.acknowledgedAt) return 'waiting';
}
if (session.status === 'error') return 'waiting';
if (session.isWorking === true || session.status === 'busy') return 'working';
@@ -96,7 +108,11 @@ export function classifySession(session: TuiSessionRow, approval?: ApprovalItem)
* turn's own start is the pane's last Enter.
*/
export function stateSince(state: TuiSessionState, session: TuiSessionRow, approval?: ApprovalItem): number {
if (approval) return approval.createdAt;
// The prompt's own age measures the state only while the prompt is what put the
// row in that state. An acknowledged item still rides along on a row that is
// plainly idle or working, and dating such a row from it would report how long
// ago the prompt arrived as though it were how long the session has been quiet.
if (approval && STATE_GROUP[state] === 'needs-you') return approval.createdAt;
if (state === 'working') return session.lastSubmitAt ?? session.createdAt ?? 0;
return session.lastActivityAt ?? session.createdAt ?? 0;
}
+15 -5
View File
@@ -462,7 +462,8 @@ export function computeListWindow(
* The pending dialog, drawn above the tail: the question, the parsed options
* with their digits, and the keys that answer them. Red for a permission or
* question prompt, yellow for an idle one, the same severity vocabulary the web
* inbox uses.
* inbox uses. An idle prompt that opened acknowledged carries neither: it reads
* grey with the idle glyph, because nothing about it wants the reader.
*/
export function renderApprovalCard(
item: ApprovalItem,
@@ -472,8 +473,8 @@ export function renderApprovalCard(
): string[] {
const paint = painterFor(opts.color);
const card = approvalCard(item);
const color = card.tone === 'err' ? SGR.red : SGR.yellow;
const glyph = card.tone === 'err' ? glyphs.blockedPermission : glyphs.waiting;
const color = card.tone === 'err' ? SGR.red : card.tone === 'warn' ? SGR.yellow : SGR.gray;
const glyph = card.tone === 'err' ? glyphs.blockedPermission : card.tone === 'warn' ? glyphs.waiting : glyphs.idle;
const lines: string[] = [];
const push = (text: string, style: string): void => {
lines.push(padDisplay(paint(clipStyledLine(text, width), style), width));
@@ -571,10 +572,19 @@ function previewBody(
// Chrome
// ─────────────────────────────────────────────────────────────────────────────
/** Sessions with a prompt waiting on a human, which is what the badge counts. */
/**
* Sessions with a prompt waiting on a human, which is what the badge counts.
*
* An ACKNOWLEDGED item is not one of them. Its alert has been spent, either by somebody
* opening the session elsewhere or because the inbox opened it that way for a session
* watching its own background work, and the row has already left NEEDS YOU by the same
* flag (`classifySession`). Counting it here would put a number in the header for a
* group the reader can see is empty.
*/
export function pendingApprovalCount(model: TuiRenderModel): number {
let count = 0;
for (const group of model.groups()) for (const row of group.rows) if (row.approval) count++;
for (const group of model.groups())
for (const row of group.rows) if (row.approval && !row.approval.acknowledgedAt) count++;
return count;
}
+63
View File
@@ -625,6 +625,54 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
launchModel?: string;
}
/**
* The agent inside a LOCAL tmux pane has exited, and the pane survived it.
*
* Codeman creates every pane with `remain-on-exit on`, so `/exit` ends the CLI
* while tmux keeps the pane, the tmux session and the `tmux attach-session`
* process Codeman records as the session's pid. No PTY exit handler runs, so
* without this record the session reads as a live idle one (Ark0N/Codeman#446).
*
* The field is TRI-STATE, and the third state is the absence of the field:
* `undefined` means Codeman does not know, and it must never be rendered as
* "alive". It is absent for a direct-PTY session (no pane exists), for a remote
* SSH session (the local pane holds the ssh client, whose death means transport
* drop OR exit) and for a docker case (the local pane holds a `docker exec`
* into the container's own tmux).
*
* `status` and `signal` are independently optional because tmux may know that
* the pane died without reporting how. Measured on tmux 3.2a: a SIGKILLed pane
* reports `pane_dead=1` with BOTH `#{pane_dead_status}` and `#{pane_dead_signal}`
* empty, and `#{pane_dead_signal}` does not exist at all before tmux 3.4. So an
* absent `status` means "the exit code is unknown", never "the exit code is 0".
*
* ⚠ AN ABSENT `status` STAYS ABSENT. Never write `status ?? 0`, and never read
* "no signal was reported" as "the exit must have been clean". On tmux 3.2a
* the absent status IS how a signal death presents, so absent-stays-absent is
* the only thing keeping a future clean-exit sweep away from crashed agents:
* an agent SIGKILLed by the OOM killer would otherwise read as a user typing
* `/exit` and be swept. Nothing here fails when somebody adds that `??` — the
* types allow it, the label still renders, and the damage shows up only once
* the sweep lands. The rule is enforced in `derivePaneExits()`
* (`tmux-manager.ts`), which omits the key rather than defaulting it.
*/
export interface PaneExit {
/**
* tmux `#{pane_dead_status}` — the command's exit code. Absent when tmux
* reported none, which means UNKNOWN and never 0. See the ⚠ above before
* giving this a default anywhere.
*/
status?: number;
/** tmux `#{pane_dead_signal}` — the signal that killed the command. Absent when unsignalled or unsupported. */
signal?: number;
/**
* Wall-clock ms when THIS server process first observed the pane dead. It is
* not when the agent exited, which nothing records, and a restart that finds
* the pane still dead respawns it rather than re-timing the old exit.
*/
at: number;
}
export interface SessionState {
/** Unique session identifier */
id: string;
@@ -780,6 +828,21 @@ export interface SessionState {
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
*/
respawnBlocked?: boolean;
/**
* The agent in this session's LOCAL tmux pane has exited (Ark0N/Codeman#446).
* See {@link PaneExit} for the tri-state rule and for which session shapes
* leave it absent. `status` and `pid` are deliberately untouched by it: the
* PTY-exit breaker owns `status: 'error'`, and a null `pid` is what makes the
* browser re-attach and launch a fresh CLI.
*
* Persisted so a reboot restore can tell a session whose agent exited from one
* that was merely idle when the power went. `reboot-restore.ts` reads the
* persisted record and never builds a `Session`, so the record is the only
* place that survives the reboot to carry it. Nothing reads it there YET:
* making the restore refuse such a session is a behavior change, and it
* belongs with the part of Ark0N/Codeman#446 that closes exited sessions.
*/
paneExit?: PaneExit;
}
/**
+75
View File
@@ -0,0 +1,75 @@
/**
* @fileoverview Does a Claude conversation transcript exist on this host?
*
* Claude writes one `<conversation-id>.jsonl` per conversation under
* `<config dir>/projects/<mangled cwd>/`. Two launch decisions turn on whether
* such a file exists: `--resume <id>` needs one, and `--session-id <id>` is
* REFUSED when one exists (`Error: Session ID ... is already in use.`).
*
* The project directory name is derived from the working directory, and a case
* that has been moved or renamed leaves its transcript under the OLD name, so
* the search is across every project directory rather than the one that matches
* the pane's cwd today.
*
* ⚠️ Existence is the whole question here, with no size floor. The create route
* additionally requires ~4 KB before it will resume, which is a "is this
* conversation worth resuming" judgement; for a relaunch the question is the
* opposite one — a one-line transcript still makes `--session-id` collide.
*
* ⚠️ A false answer is not the conservative one. Skipping a resume leaves the
* relaunch on `--session-id <id>`, which is safe only when no transcript backs
* that id either, so a lookup that misses the real config dir turns a
* recoverable pane into the collision this module exists to prevent.
*
* @dependencies none
* @consumedby session (relaunch resume pinning)
*
* @module utils/claude-transcript
*/
import { readdir, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
/**
* `<config dir>/projects`, honouring a session's relocated `CLAUDE_CONFIG_DIR`
* (#255) and, failing that, the server process's own.
*
* ⚠️ The process env is not optional here. A pane inherits the server's
* environment through tmux, so on an install that exports `CLAUDE_CONFIG_DIR`
* the CLI writes its transcripts there and a lookup under `~/.claude` answers
* "no transcript" for every conversation on the host. `claudeCredentialsPath()`
* (claude-credentials.ts) and `realClaudeConfigDir()`
* (custom-model-injection-apply.ts) resolve the same directory the same way.
*/
export function claudeProjectsDir(configDir?: string): string {
const fromEnv = typeof process.env.CLAUDE_CONFIG_DIR === 'string' && process.env.CLAUDE_CONFIG_DIR.trim();
return join(configDir || fromEnv || join(homedir(), '.claude'), 'projects');
}
/**
* True when a transcript for `conversationId` exists under any project
* directory. Returns false for a missing projects dir or an unreadable one,
* which leaves the caller unpinned: safe where nothing else can collide with
* the bare `--session-id`, and the reason the caller walks its candidates down
* to the session's own id rather than treating one false answer as final.
*/
export async function claudeTranscriptExists(conversationId: string, configDir?: string): Promise<boolean> {
if (!conversationId) return false;
const projectsDir = claudeProjectsDir(configDir);
let projectDirs: string[];
try {
projectDirs = await readdir(projectsDir);
} catch {
return false;
}
for (const projectDir of projectDirs) {
try {
await stat(join(projectsDir, projectDir, `${conversationId}.jsonl`));
return true;
} catch {
// Not in this project directory; keep looking.
}
}
return false;
}
+35
View File
@@ -71,6 +71,15 @@ export interface ApprovalItem {
* and reach the user's other devices. See `acknowledge()`.
*/
acknowledgedAt?: number;
/**
* Why the item arrived already acknowledged, for display only: the inbox
* writes `watching 1 monitor` for a session that went quiet because work it
* started itself is still running. A human acknowledgement leaves this unset,
* so a card can say "quiet, watching 1 monitor" rather than implying somebody
* looked. ⚠️ Pane-derived text, so it is bounded at the source and must not
* reach the DOM as markup — see `watchingLabel()` in `session-activity.ts`.
*/
acknowledgedReason?: string;
/**
* Present only when the frame parsed confidently. Gates which digits the
* answer endpoint accepts; absent → only approve('1')/deny(Esc) are allowed.
@@ -93,6 +102,12 @@ interface NotePromptArgs {
toolSummary?: string;
message?: string;
cwd?: string;
/**
* What the session's pane says is still running in the background
* (`Session.watching`, e.g. `1 monitor`). An idle prompt from such a session
* opens ALREADY acknowledged: see `notePrompt()`.
*/
watching?: string | null;
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
capture?: () => string | null;
}
@@ -217,6 +232,22 @@ export class ApprovalInbox {
* Record a prompt for a session, superseding any previous item, and return
* the new item. Captures context immediately and once more after a short
* delay (see RECAPTURE_DELAY_MS).
*
* ⚠️ An idle prompt from a session that is WATCHING its own background work
* opens already acknowledged (`args.watching`). Claude Code ends the turn
* after arming a monitor or backgrounding a shell and then reports the pane
* idle a minute later, so the alert that follows asks a human to look at a
* session that wants nothing from them. Acknowledging is deliberately what
* happens here rather than skipping the item: the prompt is real and stays
* pending, answerable and available as Read My Mind context, and only the
* alert it would have armed is spent. A wrong label therefore costs a card
* that does not blink, never an alert that was never created.
*
* It re-arms by itself. The next idle prompt supersedes this item and builds
* a fresh one, so once the background work ends and the session goes quiet
* for an ordinary reason, that item carries no acknowledgement and alerts
* normally. Only `idle` is eligible: a permission or question dialog blocks
* the agent whatever else it started, so its alert must survive.
*/
notePrompt(args: NotePromptArgs): ApprovalItem {
this.resolveForSession(args.sessionId, 'superseded');
@@ -231,6 +262,10 @@ export class ApprovalInbox {
message: args.message,
cwd: args.cwd,
};
if (args.kind === 'idle' && args.watching) {
item.acknowledgedAt = item.createdAt;
item.acknowledgedReason = `watching ${args.watching}`;
}
this.applyCapture(item, args.capture);
this.items.set(args.sessionId, item);
if (args.capture) this.captures.set(args.sessionId, args.capture);
+505 -64
View File
@@ -66,7 +66,23 @@ const _crashDiag = {
// concurrent clients (desktop + phone) don't clobber each other.
_pageId: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8),
log(msg) {
const entry = `${new Date().toISOString().slice(11,23)} ${msg}`;
// Entries are joined with '\n' into ONE localStorage value and beaconed to
// the server, and some call sites interpolate text this client does not
// control (a WebSocket close `reason` arrives from the server). A newline
// in there forges extra entries in the trail; an unbounded string can fill
// the storage quota and silently kill every later breadcrumb. Flatten and
// cap. CodemanDiag is loaded before app.js, but guard anyway — a
// diagnostic that can throw is worse than no diagnostic.
// Bound to a local FIRST: `CodemanDiag?.x` still throws a ReferenceError
// when the identifier was never declared, and this is the one function in
// the app that must never throw.
const diag = typeof CodemanDiag !== 'undefined' ? CodemanDiag : null;
const flat = diag?.sanitizeDiagEntry
? diag.sanitizeDiagEntry(msg)
: String(msg == null ? '' : msg)
.replace(/[\r\n\u2028\u2029]+/g, ' ')
.slice(0, diag?.DIAG_ENTRY_MAX_CHARS ?? 300);
const entry = `${new Date().toISOString().slice(11,23)} ${flat}`;
this._entries.push(entry);
if (this._entries.length > this._maxEntries) this._entries.shift();
try { localStorage.setItem('codeman-crash-diag', this._entries.join('\n')); } catch {}
@@ -325,6 +341,66 @@ function parseSessionPrefix(name) {
return null;
}
// ═══════════════════════════════════════════════════════════════
// Exited-agent tab label (Ark0N/Codeman#446)
// ═══════════════════════════════════════════════════════════════
// The server publishes session.paneExit when the agent inside a local tmux
// pane has exited while remain-on-exit kept the pane. The field is tri-state
// and its third state is absence, which means Codeman does not know — that
// renders as nothing here and must never read as alive.
//
// status and signal are each optional, because tmux can know the pane died
// without reporting how (a SIGKILLed pane on tmux 3.2a reports neither). So an
// absent status shows a bare "exited" rather than "exited (0)": a clean exit
// and an unexplained one must not look the same.
function paneExitLabel(paneExit) {
if (!paneExit || typeof paneExit !== 'object') return '';
if (typeof paneExit.signal === 'number' && paneExit.signal > 0) return `exited (signal ${paneExit.signal})`;
if (typeof paneExit.status === 'number') return `exited (${paneExit.status})`;
return 'exited';
}
// The tab's accessible name, with the exit appended when there is one. Shared
// by the full render and applyPaneExitBadge() so the two cannot disagree.
function paneExitAriaLabel(name, label) {
return label ? `${name} session, agent ${label}` : `${name} session`;
}
// Add, update or remove one tab's exited-agent badge in place. Separate from
// the render loop so it can be exercised directly: this is the only path a
// session going live-to-exited ever takes, since that transition adds and
// removes no tab and so never reaches the full rebuild.
function applyPaneExitBadge(tab, paneExit) {
const label = paneExitLabel(paneExit);
const existing = tab.querySelector('.tab-exited-badge');
// Quiets the status dot too. That dot reports `status`, which stays `idle` or
// `busy` for an exited pane by design, so without this a green or pulsing dot
// sits next to a badge saying the agent is gone.
tab.classList.toggle('tab-agent-exited', !!label);
// The tab's aria-label overrides its contents for the accessible name, and the
// badge is aria-hidden like its siblings, so the exit has to ride the label.
const name = tab.querySelector('.tab-name')?.dataset?.fullName;
if (name) tab.setAttribute('aria-label', paneExitAriaLabel(name, label));
if (!label) {
existing?.remove();
return;
}
if (!existing) {
const badge = document.createElement('span');
badge.className = 'tab-exited-badge';
badge.setAttribute('aria-hidden', 'true');
// Generated status text, like the status pills: it carries data-i18n-skip
// rather than a dictionary entry. Without it the translator would rewrite
// the badge and the next render pass would rewrite it back, because the
// comparison below is against the English string.
badge.setAttribute('data-i18n-skip', '');
badge.textContent = label;
tab.querySelector('.tab-name')?.insertAdjacentElement('afterend', badge);
return;
}
if (existing.textContent !== label) existing.textContent = label;
}
const DEFAULT_SHORTCUTS = [
{
id: 'show-shortcuts',
@@ -682,6 +758,10 @@ class CodemanApp {
this._wsReady = false; // True when WS is open and ready for I/O
this._wsState = 'disconnected'; // connecting | connected | reconnecting | fallback | disconnected
this._wsLastRecvAt = 0; // ms timestamp of the last frame received on the active WS
// Session whose socket dropped unintentionally, so output produced during
// the outage is missing from its buffer. Output frames carry no sequence
// number, so the only recovery is to refetch on the next successful open.
this._wsOutputGapSession = null;
// Terminal write batching with DEC 2026 sync support
this.pendingWrites = [];
@@ -1998,14 +2078,12 @@ class CodemanApp {
+ (this._loadBufferQueue?.reduce((s, w) => s + w.data.length, 0) || 0)
+ (this._terminalWriteInFlightBytes || 0);
if (queued + data.data.length > 131072) { // 128KB — drop to prevent accumulation
// Schedule a self-recovery once the
// queue drains (debounced to avoid hammering the API during sustained bursts).
if (!this._clientDropRecoveryTimer) {
this._clientDropRecoveryTimer = setTimeout(() => {
this._clientDropRecoveryTimer = null;
this._onSessionNeedsRefresh();
}, 2000);
}
// The bytes are gone from the stream now, so the recovery is the only
// thing that puts this terminal back in step with the PTY. It also
// writes the crash-trail line, once per debounce window: logged here,
// one line per dropped frame evicted the whole 50-entry trail in
// under a second.
this._scheduleDroppedOutputRecovery(data.id, 0, queued);
return;
}
@@ -2013,6 +2091,61 @@ class CodemanApp {
}
}
/**
* Put the terminal back in step after a dropped frame, and keep trying until
* something actually repaints.
*
* ⚠️ A fire-and-forget timer is not a recovery, which is what this used to be:
* it nulled its own handle and then called `_onSessionNeedsRefresh()`, whose
* early returns are most likely to fire during the very burst that caused the
* drop. A skipped refresh lost the recovery with nothing left to retry it, so
* the hole stayed in the stream — and a hole in a TUI byte stream is a
* desynced cursor, which is muffled text (issue #464).
*
* Debounced by the same 2s as before, so a sustained burst still collapses
* into one attempt rather than hammering the API; bounded by
* `DROP_RECOVERY_MAX_ATTEMPTS`, because the early returns it retries past
* are transient contention. A refresh that died at the fetch DEADLINE is
* not retried: that is a stalled link, not contention, and each retry would
* be another `?full=1` capture waiting out a deadline of up to two minutes.
* Giving up leaves exactly what the old code left, so the floor is no worse.
*
* @param {string} sessionId - the session whose output was dropped
* @param {number} [attempt] - zero-based, for the bound
* @param {number} [queuedBytes] - render-queue bytes at the drop, for the crash trail
*/
_scheduleDroppedOutputRecovery(sessionId, attempt = 0, queuedBytes) {
if (!sessionId || this._clientDropRecoveryTimer) return;
// Behind the debounce guard: one line per window, not per dropped frame.
if (Number.isFinite(queuedBytes)) _crashDiag.log(`TERMINAL DROP: ${(queuedBytes / 1024).toFixed(0)}KB queued`);
this._clientDropRecoveryTimer = setTimeout(async () => {
this._clientDropRecoveryTimer = null;
let repainted = false;
let timedOut = false;
try {
const result = await this._onSessionNeedsRefresh({ id: sessionId });
repainted = result === true;
timedOut = result === 'deadline';
} catch {
// Treated as "did not repaint" — retrying is the entire point of this.
}
const retry = window.CodemanDroppedOutput.shouldRetryDroppedOutputRecovery({
repainted,
timedOut,
attempt,
stillActive: this.activeSessionId === sessionId,
});
if (retry) {
_crashDiag.log(`DROP RECOVERY: attempt ${attempt + 1} did not repaint, retrying`);
this._scheduleDroppedOutputRecovery(sessionId, attempt + 1);
}
// Read through `window.` like terminal-ui.js does with its own constants:
// a bare global resolves in a browser but not in the vm harnesses the gate
// runs app.js under, and this body executes inside a timer where a
// ReferenceError would be swallowed — taking the recovery with it.
}, window.CodemanDroppedOutput.DROP_RECOVERY_DELAY_MS);
}
// ═══════════════════════════════════════════════════════════════
// Response Viewer — native-scroll panel for reading full Claude responses
// ═══════════════════════════════════════════════════════════════
@@ -2436,8 +2569,14 @@ class CodemanApp {
// placeholder beats a messy screen dump there.
const sessionMode = this.sessions.get(this.activeSessionId)?.mode || 'claude';
if (!lastResponse && (sessionMode === 'claude' || sessionMode === 'shell')) {
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
const termData = (await termRes.json())?.data ?? {};
// The no-param form is capped only by `terminalBufferMaxBytes` (32MB by
// default), so it is the largest body the frontend asks for anywhere —
// it gets the full-history budget, not the tail one.
const termCapture = await this._fetchTerminalCapture(
`/api/sessions/${this.activeSessionId}/terminal`,
{ full: true }
);
const termData = termCapture.json?.data ?? {};
if (termData.terminalBuffer) {
lastResponse = this._cleanTerminalBuffer(termData.terminalBuffer);
}
@@ -2555,15 +2694,104 @@ class CodemanApp {
}
}
/**
* Fetch a terminal capture under a deadline.
*
* Every terminal fetch used to run with no timeout at all, including
* `?full=1`, which _maybeRefetchFullHistory itself calls "unbounded-ish work:
* at the default history limit it can be megabytes". On a stalled mobile link
* that request hangs on the browser default with no retry, and the load-state
* machinery stays armed behind it.
*
* The budget scales with what is being asked for and with how many captures
* are already running (see CodemanFetchDeadline): a full scrollback on a slow
* uplink legitimately needs longer than a tail, and eight tabs resuming must
* not all expire together because each assumed it had the link to itself.
*
* An abort surfaces as a rejected promise, which every caller already handles —
* they wrap these in try/catch and log. That is the point: a timeout becomes a
* recoverable error instead of an indefinite hang.
*
* ⚠️ **The body is read HERE, and that is the whole point.** `await fetch()`
* settles on response HEADERS, not the body, so clearing the deadline when it
* resolves leaves the body — the multi-megabyte `?full=1` capture this exists
* for — completely unbounded. Measured against a server that sends headers
* immediately and stalls the body: `fetch()` resolved at 30ms, the timer was
* cleared there, and the body completed at 4026ms unaborted under a 1000ms
* deadline. Reading the body inside the helper is what makes the deadline
* cover the transfer rather than just the handshake. `_terminalCaptureInflight`
* is scoped the same way, so a body still streaming counts toward the budget
* of a capture starting beside it.
*
* Returns the PARSED envelope plus the response headers, because two callers
* read `server-timing`, and `headersAt` because those same callers measure
* header-vs-body time and can no longer observe that moment themselves.
*
* @param {string} url
* @param {{full?: boolean}} [opts]
* @returns {Promise<{json: unknown, headers: Headers|undefined, headersAt: number}>}
*/
async _fetchTerminalCapture(url, opts = {}) {
const deadlineMs =
typeof CodemanFetchDeadline !== 'undefined'
? CodemanFetchDeadline.terminalFetchDeadlineMs({
full: !!opts.full,
inflight: this._terminalCaptureInflight || 0,
})
: 45000;
// AbortSignal.timeout() is not on every browser Codeman supports, so drive
// it from a controller and always clear the timer — an uncancelled one
// would abort a LATER request that reused this controller's signal.
//
// Degrade to a plain fetch where AbortController is missing rather than
// throwing: a capture with no deadline is the behaviour every caller had
// before this helper existed, while a ReferenceError here would take out
// terminal replay entirely. The deadline is a safety net, not a dependency.
const canAbort = typeof AbortController === 'function';
const controller = canAbort ? new AbortController() : null;
const timer = controller ? setTimeout(() => controller.abort(), deadlineMs) : null;
this._terminalCaptureInflight = (this._terminalCaptureInflight || 0) + 1;
try {
const res = await (controller ? fetch(url, { signal: controller.signal }) : fetch(url));
const headersAt = performance.now();
// Still inside the deadline: an abort here rejects the body stream, which
// is exactly the case a header-only timeout could not reach.
const json = await res.json();
return { json, headers: res.headers, headersAt };
} catch (err) {
if (err?.name === 'AbortError') {
_crashDiag.log(`TERMINAL FETCH TIMEOUT after ${deadlineMs}ms`);
}
throw err;
} finally {
if (timer !== null) clearTimeout(timer);
this._terminalCaptureInflight = Math.max(0, (this._terminalCaptureInflight || 1) - 1);
}
}
/**
* Reload this session's buffer from the server.
*
* ⚠️ Returns whether it ACTUALLY reloaded. Four of the paths out of here are
* early returns, and two of them — a buffer load in flight, a refresh already
* owning this session — are most likely to be true during exactly the output
* burst that makes a caller need this. A caller that treats "called" as
* "recovered" silently loses the recovery; `_scheduleDroppedOutputRecovery`
* is the one that cannot afford to.
*
* @returns {Promise<boolean|'deadline'>} true only once a response has been
* applied; 'deadline' when the capture fetch hit its deadline (a stalled
* link, which the dropped-output scheduler does not retry); false otherwise.
*/
async _onSessionNeedsRefresh(event = {}) {
// Server sends this after SSE backpressure clears — terminal data was dropped,
// so reload the buffer to recover from any display corruption.
const sessionId = this.activeSessionId;
if (event?.id && event.id !== sessionId) return;
if (!sessionId || !this.terminal) return;
if (event?.id && event.id !== sessionId) return false;
if (!sessionId || !this.terminal) return false;
// Skip if buffer load already in progress — avoids competing clear+rewrite cycles
if (this._isLoadingBuffer) return;
if (this._terminalRefreshOwner?.sessionId === sessionId) return;
if (this._isLoadingBuffer) return false;
if (this._terminalRefreshOwner?.sessionId === sessionId) return false;
const refreshOwner = { sessionId };
this._terminalRefreshOwner = refreshOwner;
try {
@@ -2573,22 +2801,23 @@ class CodemanApp {
// TUI modes still recover the whole picture, with the downgrade guard for
// repaint-mode panes whose tmux capture can be smaller than xterm's buffer.
const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell';
let res = await fetch(
let capture = await this._fetchTerminalCapture(
useFullHistory
? `/api/sessions/${sessionId}/terminal?full=1`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`,
{ full: useFullHistory }
);
let headersReceivedAt = performance.now();
let data = (await res.json())?.data ?? {};
let headersReceivedAt = capture.headersAt;
let data = capture.json?.data ?? {};
if (useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
headersReceivedAt = performance.now();
data = (await res.json())?.data ?? {};
capture = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
headersReceivedAt = capture.headersAt;
data = capture.json?.data ?? {};
}
// Bail on a tab switch mid-fetch: writing here would paint this session's
// history into the terminal the user is now looking at. The window is two
// fetches wide in the fallback case, so this guard is not optional.
if (this.activeSessionId !== sessionId || this._terminalRefreshOwner !== refreshOwner) return;
if (this.activeSessionId !== sessionId || this._terminalRefreshOwner !== refreshOwner) return false;
if (data.terminalBuffer) {
// This refresh is SERVER-triggered, so a user quietly reading scrollback
// did not ask for it and must not be dragged to the bottom by it (#259).
@@ -2596,8 +2825,11 @@ class CodemanApp {
// meaningless across it — distance from the bottom is what survives.
const before = this.terminal.buffer?.active;
const linesFromBottom = before ? Math.max(0, (before.baseY || 0) - (before.viewportY || 0)) : 0;
this.terminal.clear();
this.terminal.reset();
// One queued clear, not clear()+reset(): both of those are synchronous
// and skip xterm's write queue, so live bytes still parsing would land
// after them and fuse into the buffer written below. See
// _resetTerminalForReplay.
this._resetTerminalForReplay();
await this.chunkedTerminalWrite(
data.terminalBuffer,
TERMINAL_CHUNK_SIZE,
@@ -2625,13 +2857,36 @@ class CodemanApp {
this.sendResize(this.activeSessionId);
}
}
// ⚠️ HERE: after a response arrived, and NOT in the `finally`. The marker
// means "this session lost output", and only a reconcile that actually
// completed settles it. Clearing on every exit meant one that threw — or
// hit the fetch deadline, which is the flaky-link case the marker exists
// for — dropped the gap silently with nothing to retry it.
// ⚠️ Outside the `if (data.terminalBuffer)` too: a server that answers
// with an empty capture HAS reconciled us, there was simply nothing to
// replay. Leaving the marker set there refetched on every reconnect for
// the life of the page.
this._markTerminalBufferReconciled(sessionId);
return true;
} catch (err) {
console.error('needsRefresh reload failed:', err);
return err?.name === 'AbortError' ? 'deadline' : false;
} finally {
if (this._terminalRefreshOwner === refreshOwner) this._terminalRefreshOwner = null;
}
}
/**
* Drop the "this session lost output" marker.
*
* Called from every path that repaints a session's buffer from the server, so
* the ws.onopen reconcile fires once and only when nothing else already did
* the work. See the ws.onclose note for what the marker means.
*/
_markTerminalBufferReconciled(sessionId) {
if (sessionId && this._wsOutputGapSession === sessionId) this._wsOutputGapSession = null;
}
async _onSessionClearTerminal(data) {
if (data.id === this.activeSessionId) {
// Skip if selectSession is already loading the buffer — clearTerminal arriving
@@ -2642,12 +2897,16 @@ class CodemanApp {
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
try {
const res = await fetch(`/api/sessions/${data.id}/terminal`);
const headersReceivedAt = performance.now();
const termData = (await res.json())?.data ?? {};
// No-param capture: `terminalBufferMaxBytes` (32MB) is its only ceiling,
// so it needs the full-history budget. Defaulting to the tail budget
// gave the largest payload the smallest deadline.
const capture = await this._fetchTerminalCapture(`/api/sessions/${data.id}/terminal`, { full: true });
const headersReceivedAt = capture.headersAt;
const termData = capture.json?.data ?? {};
this.terminal.clear();
this.terminal.reset();
// Queued clear — see _resetTerminalForReplay for why clear()+reset()
// cannot do this job.
this._resetTerminalForReplay();
if (termData.terminalBuffer) {
// Strip any DEC 2026 markers and write raw content
// (markers don't help here - this is a static buffer reload, not live Ink redraws)
@@ -2972,6 +3231,22 @@ class CodemanApp {
// Flush any durably-queued input over the fresh socket (covers frames a
// prior half-open socket silently dropped, and input typed while offline).
this._onWsReady(sessionId);
// Reconcile the output hole this drop left (see the ws.onclose note).
// Only after an unintentional close — a first connect has no gap, and
// refetching there would duplicate the buffer selectSession just wrote.
if (this._wsOutputGapSession === sessionId) {
// NOT cleared here. `_onSessionNeedsRefresh` clears it once it has
// actually repainted; a reconcile that fails or is skipped (a buffer
// load already in flight, a tab switch) leaves the marker set so the
// next open retries. Re-entry is safe: `_terminalRefreshOwner` makes
// a second reconcile for the same session a no-op.
_crashDiag.log(`WS REOPEN: reconciling output gap for ${sessionId}`);
// Fire-and-forget: this is recovery, and a failure here must not stop
// the socket coming up. _onSessionNeedsRefresh already guards against
// running while a buffer load is in flight and against a tab switch
// landing this session's history in another session's terminal.
void this._onSessionNeedsRefresh({ id: sessionId });
}
}
};
@@ -2993,6 +3268,10 @@ class CodemanApp {
// Input ACK — the server applied (or deduped) this seq; drop it from
// the durable queue so it can never be re-delivered/lost.
this._onWsInputAck(msg.seq, msg);
} else if (msg.t === 'zc') {
// Resize confirm — the geometry the PTY actually holds, which is not
// always the one this client asked for (issue #464).
this._onPtyGeometryReport(sessionId, msg.c, msg.r);
}
} catch {
// Ignore malformed messages
@@ -3019,6 +3298,30 @@ class CodemanApp {
`WS CLOSE code=${event.code} reason=${event.reason || ''} action=${plan.action} attempts=${this._wsReconnectAttempts || 0}`
);
// Output frames carry no sequence number, so a dropped socket leaves a
// hole with nothing to replay it. ws.onopen re-sends dims and flushes
// queued INPUT; `needsRefresh` fires only on external-CLI startup and on
// SSE backpressure drain, never here.
//
// ⚠️ The gap this closes is NARROWER than "the device went offline". If
// the network drops, SSE drops with it and `handleInit`'s keepTerminal
// branch already reconciles on reconnect. The uncovered case is the WS
// dying while SSE stays up — a half-open socket, a proxy idle-timeout,
// a ping timeout — because `_onSSETerminal` discards every SSE terminal
// frame while `_wsReady` is true, and `_wsReady` only flips here, in
// onclose. Detecting a half-open socket takes up to the ping+pong window,
// and that whole span produces output nothing writes to the terminal.
//
// Reaching onclose at all means the drop was NOT intentional
// (_disconnectWs nulls this handler first), so mark the gap and let the
// next successful open reconcile from the server's buffer.
//
// Scoped to the session that actually lost bytes: a user who switches
// sessions during an outage gets a clean intentional disconnect for the
// new one, and its freshly-loaded buffer must not be refetched because a
// DIFFERENT session's socket dropped.
this._wsOutputGapSession = sessionId;
const stillActive = this.activeSessionId === sessionId;
if (plan.action === 'give-up') {
this._wsState = stillActive ? 'fallback' : 'disconnected';
@@ -4571,11 +4874,30 @@ class CodemanApp {
_sidebarRichRow(id, session) {
if (typeof this._mobileOverviewState !== 'function') return null;
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
// An exited agent (Ark0N/Codeman#446) overrides the LABEL, never the state:
// `state` keys SESSION_ACTIVITY_RANK and the sort, while `status` stays idle
// or busy for an exited pane by design, so without this the muted dot sits
// beside a pill saying "idle". A pending alert still wins, exactly as it
// does for the dot.
const exited = !!paneExitLabel(session.paneExit) && (state === 'idle' || state === 'working');
const exitAt = exited ? Number(session.paneExit.at) || 0 : 0;
return {
state,
pill: this._sidebarRichPillLabel(state),
exited,
pill: exited ? 'exited' : this._sidebarRichPillLabel(state),
// What the pane's own footer says is still running in the background ("1 monitor",
// "2 shells"). A row that has one went quiet because the agent is waiting for that,
// which is a different thing from waiting for the user — so it rides BESIDE the
// state pill and never replaces it.
watching: typeof session.watching === 'string' ? session.watching : '',
createdAt: Number(session.createdAt) || 0,
since: this._mobileOverviewSince ? this._mobileOverviewSince(state, session) : null,
since: exitAt
? { key: 'exited', at: exitAt }
: exited
? null
: this._mobileOverviewSince
? this._mobileOverviewSince(state, session)
: null,
};
}
@@ -4608,7 +4930,19 @@ class CodemanApp {
parts.push('<span class="tab-meta-sep" aria-hidden="true">\u00B7</span>');
parts.push(stamp(row.since.key, row.since.at, 'for', 'tab-meta-since'));
}
parts.push(`<span class="tab-pill tab-pill--${escapeHtml(row.state)}">${escapeHtml(row.pill)}</span>`);
const pillMod = row.exited ? 'exited' : row.state;
parts.push(`<span class="tab-pill tab-pill--${escapeHtml(pillMod)}">${escapeHtml(row.pill)}</span>`);
// The word is duplicated from mobile-overview.js for the same reason the pill labels
// above are: it is one word, and this file must render a complete row even when a
// stale cached mobile-overview.js has arrived without it.
// The visible text is that constant. The pane-derived label appears only in the
// tooltip, where escapeHtml() (which escapes both quote characters) is what this file
// already relies on for every untrusted string it puts in an attribute, and where the
// source caps it at MAX_WATCHING_LABEL_CHARS before it ever gets here.
if (row.watching) {
const title = escapeHtml(`Still running in the background: ${row.watching}`);
parts.push(`<span class="tab-pill tab-pill--watching" title="${title}">watching</span>`);
}
// Both absolute stamps ALSO on the line itself, not only on the two items.
// Below 288px the rail hides `.tab-meta-created` (the `tab-rail-tight`
// rule), and a tooltip on a `display: none` element has no hover target —
@@ -4646,7 +4980,11 @@ class CodemanApp {
const prev = tab.dataset.tabState;
// The since ANCHOR moves without the state changing (each new turn re-stamps
// lastSubmitAt), so key the compare on both.
const sig = `${row.state}:${row.since ? row.since.at : 0}:${row.createdAt}`;
// Unescaped on purpose, and it still matches the attribute the initial render wrote:
// that one goes through escapeHtml() because it is interpolated into markup, and the
// browser hands the decoded string back through `dataset`. `watching` is the only
// pane-derived value in this signature, which is why it is the only one escaped there.
const sig = `${row.state}${row.exited ? '+exited' : ''}:${row.since ? row.since.at : 0}:${row.createdAt}:${row.watching}`;
if (tab.dataset.tabMetaSig === sig) return;
tab.dataset.tabMetaSig = sig;
tab.dataset.tabState = row.state;
@@ -4968,6 +5306,11 @@ class CodemanApp {
statusEl.className = `tab-status ${status}`;
}
// The exited-agent badge (Ark0N/Codeman#446). A session going from live
// to exited changes no tab count, so the full rebuild below never runs
// for it and this is the only path that ever draws the badge.
applyPaneExitBadge(tab, session.paneExit);
// Rich sidebar meta ("created 3d ago · working 12m" + pill). The stamps
// themselves move on _tickSidebarRichTimes(); this is here for the parts
// a tick cannot see — the state flipping, and with it the pill, the row
@@ -5265,13 +5608,18 @@ class CodemanApp {
const richMeta = this._sidebarRichMetaHTML(richRow);
const richClass = richRow ? ` tab-state-${richRow.state}` : '';
const richData = richRow
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}"`
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}${richRow.exited ? '+exited' : ''}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}:${escapeHtml(richRow.watching)}"`
: '';
// '' whenever the server said nothing about this pane's agent, which covers
// a running pane and every session shape the field never applies to
// (direct-PTY, remote SSH, docker). See paneExitLabel().
const paneExitBadge = paneExitLabel(session.paneExit);
const inlineSessionActions = this.shouldInlineSessionActions();
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
@@ -5279,6 +5627,7 @@ class CodemanApp {
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''}
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
${paneExitBadge ? `<span class="tab-exited-badge" data-i18n-skip aria-hidden="true">${escapeHtml(paneExitBadge)}</span>` : ''}
${inlineSessionActions ? tabActionsHtml : ''}
<span class="tab-detached-badge" aria-hidden="true">detached</span>
</span>
@@ -5866,9 +6215,29 @@ class CodemanApp {
}
}
/**
* Clear the terminal for a replay, IN STREAM.
*
* xterm's `write()` is asynchronously queued (the WriteBuffer parses in ~12ms
* slices) while `Terminal.reset()` is synchronous and, by upstream's own
* documentation, "does not clear input buffers and does not reset the parser,
* thus the terminal will continue to apply pending input data". So bytes
* queued just before a `reset()` are parsed AFTER it and fuse into whatever
* snapshot is written next — measured upstream as `p8rmissions` rendered
* where `bypass permissions` belonged.
*
* A queued clear cannot race that way: it lands after the leftovers and
* before the snapshot, whatever the queue held. This function used to follow
* the sync `reset()` with a queued `\x1b[3J\x1b[H\x1b[2J`, which already got
* that right for CONTENT. RIS (`\x1bc`) additionally resets modes, charsets,
* scroll regions and SGR state, so leftover bytes cannot park the terminal in
* alt-screen or an odd scroll region and survive the clear.
*
* Callers may write the replacement content in as many chunks as they like —
* ordering within the queue is what matters, not writing it all at once.
*/
_resetTerminalForReplay() {
this.terminal.reset();
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
this.terminal.write('\x1bc');
}
_recordTerminalLoadTiming(timing) {
@@ -5932,9 +6301,9 @@ class CodemanApp {
this._fullHistoryRepullInFlight = true;
try {
const requestStartedAt = performance.now();
const res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
const headersReceivedAt = performance.now();
const payload = (await res.json())?.data ?? {};
const capture = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true });
const headersReceivedAt = capture.headersAt;
const payload = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
const buffer = payload.terminalBuffer;
const timing = {
@@ -5947,7 +6316,7 @@ class CodemanApp {
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
resetAndParseMs: 0,
totalMs: 0,
serverTiming: res.headers?.get?.('server-timing') || '',
serverTiming: capture.headers?.get?.('server-timing') || '',
refused: false,
};
// Bail on a tab switch mid-fetch: writing here would paint another session's
@@ -6315,11 +6684,18 @@ class CodemanApp {
// For that just-created-session case we flush (not discard) queued SSE events.
let bufferWasEmpty = false;
let cacheResetAndParseMs = 0;
// Hoisted out of the try: the catch needs to know whether the pane was
// blanked before the fetch, because only then is there nothing on screen.
let clearedBeforeFresh = false;
try {
// Fit terminal to container BEFORE writing any buffer data.
// If the browser was resized while viewing another session, the terminal
// canvas may be at stale dimensions — content would render at wrong width.
if (this.fitAddon) this.fitAddon.fit();
// A width refusal belonged to the PREVIOUS session's pane, and while it
// stands sendResize keeps the columns it last adopted; this pane's own
// report re-establishes it if another device holds this one too.
this._paneWidthRefused = false;
this.syncTerminalGeometry();
// Also push the new dimensions to the PTY. Without this, codex/codeman
// sees the size that was set the last time the throttled resize handler
@@ -6396,7 +6772,8 @@ class CodemanApp {
// blank and rewrites with fresh data. Skip the cache and write the fresh
// buffer once for a single clean transition.
const cachedBuffer = this.terminalBufferCache.get(sessionId);
let clearedBeforeFresh = false;
// `clearedBeforeFresh` is declared above the try, because the catch reads
// it — re-declaring it here would shadow that and silently break it.
if (cachedBuffer && !sessionIsBusy && !restoredSnapshot && session?.mode !== 'shell') {
_crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
this._setTerminalLoadState(sessionId, selectGen, 'replaying');
@@ -6445,17 +6822,32 @@ class CodemanApp {
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
const fetchStartedAt = performance.now();
const res = await fetch(
useFullHistory
? `/api/sessions/${sessionId}/terminal?full=1`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
);
const headersReceivedAt = performance.now();
const tailUrl = `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`;
let capture;
try {
capture = await this._fetchTerminalCapture(
useFullHistory ? `/api/sessions/${sessionId}/terminal?full=1` : tailUrl,
{ full: useFullHistory }
);
} catch (err) {
// The deadline made a slow link reachable for the first time, and the
// pane was already blanked above — so an abort here used to leave a
// black rectangle, discard the queued live output, and never reach
// `_connectWs`. Degrade to the bounded tail instead: less history, but
// a working tab. Only for the full-history pull; the tail has nothing
// smaller to fall back to, and a second failure is the honest floor.
if (err?.name !== 'AbortError' || !useFullHistory) throw err;
_crashDiag.log('FULL CAPTURE ABORTED → tail');
// It never loaded, so the next select must be allowed to try again.
this._fullHistoryLoaded.delete(sessionId);
capture = await this._fetchTerminalCapture(tailUrl);
}
const headersReceivedAt = capture.headersAt;
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
const data = (await res.json())?.data ?? {};
const data = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
// How this load must end, decided here because `chunkedTerminalWrite` is
// what actually ends it for a non-empty buffer. A tmux pane capture is a
@@ -6535,7 +6927,7 @@ class CodemanApp {
cacheResetAndParseMs,
freshResetAndParseMs,
selectToReplayCompleteMs: performance.now() - _selStart,
serverTiming: res.headers?.get?.('server-timing') || '',
serverTiming: capture.headers?.get?.('server-timing') || '',
};
// Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
// _finishBufferLoad after ordering the fetched snapshot in xterm; if we skipped
@@ -6552,6 +6944,11 @@ class CodemanApp {
bufferWasEmpty ? { flushQueued: true, since: 0 } : finishOpts
);
}
// This load repainted the session from the server, so any pending
// output-gap marker is already satisfied. Selecting a session runs BEFORE
// _connectWs, so without this the socket opening afterwards would replay
// the whole buffer again on top of the one just written.
this._markTerminalBufferReconciled(sessionId);
// Drop the guard so user input clears state normally
this._restoringFlushedState = false;
@@ -6646,17 +7043,28 @@ class CodemanApp {
// and, because it goes through `forceReload`, a dropped and reopened
// WebSocket plus a deleted xterm snapshot.
//
// That equality is the signature of a CLAMP rather than a race.
// `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` does
// not, so a terminal narrower than 40 columns or shorter than 10 rows
// reports a pane permanently bigger than itself, and every select would
// retry without ever converging. A race never produces this equality: its
// whole premise is that the pane was still at the size we asked it to
// leave. The other non-converging case, `Session.resize` declining a
// small viewport while a desktop claim is live, does not produce it
// either — that pane sits at the DESKTOP's size — so it still costs the
// one capped attempt, and stopping it needs the pane-ownership policy
// this does not touch.
// A race never produces this equality: its whole premise is that the pane
// was still at the size we asked it to leave. So the equality means the
// pane already IS what we asked for and a retry would capture the same
// frame twice.
//
// ⚠️ This used to also be the signature of a CLAMP, and that is now fixed
// at the source rather than worked around here (issue #464).
// `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` did
// not, so a terminal under 40 columns or 10 rows reported a pane
// permanently bigger than itself and every select retried without ever
// converging. `syncTerminalGeometry()` now applies that floor to xterm as
// well, so the browser terminal IS the size it reports and the clamp can
// no longer manufacture a mismatch — which also means the repair below is
// reached only by cases it can actually repair.
//
// The other non-converging case, `Session.resize` declining a small
// viewport while a desktop claim is live, does not produce this equality
// either — that pane sits at the DESKTOP's size. It no longer needs
// repairing from here: the server reports the geometry the PTY actually
// holds ({"t":"zc"} / the resize response) and `_onPtyGeometryReport`
// adopts it, so the terminal matches the pane that is being drawn instead
// of replaying against one that never existed.
const captureMatchesRequestedSize =
!!dimsAfterLoad && data.captureCols === dimsAfterLoad.cols && data.captureRows === dimsAfterLoad.rows;
@@ -6823,14 +7231,46 @@ class CodemanApp {
} catch (err) {
if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
this._restoringFlushedState = false;
this._setTerminalLoadState(sessionId, selectGen, 'failed');
console.error('Failed to load session terminal:', err);
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
// The history did not load. That is not a reason to leave the tab dead:
// ⚠️ the socket is what carries LIVE output, and it is opened at the end
// of the happy path, so bailing here left the session mute until the user
// switched away and back.
this._connectWs(sessionId);
// Only when the pane was blanked for a replay that never came. A pane
// still holding its previous content is stale, not empty, and stacking a
// notice on top of readable output is worse than the staleness.
if (clearedBeforeFresh && this.terminal) {
// Three short lines, none over 25 columns, because the narrowest
// terminal this app will render is the 40-column floor and a notice
// that wraps there leaves a lone '.' on a line of its own — measured at
// 320px, where a single 52-character sentence did exactly that.
// Each line is one fact: what failed, that the session is still alive,
// and what to do. The last says RELOAD rather than "reopen the tab",
// because `selectSession` early-returns when the session is already
// active, so clicking the tab you are already on retries nothing.
this.terminal.write(
'\r\n\x1b[2m History did not load.\r\n Live output continues.\r\n Reload to try again.\x1b[0m\r\n'
);
}
// ⚠️ CLEAR, not 'failed'. `_setTerminalLoadState` only marks the TAB, and
// nothing ever cleared it on this path — so the tab kept its spinner and
// `aria-busy="true"` forever, telling every reader and every screen reader
// that a load was still running when it had already given up.
this._clearTerminalLoadState(sessionId, selectGen);
}
}
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler
_cleanupSessionData(sessionId) {
this.closeTabRailActionMenu?.();
// A dead session has no buffer to reconcile; leaving the marker set would
// make a later socket for a REUSED id reconcile against nothing.
this._markTerminalBufferReconciled(sessionId);
// If the deleted session is currently being renamed, abort the rename
// so the inline <input> doesn't ghost as a stale tab on screen.
if (this._activeRename?.sessionId === sessionId) {
@@ -6851,6 +7291,7 @@ class CodemanApp {
this._flushedOffsets?.delete(sessionId);
this._flushedTexts?.delete(sessionId);
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.discardComposerDraft?.(sessionId);
// Drop any durably-queued input for a session that's actually gone (deleted/
// exited). Not a lost prompt — the target no longer exists. Only reached on
// real session removal, never on a tab switch.
+26 -1
View File
@@ -194,8 +194,21 @@ Object.assign(CodemanApp.prototype, {
document.querySelector('.btn-approvals')?.setAttribute('aria-expanded', 'false');
},
/**
* Items still waiting on a human. An acknowledged item (a human already looked, or the
* session opened it acknowledged because it is watching its own background work) keeps
* its card but arms no alert, so it must not light the bell either; this is the same
* count `pendingApprovalCount()` gives `codeman tui`.
*/
pendingApprovalsCount() {
if (!this.approvals) return 0;
let count = 0;
for (const item of this.approvals.values()) if (!item.acknowledgedAt) count++;
return count;
},
renderApprovals() {
const count = this.approvals ? this.approvals.size : 0;
const count = this.pendingApprovalsCount();
const btn = document.querySelector('.btn-approvals');
if (btn) {
// Marker-class visibility (base header rules are display !important):
@@ -222,6 +235,15 @@ Object.assign(CodemanApp.prototype, {
return;
}
list.innerHTML = items.map((item) => this._approvalCardHtml(item)).join('');
// The quiet reason is the only pane-derived string on a card, and it is the one
// an agent could write itself (it prints its own footer row), so it reaches the
// DOM as text and never as markup. The card leaves an empty span for it.
for (const item of items) {
if (!item.acknowledgedReason) continue;
const card = list.querySelector(`[data-approval-id="${CSS.escape(item.id)}"]`);
const slot = card && card.querySelector('.approval-quiet');
if (slot) slot.textContent = 'quiet, ' + item.acknowledgedReason;
}
},
_approvalCardHtml(item) {
@@ -260,6 +282,9 @@ Object.assign(CodemanApp.prototype, {
`<span class="approval-session" data-i18n-skip>${escapeHtml(item.sessionName || item.sessionId.slice(0, 8))}</span>` +
`<span class="approval-age" data-i18n-skip>${age}</span>` +
`</div>` +
// Filled by renderApprovalsDrawer through textContent, never here: see the note
// there. An item a human acknowledged carries no reason and gets no line.
(item.acknowledgedReason ? `<div class="approval-quiet" data-i18n-skip></div>` : '') +
(summary ? `<div class="approval-summary" data-i18n-skip>${escapeHtml(summary)}</div>` : '') +
(item.context ? `<pre class="approval-context">${escapeHtml(item.context)}</pre>` : '') +
`<div class="approval-actions">${actions}</div>` +
+337 -24
View File
@@ -819,36 +819,32 @@ function decideAutoCopy({ enabled, text, lastCopied, pending } = {}) {
// _selectTouchSelectionLine already treats those cells as padding. This is that
// same rule for the mouse and keyboard paths, which never had it.
//
// ⚠ Trailing padding ONLY. A shared LEADING indent is deliberately left alone,
// and this note is here so the idea is not re-derived: it was built, measured
// and dropped before merge. Removing the longest leading run every selected row
// shares looks like the mirror image of the trailing trim and is not, because
// no native terminal does it and the transform cannot tell a TUI's margin from
// content that is genuinely indented. Measured over 401 445 three-row windows
// across 1 010 tracked files in this repo, it fired on 73% of them: 92% inside
// a YAML workflow, 76% over `git log` output, 48% in a TypeScript source file.
// No width threshold separates the two, because they are the same widths: a
// live Claude Code pane's own margins measure 2 and 5 columns while the most
// common non-TUI shared run is 4, sitting between them.
// A LEADING margin is stripped too, but only the one the CLI in the pane
// DECLARES as its transcript gutter, passed in as `options.margin`. Called with
// no options this trims trailing padding and nothing else, which is what keeps
// every caller that has no declared gutter on the old behaviour.
//
// The asymmetry that settles it is in the failure modes. A wrong trailing trim
// ⚠ The failure modes are not symmetrical, and that asymmetry sets how much
// evidence a leading strip has to show before it fires. A wrong trailing trim
// costs nothing. A wrong dedent silently deletes information that was on the
// screen, with no signal to the user and nothing in the clipboard to hint at
// it, and it is wrong on `git log` bodies, on indented code read out of `cat`
// (semantic in Python), on `git diff` context rows where the leading space is
// the marker, and on stack traces.
//
// ⚠ It also cannot be made consistent cheaply. Whether the first row joins the
// measurement depended on the mousedown COLUMN, which the user never sees, so
// one block of three rows produced three different clipboard results; and the
// flag read `getSelectionPosition().start`, which is the mousedown anchor that
// xterm never normalises, so dragging UP through a block read it off the bottom
// row. If it is ever revisited, the one qualification that measured clean is
// painted trailing padding (a full-screen TUI writes real spaces across every
// row; a shell pane leaves those cells never-written, so xterm trims them):
// zero false positives over all 401 445 windows. It still mangles a `git log`
// body sitting inside an agent's own gutter, which is why it was not taken now.
function cleanCopiedSelection(text) {
// ⚠ The declared gutter is a CEILING, not the answer. The strip is the lesser
// of it and the run every selected line shares, so a block can only ever shift
// as a unit: the relative structure inside a selection survives by
// construction, and a selection reaching column 0 loses nothing at all.
//
// ⚠ Deriving the width from the text instead is what fails, twice over. The
// selection's own shared indent cannot tell a margin from content, because a
// three-row window of nested YAML shares an indent for the same reason a margin
// does — it fired on 73% of ordinary indented text. Taking the narrowest indent
// on the surrounding rows fails more quietly: a file listing inside the
// transcript can be the narrowest thing on screen, which over-stripped about 1%
// of selections across six pane widths.
function cleanCopiedSelection(text, options) {
if (typeof text !== 'string' || !text) return '';
// Split on \n and leave any \r in place: xterm joins rows with \r\n on
// Windows, and the clipboard should keep the endings xterm chose.
@@ -868,7 +864,36 @@ function cleanCopiedSelection(text) {
while (cut > 0 && (line[cut - 1] === ' ' || line[cut - 1] === '\t')) cut--;
return cut === end ? line : line.slice(0, cut) + line.slice(end);
};
return text.split('\n').map(trimEnd).join('\n');
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) lines[i] = trimEnd(lines[i]);
const margin = Math.max(0, Math.trunc(Number(options?.margin) || 0));
if (!margin) return lines.join('\n');
// The first line of a selection that began mid-row carries no margin — the
// mousedown cut it off — so it neither votes on the shared indent nor gets
// stripped. This is the ONE thing the mousedown column still decides, and it
// decides it for that line alone. Whether the rest of the block is dedented
// no longer depends on where the click landed, which is what made the same
// three rows produce three different clipboard results before.
const from = options?.firstLinePartial === true ? 1 : 0;
// The pane's margin is a ceiling, not the answer. Strip the narrower of it
// and what every selected line shares, so the block shifts as a unit and no
// line can lose indentation another line keeps.
let shared = margin;
for (let i = from; i < lines.length && shared > 0; i++) {
const line = lines[i];
if (!line || line === '\r') continue; // a padding-only row, already trimmed away
let run = 0;
while (run < line.length && line[run] === ' ') run++;
if (run < shared) shared = run;
}
if (!shared) return lines.join('\n');
for (let i = from; i < lines.length; i++) {
if (lines[i] && lines[i] !== '\r') lines[i] = lines[i].slice(shared);
}
return lines.join('\n');
}
if (typeof window !== 'undefined') {
@@ -1516,8 +1541,296 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
return { startRow, endRow, text, offsetToCell, cellToOffset };
}
// ═══════════════════════════════════════════════════════════════
// Split-Pane Sessions — pure helpers (divider math, picker list)
// ═══════════════════════════════════════════════════════════════
// Desktop-only, same reasoning and same threshold as HOME_SESSIONS_MIN_WIDTH
// (home-sessions.js): two 240px min-width panes plus the divider need ~486px,
// which a phone or narrow tablet cannot give them, and the divider has no
// touch handlers. A dedicated constant rather than reusing
// HOME_SESSIONS_MIN_WIDTH directly — that name lives in home-sessions.js,
// which loads AFTER this file (load order 12.56 vs 7.5), so referencing it
// from module-evaluation-time code here would be a ReferenceError.
const SPLIT_PANE_MIN_WIDTH = 1180;
function clampDividerPercent(rawPercent, min = 20, max = 80) {
if (rawPercent < min) return min;
if (rawPercent > max) return max;
return rawPercent;
}
function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds) {
const result = [];
for (const id of sessionOrder) {
if (id === excludeId) continue;
// A detached (popped-out) session's own window already yields its PTY
// size (see sendResize's detachedElsewhere guard in terminal-ui.js) —
// Pane B's SplitTerminalPane._sendResize() has no such check, so letting
// one into the picker put its detached window and Pane B in a fight over
// the same PTY's dimensions.
if (detachedIds?.has?.(id)) continue;
const session = sessions.get(id);
if (!session) continue;
// A session with no PTY attached (exited CLI, a crash-looped session
// whose breaker tripped, a restore that failed to re-attach) has nothing
// reading its tmux pane. SplitTerminalPane never does selectSession()'s
// re-attach POST, so its socket would open onto a pane nothing feeds:
// no terminal events, and Session.write() silently drops every keystroke
// with no ack either way (Pane B sends no `seq`), so the loss is
// invisible — the healthy socket never trips the disconnect banner.
if (session.pid === null) continue;
result.push({ id, label: session.name || 'Session' });
}
return result;
}
// ── Renderer liveness ──────────────────────────────────────────────────────
//
// iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to
// the background — not deferred, never delivered. xterm's RenderDebouncer only
// clears its `_animationFrame` handle from INSIDE that callback:
//
// refresh() {
// if (this._animationFrame !== undefined) return; // <- stale forever
// this._animationFrame = requestAnimationFrame(() => this._innerRefresh());
// }
// _innerRefresh() { this._animationFrame = undefined; ... } // never runs
//
// So after one backgrounding the handle is permanently non-undefined and EVERY
// later render request returns on line one. Parsing is decoupled from
// rendering, so bytes keep filling the buffer correctly and nothing throws —
// the terminal is simply frozen. Closing and reopening fixes it because that
// constructs a new Terminal, and therefore a new debouncer.
//
// Codeman is MORE exposed than a per-session-terminal app: there is exactly one
// xterm instance for the whole page load, so a single backgrounding can wedge
// it until a full reload.
//
// This is the pure decision half. The signature that distinguishes this from
// every other way a terminal can look stuck is that bytes were WRITTEN and the
// element is VISIBLE, yet onRender has not fired since:
//
// frozen = wroteAt > renderedAt && now - wroteAt >= threshold && visible
//
// Deliberately NOT a "no output at all" check: a quiet terminal is the normal
// state and must never be kicked. And `visible` is required because a hidden
// terminal legitimately stops rendering (xterm pauses it), so kicking there
// would fire constantly on every backgrounded tab.
const RENDER_STALL_MS = 4000;
// How often the watchdog checks. Deliberately coarse: the failure it catches is
// permanent until healed, so detecting it a second late costs nothing, while a
// tight interval would burn a wakeup per second on every idle phone.
const RENDER_LIVENESS_POLL_MS = 2000;
/**
* Should the renderer be kicked? Pure so the CI gate can cover it — the DOM
* half (cancelling the stale handle) lives in terminal-ui.js.
*
* @param {{wroteAt:number, renderedAt:number, now:number, visible:boolean,
* thresholdMs?:number}} s
* @returns {boolean}
*/
function shouldKickRenderer(s) {
if (!s || !s.visible) return false;
const wroteAt = Number(s.wroteAt) || 0;
const renderedAt = Number(s.renderedAt) || 0;
const now = Number(s.now) || 0;
// Nothing written yet — a fresh terminal has no render to be missing.
if (wroteAt <= 0) return false;
// A render landed at or after the last write: the pipeline is alive.
if (renderedAt >= wroteAt) return false;
const threshold = Number.isFinite(s.thresholdMs) && s.thresholdMs > 0 ? s.thresholdMs : RENDER_STALL_MS;
return now - wroteAt >= threshold;
}
// ── Fetch deadlines ────────────────────────────────────────────────────────
//
// No terminal fetch carried any deadline, including `?full=1`, which the code
// itself describes as "unbounded-ish work: at the default history limit it can
// be megabytes". On a stalled mobile link that request hangs on the browser
// default with no retry and no path back to a usable terminal short of a
// reload.
//
// A single fixed timeout is wrong in both directions — too short for a full
// scrollback capture on a slow uplink, too long for a small tail on a dead
// connection. So the deadline is scaled by what is actually being asked for,
// and by how many captures are already in flight: on a slow link those bytes
// must drain before this request's own bytes start moving, and its timer is
// already running the whole time.
const FETCH_DEADLINE_TAIL_MS = 15000;
const FETCH_DEADLINE_FULL_MS = 45000;
const FETCH_DEADLINE_MAX_MS = 120000;
/**
* Deadline in ms for a terminal capture.
*
* @param {{full?:boolean, inflight?:number}} s - `full` = the ?full=1 capture;
* `inflight` = captures already running (this one included or not, it only
* scales the budget).
* @returns {number}
*/
function terminalFetchDeadlineMs(s) {
const full = !!(s && s.full);
const base = full ? FETCH_DEADLINE_FULL_MS : FETCH_DEADLINE_TAIL_MS;
const inflight = Math.max(0, Number(s && s.inflight) || 0);
// Each already-queued capture gets the newcomer one more base budget to wait
// through. Linear rather than clever: the point is only that eight tabs
// resuming do not all time out together because each assumed it was alone.
return Math.min(FETCH_DEADLINE_MAX_MS, base * (1 + inflight));
}
// ── Diagnostics hygiene ────────────────────────────────────────────────────
//
// The crash trail is joined with '\n' into ONE localStorage value and beaconed
// to the server, and at least one call site interpolates server-controlled text
// (a WebSocket close `reason`). An embedded newline there forges extra entries
// in the trail; an unbounded string can fill the storage quota. Both are cheap
// to close, and the trail is something a user may be asked to paste into an
// issue.
const DIAG_ENTRY_MAX_CHARS = 300;
/** Flatten a diagnostic message to one bounded, newline-free line. */
function sanitizeDiagEntry(msg) {
return String(msg == null ? '' : msg)
.replace(/[\r\n\u2028\u2029]+/g, ' ')
.slice(0, DIAG_ENTRY_MAX_CHARS);
}
// ── Recovering a dropped output frame ──────────────────────────────────────
//
// `_onSessionTerminal` drops an incoming frame when the app-owned render queues
// already hold 128KB, which is the right call — the alternative is an unbounded
// backlog — but a hole in a TUI byte stream is a desynced cursor, and a desynced
// cursor is muffled text (issue #464). So the drop is only half of it: the
// recovery has to actually happen.
//
// ⚠️ It used to be a fire-and-forget timer. `_onSessionNeedsRefresh` opens with
// four early returns, and two of them — a buffer load in flight, a refresh
// already owning this session — are MOST likely to be true during exactly the
// output burst that caused the drop. The timer nulled itself before the call,
// so a skipped refresh lost the recovery silently and the dropped bytes were
// never replayed.
//
// Bounded, because the early returns it retries past are transient contention
// that clears in seconds, and a permanently failing refresh must not become a
// forever-loop against the API. A refresh that hit the capture fetch DEADLINE
// is not contention but a stalled link, and is not retried at all: each retry
// would be another `?full=1` capture waiting out a deadline of up to two
// minutes, where the old code cost exactly one. Giving up after the cap leaves
// exactly the garbled frames the old code left, so the floor is no worse.
const DROP_RECOVERY_DELAY_MS = 2000;
const DROP_RECOVERY_MAX_ATTEMPTS = 5;
/**
* Should a dropped-output recovery run again?
*
* @param {{repainted: boolean, timedOut?: boolean, attempt: number, stillActive: boolean}} state
* `repainted` — whether `_onSessionNeedsRefresh` actually rewrote the buffer.
* `timedOut` - whether it failed at the capture fetch deadline.
* `attempt` — how many have already run, zero-based.
* `stillActive` — whether the dropped session is still the one on screen.
* @returns {boolean}
*/
function shouldRetryDroppedOutputRecovery({ repainted, timedOut = false, attempt, stillActive }) {
// Switched away: `selectSession` repaints from the server on its own, so a
// retry here would be a second replay of a buffer that is about to be written.
if (!stillActive) return false;
if (repainted) return false;
if (timedOut) return false;
return attempt + 1 < DROP_RECOVERY_MAX_ATTEMPTS;
}
// ── Terminal geometry: xterm and the PTY must never disagree ───────────────
//
// Issue #464 ("text gets muffled"). Claude Code's TUI repaints by wrapping its
// frame at the width the PTY reported and walking the cursor up that many
// ROWS. So a browser terminal whose width differs from the PTY's makes every
// repaint arithmetic wrong: a logical line occupies more physical rows than
// Ink counted, `eraseLines(n)` clears too few of them, and the new frame paints
// over rows that were never erased. Measured against a real xterm — a PTY
// believing 120 columns against a 62-column terminal renders each wrapped line
// twice, and a shorter replacement line leaves the tail of the old one behind.
// That is exactly the doubled rows and half-overwritten prose in the report.
//
// The floor exists because a PTY a handful of columns wide makes any CLI wrap
// every word; it is NOT a display preference, so the browser terminal has to
// honour it too. Three separate call sites used to fit xterm to the RAW
// proposal and report the CLAMPED one, which is how the two drifted apart with
// nothing to notice: resize is write-only, so nobody could see the disagreement.
const TERMINAL_MIN_COLS = 40;
const TERMINAL_MIN_ROWS = 10;
/**
* The geometry to apply AND report — there is only ever one answer to both.
* @param {{cols: number, rows: number}|null|undefined} proposed
* @returns {{cols: number, rows: number}|null}
*/
function clampTerminalDimensions(proposed) {
if (!proposed || !Number.isFinite(proposed.cols) || !Number.isFinite(proposed.rows)) return null;
return {
cols: Math.max(Math.trunc(proposed.cols), TERMINAL_MIN_COLS),
rows: Math.max(Math.trunc(proposed.rows), TERMINAL_MIN_ROWS),
};
}
/**
* What to do when the server reports the PTY's real geometry.
*
* The server is the authority: it owns the PTY the CLI is drawing for, and it
* can refuse a resize outright (`Session.resize` ignores small-viewport
* requests while a desktop connection holds an active sizing claim) without
* the asking client ever being told. A terminal that keeps its own WIDTH after
* such a refusal renders garbage, because Ink wraps its frame and counts its
* erase rows at the width it was told.
*
* ⚠️ COLUMNS ONLY. Rows are deliberately left alone, and adopting them was a
* real regression: a phone that took a desktop's 43 rows into a viewport with
* room for 18 painted an `.xterm-screen` far taller than its container, and
* because xterm's own viewport then had nothing to scroll, the bottom of the
* frame — the CLI's input line — sat below the container with no gesture that
* could reach it. Output visible, typing invisible, for as long as the claim
* stayed hot. Width is the axis the wrap arithmetic depends on; rows only
* decide how much is on screen at once, and keeping the local row count keeps
* the composer at the bottom of a viewport that scrolls.
*
* @param {{cols: number, rows: number}|null} local - what xterm currently holds
* @param {{cols: number, rows: number}|null} pty - what the server just reported
* @returns {{adopt: boolean, cols: number|null}}
*/
function reconcilePtyGeometry(local, pty) {
if (!pty || !Number.isFinite(pty.cols)) return { adopt: false, cols: null };
if (!local || !Number.isFinite(local.cols) || local.cols === pty.cols) return { adopt: false, cols: null };
return { adopt: true, cols: pty.cols };
}
if (typeof window !== 'undefined') {
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
window.CodemanTerminalLines = { terminalLogicalLine };
window.CodemanSplitPane = {
clampDividerPercent,
buildSplitPickerSessions,
SPLIT_PANE_MIN_WIDTH,
};
window.CodemanRenderLiveness = { shouldKickRenderer, RENDER_STALL_MS, RENDER_LIVENESS_POLL_MS };
window.CodemanFetchDeadline = {
terminalFetchDeadlineMs,
FETCH_DEADLINE_TAIL_MS,
FETCH_DEADLINE_FULL_MS,
FETCH_DEADLINE_MAX_MS,
};
window.CodemanDiag = { sanitizeDiagEntry, DIAG_ENTRY_MAX_CHARS };
window.CodemanDroppedOutput = {
shouldRetryDroppedOutputRecovery,
DROP_RECOVERY_DELAY_MS,
DROP_RECOVERY_MAX_ATTEMPTS,
};
window.CodemanTerminalGeometry = {
clampTerminalDimensions,
reconcilePtyGeometry,
TERMINAL_MIN_COLS,
TERMINAL_MIN_ROWS,
};
}
+9
View File
@@ -212,6 +212,9 @@ Object.assign(CodemanApp.prototype, {
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state,
pill: HOME_SESSIONS_PILL_LABEL[state] || state,
// What the pane's footer says is still running in the background, straight off
// the session payload. Same field, same meaning as on the phone overview.
watching: typeof session.watching === 'string' ? session.watching : '',
// Epoch ms, straight off the session payload; formatting happens at
// render time so the clock below can redo it without a re-render.
createdAt: Number(session.createdAt) || 0,
@@ -450,6 +453,12 @@ Object.assign(CodemanApp.prototype, {
// what stops it ellipsizing.
const meta = this._buildHomeSessionsMeta(row);
meta.appendChild(pill);
// Built by the phone overview so both home screens word the badge identically.
// Guarded like every other cross-file call here: a stale cached mobile-overview.js
// must cost the badge, not the rail.
if (row.watching && typeof this._buildWatchingBadge === 'function') {
meta.appendChild(this._buildWatchingBadge(row.watching, 'home-sessions-pill'));
}
item.appendChild(meta);
return item;
+17
View File
@@ -73,6 +73,10 @@
'File Viewer': '文件查看器',
'Open file viewer': '打开文件查看器',
'Open Codeman across all displays': '在所有显示器上打开 {name}',
'Split: open a second session beside this one': '分屏:在旁边打开第二个会话',
'Split: close the second session': '分屏:关闭第二个会话',
'Close split': '关闭分屏',
'No other sessions to split with': '没有其他可用于分屏的会话',
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
Notifications: '通知',
@@ -312,6 +316,8 @@
'运行菜单选择器会为此端点应用该模型。请先发现可用模型。',
'Custom Endpoints': '自定义端点',
'Choose a model': '选择模型',
'Currently loaded': '当前已加载',
'Last used': '上次使用',
'That endpoint no longer exists': '该端点已不存在',
'No models discovered for this endpoint yet': '此端点尚未发现任何模型',
'Subagent Options': '子智能体选项',
@@ -445,6 +451,17 @@
'Show Shortcuts': '显示快捷键',
'Full shortcut reference': '完整快捷键参考',
// Mobile prompt composer (keyboard-accessory.js). The textarea's own
// placeholder and label are looked up by the module at build time, since
// the DOM translator skips <textarea> subtrees.
'Compose prompt': '撰写提示词',
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
'Enter adds a new line': '按 Enter 换行',
'Write your prompt…': '请输入提示词…',
'Use terminal keyboard': '使用终端键盘',
'Uploading…': '上传中…',
// Mobile overview (phone home screen)
'Needs you': '需要你',
'Current sessions': '当前会话',
+8 -4
View File
@@ -124,12 +124,15 @@ Object.assign(CodemanApp.prototype, {
// 20 photos don't crawl through serially.
_uploadConcurrency: 3,
async _uploadAndInsertImages(fileList) {
/** Upload a batch and normally insert its paths into the active terminal.
* The prompt composer passes `{ insert: false }` so it can put those paths
* into its textarea instead. Returns successful paths in selection order. */
async _uploadAndInsertImages(fileList, options = {}) {
const sessionId = this.activeSessionId;
if (!sessionId) return;
if (!sessionId) return [];
let files = Array.from(fileList || []);
if (files.length === 0) return;
if (files.length === 0) return [];
// Cap the batch and tell the user what got dropped (no silent truncation).
let capped = false;
@@ -175,7 +178,7 @@ Object.assign(CodemanApp.prototype, {
await Promise.all(Array.from({ length: Math.min(this._uploadConcurrency, total) }, () => worker()));
const paths = results.filter(Boolean);
if (paths.length > 0) {
if (paths.length > 0 && options.insert !== false) {
// Insert all paths in one shot, space-separated, in selection order.
await this.sendInput(paths.join(' '));
}
@@ -187,6 +190,7 @@ Object.assign(CodemanApp.prototype, {
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
return paths;
},
async _uploadPasteImage(sessionId, file) {
+10
View File
@@ -191,6 +191,7 @@
</button>
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
@@ -1790,6 +1791,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoCopySelection"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="copy indent margin gutter dedent trim leading whitespace paste">
<div class="set-row-text">
<span class="set-row-label">Trim the pane margin on copy</span>
<span class="set-row-desc">Drop the left margin a full-screen agent CLI paints down its own edge, so copied text pastes flush instead of indented. Each CLI declares its own width, and the strip is never wider than the indent every selected line shares, so nesting inside the selection is kept. Claude Code and Codex declare a margin today; a shell, and any CLI that declares none, is left alone.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCopyStripMargin"><span class="slider"></span></label>
</div>
</div>
</div>
@@ -1924,6 +1932,7 @@
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
<label class="set-chip" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
<label class="set-chip" data-preview="header" data-preview-order="13" data-preview-text="42%"><input type="checkbox" id="appSettingsShowPlanUsageLimits"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18a8 8 0 1 1 16 0"/><path d="M12 18l4.5-5"/></svg><span>Plan Usage</span></label>
<label class="set-chip" data-preview="header" data-preview-order="14"><input type="checkbox" id="appSettingsShowLifecycleLog"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span>Lifecycle Log</span></label>
</div>
@@ -3719,6 +3728,7 @@
<script defer src="app.js"></script>
<script defer src="tab-rail-resize.js"></script>
<script defer src="terminal-ui.js"></script>
<script defer src="terminal-split.js"></script>
<script defer src="respawn-ui.js"></script>
<script defer src="ralph-panel.js"></script>
<script defer src="orchestrator-panel.js"></script>
+279 -14
View File
@@ -4,13 +4,14 @@
* Defines three exports:
*
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
* keyboard on mobile: arrow up/down, /init, Tab, paste, Esc, and dismiss (the extended
* keyboard on mobile: arrow up/down, /init, Tab, Compose, Esc, and dismiss (the extended
* bar adds /clear, /compact, Shift+Tab and more). Shift+Left/Right ship in both agent
* layouts but are revealed only on Codex sessions (`codex-enabled` marker class on the
* bar, synced on every session switch), since they are Codex bindings. Tab flushes any locally-buffered
* prompt text to the PTY before sending \t, so completion applies to what was typed.
* The paste button opens a dialog that handles both text paste and image attach
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
* Agent bars expose a Compose dialog with an autocorrect-aware multiline textarea,
* per-session in-memory drafts and image attach. Shell bars keep the direct Paste
* dialog because shell input is not an agent prompt.
* Destructive actions (/clear, /compact, extended bar only) require double-tap confirmation (2s amber state).
* Commands are sent as text + Enter separately for Ink compatibility.
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
@@ -635,6 +636,16 @@ function applyOneShotCtrl(data) {
return { data, consumed: true };
}
// The composer's Send goes out as ONE bracketed-paste frame on the WebSocket
// input path, and ws-routes.ts drops a frame longer than MAX_INPUT_LENGTH
// (config/terminal-limits.ts: 64 KiB, compared in UTF-16 code units) WITHOUT
// an ACK, which would wedge the durable input queue. So the prompt budget is
// that limit minus the two markers, derived here once so the refusal in
// _sendComposedPrompt() and the toast that names the maximum cannot drift.
const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024;
const COMPOSER_PASTE_START = '\x1b[200~';
const COMPOSER_PASTE_END = '\x1b[201~';
/**
* KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing.
*/
@@ -648,8 +659,15 @@ const KeyboardAccessoryBar = {
_baseMode: 'simple',
// One-shot Ctrl modifier (shell bar only). See handleAction('ctrl').
_ctrlArmed: false,
// Prompt drafts intentionally stay in memory: prompts routinely contain secrets,
// so persistence would need the same treatment as the 0600 intent store.
_composerDrafts: new Map(),
_composerUploads: new Map(),
_composerOverlay: null,
// Longest prompt Send accepts: the input frame limit minus both markers.
_composerMaxLength: COMPOSER_INPUT_FRAME_LIMIT - COMPOSER_PASTE_START.length - COMPOSER_PASTE_END.length,
/** HTML for simple mode: arrows, commands, paste, Esc, dismiss */
/** HTML for simple mode: arrows, commands, Compose, Esc, dismiss */
_simpleButtons: `
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
@@ -665,10 +683,10 @@ const KeyboardAccessoryBar = {
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
<button class="accessory-btn accessory-btn-codex" data-action="shift-left" title="Shift+Left (Codex: edit queued message)" aria-label="Shift+Left (Codex: edit queued message)">⇧←</button>
<button class="accessory-btn accessory-btn-codex" data-action="shift-right" title="Shift+Right (Codex: prompt stack back)" aria-label="Shift+Right (Codex: prompt stack back)">⇧→</button>
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
<button class="accessory-btn accessory-btn-compose" data-action="compose" title="Compose prompt" aria-label="Compose prompt">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
</svg>
</button>
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button>
@@ -740,10 +758,10 @@ const KeyboardAccessoryBar = {
<path d="M9 5l7 7-7 7"/>
</svg>
</button>
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
<button class="accessory-btn accessory-btn-compose" data-action="compose" title="Compose prompt" aria-label="Compose prompt">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
</svg>
</button>
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">&#x1F4C1; Path</button>
@@ -781,6 +799,7 @@ const KeyboardAccessoryBar = {
// The ⇧←/⇧→ keys are Codex bindings: same shape, gated on the active
// session's mode instead of a setting.
this.syncCodexKeys();
this._syncComposerDraftIndicator();
// Add click handlers — preventDefault stops event from reaching terminal
this.element.addEventListener('click', (e) => {
@@ -823,8 +842,13 @@ const KeyboardAccessoryBar = {
* the next one. */
refreshForActiveSession() {
this.clearCtrl();
const activeSessionId = typeof app !== 'undefined' ? app.activeSessionId : null;
if (this._composerOverlay && this._composerOverlay.dataset.sessionId !== activeSessionId) {
this._composerOverlay._closeComposer?.({ restoreFocus: false });
}
this._applyLayout(this._resolveMode());
this.syncCodexKeys();
this._syncComposerDraftIndicator();
},
/** Which layout the current state calls for. */
@@ -852,6 +876,20 @@ const KeyboardAccessoryBar = {
this.clearCtrl();
this.element.innerHTML =
mode === 'shell' ? this._shellButtons : mode === 'extended' ? this._extendedButtons : this._simpleButtons;
this._syncComposerDraftIndicator();
},
/** Show when the active session has a prompt parked in memory. The marker
* keeps non-Send closes visible without copying the draft back into the PTY
* and creating a second source of truth. */
_syncComposerDraftIndicator() {
const button = this.element?.querySelector('[data-action="compose"]');
if (!button) return;
const sessionId = typeof app !== 'undefined' ? app.activeSessionId : null;
const hasDraft = !!(sessionId && this._composerDrafts.get(sessionId));
button.classList.toggle('has-draft', hasDraft);
button.title = hasDraft ? 'Resume saved prompt draft' : 'Compose prompt';
button.setAttribute('aria-label', hasDraft ? 'Compose prompt, draft saved' : 'Compose prompt');
},
// ── One-shot Ctrl modifier (shell bar) ──────────────────────────────────
@@ -977,6 +1015,9 @@ const KeyboardAccessoryBar = {
case 'paste':
this.pasteFromClipboard();
break;
case 'compose':
this.composePrompt();
break;
case 'pick-path':
this.pickPath();
break;
@@ -1130,6 +1171,230 @@ const KeyboardAccessoryBar = {
});
},
/** Move the whole editable terminal prompt into the composer. Pending text
* exists only in the overlay; flushed text already reached the PTY, so erase
* that prefix before making the textarea authoritative. */
_takePendingLocalEcho(sessionId) {
if (!app._localEchoEnabled || !app._localEchoOverlay) return '';
const pending = app._localEchoOverlay.pendingText || '';
const overlayFlushed = app._localEchoOverlay.getFlushed?.() || {};
const flushedText = overlayFlushed.text || app._flushedTexts?.get(sessionId) || '';
const flushedLength = Array.from(flushedText).length;
app._localEchoOverlay.clear();
app._localEchoOverlay.suppressBufferDetection?.();
app._flushedOffsets?.delete(sessionId);
app._flushedTexts?.delete(sessionId);
if (flushedLength > 0) {
app._sendInputAsync(sessionId, '\x7f'.repeat(flushedLength), { useMux: true });
}
return flushedText + pending;
},
/** Insert text at the textarea selection, adding one separating space when
* an attachment path would otherwise run into neighboring prompt text. */
_insertComposerText(textarea, text) {
if (!textarea || !text) return;
const start = Number.isInteger(textarea.selectionStart) ? textarea.selectionStart : textarea.value.length;
const end = Number.isInteger(textarea.selectionEnd) ? textarea.selectionEnd : start;
const before = textarea.value.slice(0, start);
const after = textarea.value.slice(end);
const prefix = before && !/\s$/.test(before) ? ' ' : '';
const suffix = after && !/^\s/.test(after) ? ' ' : '';
const inserted = `${prefix}${text}${suffix}`;
textarea.setRangeText(inserted, start, end, 'end');
textarea.dispatchEvent(new Event('input', { bubbles: true }));
},
/** Deliver one complete prompt as an explicit bracketed paste. xterm loses
* its DECSET 2004 mirror after terminal replay, even though the CLI still
* expects bracketed input, so build the byte-identical sequence directly on
* the durable session-bound path. Enter stays a separate delayed write
* because Codex drops keys sharing a PTY read with a bracketed paste. */
_sendComposedPrompt(sessionId, text) {
if (!sessionId || !text || typeof app._sendInputAsync !== 'function') return false;
app._predictiveEcho?.clearPredictions();
// Match xterm's prepareTextForTerminal(): CR keeps embedded newlines inside
// the single-line input transport and is what terminal.paste() emitted.
const pasteText = text.replace(/\r?\n/g, '\r');
if (pasteText.length > this._composerMaxLength) {
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
return false;
}
app._sendInputAsync(sessionId, `${COMPOSER_PASTE_START}${pasteText}${COMPOSER_PASTE_END}`);
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
return true;
},
/** Forget a draft when its target session no longer exists. */
discardComposerDraft(sessionId) {
this._composerDrafts.delete(sessionId);
this._composerUploads.delete(sessionId);
this._syncComposerDraftIndicator();
if (this._composerOverlay?.dataset.sessionId === sessionId) {
this._composerOverlay._closeComposer?.({ preserveDraft: false });
}
},
/** Open the manual agent prompt composer. Enter remains a newline; only the
* Send button submits. Cancel/backdrop/Escape preserve the per-session draft. */
composePrompt() {
if (typeof app === 'undefined' || !app.activeSessionId) return;
if (this._isShellSession()) {
this.pasteFromClipboard();
return;
}
const sessionId = app.activeSessionId;
const pending = this._takePendingLocalEcho(sessionId);
const saved = this._composerDrafts.get(sessionId) || '';
const initial = saved + pending;
this._composerOverlay?._closeComposer?.({ restoreFocus: false });
const overlay = document.createElement('div');
overlay.className = 'paste-overlay prompt-composer-overlay';
overlay.dataset.sessionId = sessionId;
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Compose prompt');
overlay.innerHTML = `
<div class="paste-dialog prompt-composer-dialog">
<div class="prompt-composer-header">
<strong>Compose prompt</strong>
<span>Enter adds a new line</span>
</div>
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
<div class="paste-actions prompt-composer-actions">
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
<button type="button" class="paste-image">🖼 Image</button>
<button type="button" class="paste-cancel">Cancel</button>
<button type="button" class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
</div>
`;
this._composerOverlay = overlay;
const textarea = overlay.querySelector('.prompt-composer-textarea');
// i18n.js skips <textarea> subtrees (what is typed there is user content),
// so the placeholder and label are translated here, when the dialog is built.
const i18n = typeof window !== 'undefined' ? window.CodemanI18n : undefined;
if (typeof i18n?.t === 'function') {
textarea.placeholder = i18n.t('Write your prompt…');
textarea.setAttribute('aria-label', i18n.t('Prompt'));
}
const fileInput = overlay.querySelector('.paste-file-input');
const imageButton = overlay.querySelector('.paste-image');
const sendButton = overlay.querySelector('.paste-send');
const focusTrap = new FocusTrap(overlay);
textarea.value = initial;
if (initial) this._composerDrafts.set(sessionId, initial);
this._syncComposerDraftIndicator();
const initialUploads = this._composerUploads.get(sessionId) || 0;
imageButton.disabled = initialUploads > 0;
sendButton.disabled = initialUploads > 0;
if (initialUploads > 0) imageButton.textContent = 'Uploading…';
const saveDraft = () => {
if (textarea.value) this._composerDrafts.set(sessionId, textarea.value);
else this._composerDrafts.delete(sessionId);
this._syncComposerDraftIndicator();
};
const close = ({ focusTerminal = false, preserveDraft = true, restoreFocus = true } = {}) => {
if (preserveDraft) saveDraft();
focusTrap.deactivate({ restoreFocus });
overlay.remove();
if (this._composerOverlay === overlay) this._composerOverlay = null;
if (focusTerminal) app.terminal?.focus();
};
overlay._closeComposer = close;
const send = () => {
// Whitespace-only counts as empty (it would submit blank lines), but the
// text goes out untrimmed so deliberate leading or trailing lines survive.
const text = textarea.value;
if (!text.trim() || !this._sendComposedPrompt(sessionId, text)) return;
app._echoPassthroughSessions?.delete(sessionId);
this._composerDrafts.delete(sessionId);
this._syncComposerDraftIndicator();
close({ preserveDraft: false });
};
const handleImages = async (files) => {
const images = Array.from(files || []).filter((file) => file.type.startsWith('image/'));
if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
saveDraft();
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
const syncUploadUi = () => {
const currentOverlay =
this._composerOverlay?.isConnected && this._composerOverlay.dataset.sessionId === sessionId
? this._composerOverlay
: null;
const count = this._composerUploads.get(sessionId) || 0;
const currentImageButton = currentOverlay?.querySelector('.paste-image');
const currentSendButton = currentOverlay?.querySelector('.paste-send');
if (currentImageButton) {
currentImageButton.disabled = count > 0;
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
}
if (currentSendButton) currentSendButton.disabled = count > 0;
};
syncUploadUi();
try {
const paths = await app._uploadAndInsertImages(images, { insert: false });
if (paths?.length) {
const currentOverlay =
this._composerOverlay?.isConnected && this._composerOverlay.dataset.sessionId === sessionId
? this._composerOverlay
: null;
const currentTextarea = currentOverlay?.querySelector('.prompt-composer-textarea');
if (currentTextarea) this._insertComposerText(currentTextarea, paths.join(' '));
else if (app.sessions?.has(sessionId)) {
const draft = this._composerDrafts.get(sessionId) || '';
this._composerDrafts.set(sessionId, `${draft}${draft && !/\s$/.test(draft) ? ' ' : ''}${paths.join(' ')}`);
this._syncComposerDraftIndicator();
}
}
} finally {
const remaining = Math.max(0, (this._composerUploads.get(sessionId) || 1) - 1);
if (remaining > 0) this._composerUploads.set(sessionId, remaining);
else this._composerUploads.delete(sessionId);
syncUploadUi();
if (overlay.isConnected) fileInput.value = '';
}
};
textarea.addEventListener('input', saveDraft);
textarea.addEventListener('paste', (event) => {
const items = event.clipboardData?.items;
if (!items) return;
const images = Array.from(items)
.filter((item) => item.type.startsWith('image/'))
.map((item) => item.getAsFile())
.filter(Boolean);
if (images.length > 0) {
event.preventDefault();
void handleImages(images);
}
});
overlay.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
close();
}
});
imageButton.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => void handleImages(fileInput.files));
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => close({ focusTerminal: true }));
overlay.querySelector('.paste-cancel').addEventListener('click', () => close());
sendButton.addEventListener('click', send);
overlay.addEventListener('click', (event) => {
if (event.target === overlay) close();
});
document.body.appendChild(overlay);
focusTrap.activate();
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
},
/** Show a paste overlay for iOS compatibility.
* Handles three input paths from one dialog:
* - Text: long-press the textarea → Paste → Send (unchanged).
@@ -1259,9 +1524,9 @@ class FocusTrap {
});
}
deactivate() {
deactivate({ restoreFocus = true } = {}) {
this.element.removeEventListener('keydown', this.boundHandleKeydown);
if (this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') {
if (restoreFocus && this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') {
this.previouslyFocused.focus();
}
}
+22 -27
View File
@@ -581,11 +581,10 @@ const KeyboardHandler = {
this._settleRestoreScroll = false;
if (typeof app !== 'undefined' && app.terminal) {
if (app.fitAddon) {
try {
app.fitAddon.fit();
} catch {}
}
// Floored fit, not a bare fitAddon.fit(): _shrinkPaddingToFit measures
// the leftover gap under the LAST row, so it has to run against the
// geometry xterm will actually keep (issue #464).
app.syncTerminalGeometry?.();
if (this.keyboardVisible) this._shrinkPaddingToFit();
// Following live output → bottom, as before. Reading history → back to
// the pre-reflow anchor instead of being yanked down (#259).
@@ -601,25 +600,23 @@ const KeyboardHandler = {
}, this.VIEWPORT_SETTLE_MS);
},
/** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */
/**
* Send the settled terminal dimensions to the server (one-shot, for keyboard
* open/close — `throttledResize` deliberately holds the PTY's shape for the
* whole animation, so this is what stops it going stale).
*
* ⚠️ Delegates rather than computing its own numbers. This used to re-read
* `proposeDimensions()` and floor only what it POSTed, so on a phone with the
* keyboard up — where the proposal is routinely under ten rows — the PTY was
* told ten and xterm kept six, which is the #464 divergence. Worse, it read
* the proposal AFTER `_shrinkPaddingToFit()` had moved the container, so even
* unfloored its answer could differ from the fit above it. `sendResize` fits,
* floors and applies in one step, and additionally gets the WS fast path and
* the detached-session yield this hand-rolled POST never had.
*/
_sendTerminalResize() {
if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return;
try {
const dims = app.fitAddon.proposeDimensions();
if (dims) {
const cols = Math.max(dims.cols, 40);
const rows = Math.max(dims.rows, 10);
app._lastResizeDims = { cols, rows };
// Declare the viewport type so resize arbitration can ignore this
// while a desktop connection is sizing the same session.
const viewportType = MobileDetection.getDeviceType ? MobileDetection.getDeviceType() : 'mobile';
fetch(`/api/sessions/${app.activeSessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows, viewportType }),
}).catch(() => {});
}
} catch {}
if (typeof app === 'undefined' || !app.activeSessionId) return;
app.sendResize?.(app.activeSessionId)?.catch?.(() => {});
},
/**
@@ -676,10 +673,8 @@ const KeyboardHandler = {
const currentPadding = parseInt(main.style.paddingBottom) || 0;
const floor = Math.min(currentPadding, this._fixedBottomBarsHeight());
main.style.paddingBottom = Math.max(floor, currentPadding - gap) + 'px';
if (app.fitAddon)
try {
app.fitAddon.fit();
} catch {}
// Floored, like every other fit of the main terminal (#464).
app.syncTerminalGeometry?.();
}
} catch {}
},
+48
View File
@@ -60,6 +60,14 @@ const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
];
/**
* The one word every surface puts on the watching badge, and the tooltip that says
* what the pane actually reported. Both live here so the phone overview, the desktop
* home rail and the rich sidebar rows cannot word the same badge three ways.
*/
const WATCHING_BADGE_TEXT = 'watching';
const watchingBadgeTitle = (label) => 'Still running in the background: ' + label;
/** Pill copy per state. Kept short: a phone row has ~90px for it. */
const MOBILE_OVERVIEW_PILL_LABEL = {
needs: 'needs you',
@@ -185,6 +193,10 @@ Object.assign(CodemanApp.prototype, {
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state,
pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state,
// What the pane's own footer says is still running in the background ("1 monitor",
// "2 shells"), straight off the session payload. A row that has one is quiet
// because the agent is waiting for that, not because it is waiting for you.
watching: typeof session.watching === 'string' ? session.watching : '',
// Epoch ms, straight off the session payload; formatting happens at
// render time so the clock can redo it without a re-render.
createdAt: Number(session.createdAt) || 0,
@@ -713,6 +725,8 @@ Object.assign(CodemanApp.prototype, {
pill.textContent = row.pill;
item.appendChild(pill);
if (row.watching) item.appendChild(this._buildWatchingBadge(row.watching, 'mobile-overview-pill'));
const chevron = document.createElement('span');
chevron.className = 'mobile-overview-chevron';
chevron.setAttribute('aria-hidden', 'true');
@@ -734,6 +748,40 @@ Object.assign(CodemanApp.prototype, {
return item;
},
// ═══════════════════════════════════════════════════════════════
// Watching badge
// ═══════════════════════════════════════════════════════════════
/**
* The badge a session wears while work it started in the background is still
* running: a monitor, a backgrounded shell, a cloud session.
*
* It says one word, and the label the pane itself printed ("1 monitor") rides in
* the tooltip, because the badge shares a row with the state pill on the narrowest
* screen this app renders on. It does NOT replace that pill: an agent can arm a
* monitor and ask the user a question in the same breath, so the row still says
* "needs you" and this says what else is going on.
*
* Shared with the desktop home rail (home-sessions.js), for the same reason
* `_mobileOverviewState` is: one badge, one wording, one place to change it. The
* caller names its own pill class, because each surface styles its pills itself and
* the phone's live inside a media query the desktop never enters.
*/
_buildWatchingBadge(label, baseClass) {
const badge = document.createElement('span');
const base = baseClass || 'mobile-overview-pill';
badge.className = base + ' ' + base + '--watching';
badge.setAttribute('data-i18n-skip', '');
badge.textContent = WATCHING_BADGE_TEXT;
// The label rides in BOTH, because a tooltip is desktop-only: a phone has no hover
// target, and a screen reader gets the one word either way. This is the surface the
// badge was built for first, so "watching" with no way to learn what would be the
// wrong place to save a line.
badge.title = watchingBadgeTitle(label);
badge.setAttribute('aria-label', watchingBadgeTitle(label));
return badge;
},
// ═══════════════════════════════════════════════════════════════
// Age stamps: started / how long in this state
// ═══════════════════════════════════════════════════════════════
+45 -16
View File
@@ -529,6 +529,11 @@ html.mobile-init .file-browser-panel {
.btn-icon-header.btn-approvals {
display: none !important;
}
/* Split-pane is hidden here too, but the AUTHORITATIVE hard gate is the
wider `@media (max-width: 1179px)` rule in styles.css — this file only
loads up to 1023px, which does not cover the 1024-1179px tablet range
the split feature also needs to stay off (two 240px min-width panes plus
the divider need ~486px; the divider also has no touch handlers). */
/* Read My Mind 🧠 header button: never in the phone header; the phone
surface is the keyboard-accessory 🧠 key (same `readMyMindEnabled` gate,
@@ -689,6 +694,22 @@ html.mobile-init .file-browser-panel {
display: none;
}
/* The exited-agent mute, a third time, for the phone (Ark0N/Codeman#446).
The rule above enlarges the working dot and gives it a green glow with
!important, and `status` deliberately stays `busy` for a pane whose agent
died mid-turn — so without this a phone renders a 9px grey dot still
wearing the green halo, beside a badge reading "exited". Measured; the
desktop rules cannot reach it, since they declare no box-shadow and lose
to !important anyway. !important here for the reason the glow needs it:
the skin block in styles.css outranks any plain class rule in this file.
⚠ The alert classes and the error state are excluded exactly as they are
on desktop — a colour that means "this needs you" outranks "it exited". */
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status:not(.error) {
background: var(--text-muted) !important;
box-shadow: none !important;
opacity: 0.5;
}
/* Truncate tab names more aggressively on mobile */
.session-tab .tab-name {
max-width: 50px;
@@ -928,40 +949,41 @@ html.mobile-init .file-browser-panel {
border-color: rgba(16, 185, 129, 0.5);
}
/* Gemini mode colors on mobile */
/* Gemini mode colors on mobile. Same `!important` rationale as the pi block below. */
.btn-toolbar.btn-run.mode-gemini,
.btn-toolbar.btn-run-gear.mode-gemini {
background: #10243f;
border-color: rgba(96, 165, 250, 0.3);
color: #dbeafe;
background: #10243f !important;
border-color: rgba(96, 165, 250, 0.3) !important;
color: #dbeafe !important;
}
.btn-toolbar.btn-run.mode-gemini:active,
.btn-toolbar.btn-run-gear.mode-gemini:active {
background: #174ea6;
border-color: rgba(96, 165, 250, 0.5);
background: #174ea6 !important;
border-color: rgba(96, 165, 250, 0.5) !important;
}
/* Antigravity mode colors on mobile */
/* Antigravity mode colors on mobile. Same `!important` rationale as the pi block below. */
.btn-toolbar.btn-run.mode-antigravity,
.btn-toolbar.btn-run-gear.mode-antigravity {
background: #0b2b33;
border-color: rgba(34, 211, 238, 0.3);
color: #cffafe;
background: #0b2b33 !important;
border-color: rgba(34, 211, 238, 0.3) !important;
color: #cffafe !important;
}
.btn-toolbar.btn-run.mode-antigravity:active,
.btn-toolbar.btn-run-gear.mode-antigravity:active {
background: #0e7490;
border-color: rgba(34, 211, 238, 0.5);
background: #0e7490 !important;
border-color: rgba(34, 211, 238, 0.5) !important;
}
/* Pi mode colors on mobile.
`!important` is load-bearing here, not noise: styles.css nests its skin rules
inside `html:not([data-skin="og"])`, so a bare `.btn-toolbar.btn-run` in there
resolves to (0,2,1) and outranks this (0,2,0) `.mode-pi` pair regardless of
load order. The antigravity block right above omits it and is consequently
dead on every non-og skin (i.e. on the default) — do not copy that. */
inside `html:not([data-skin="og"])`, so a bare `.btn-toolbar.btn-run` or
`.btn-toolbar.btn-run-gear` in there outranks this `.mode-pi` pair regardless
of load order. Without it the gear half keeps the skin accent while the body
takes the nested block's mode colour, so the split button renders two-tone
(gemini and antigravity shipped that way until they got `!important` too). */
.btn-toolbar.btn-run.mode-pi,
.btn-toolbar.btn-run-gear.mode-pi {
background: #33121f !important;
@@ -2956,6 +2978,13 @@ html.mobile-init .file-browser-panel {
color: var(--green);
}
/* Accent, and none of the three above: a session watching work it started itself
is not asking the user for anything, and red and yellow are what say it is. */
.mobile-overview-pill--watching {
border-color: var(--accent);
color: var(--accent);
}
.mobile-overview-chevron {
flex-shrink: 0;
color: var(--text-muted);
+4 -2
View File
@@ -512,8 +512,10 @@ class NotificationManager {
}
// Re-fit terminal and send resize to PTY so this client's dimensions win.
// Fixes broken layout when switching between desktop and mobile on the same session.
if (this.app?.fitAddon && this.app?.activeSessionId) {
this.app.fitAddon.fit();
// sendResize fits (floored) as its first synchronous step, so the bare
// fit that used to precede it was both redundant and a chance to leave
// xterm at the unfloored proposal (#464).
if (this.app?.activeSessionId) {
this.app.sendResize(this.app.activeSessionId);
}
}
+5 -4
View File
@@ -5276,8 +5276,10 @@ Object.assign(CodemanApp.prototype, {
try { localStorage.removeItem('codeman-active-session'); } catch {}
this.renderSessionTabs();
this.renderMuxSessions();
this.terminal.clear();
this.terminal.reset();
// Not a replay path, so the ordering hazard does not apply here — but
// there is one way to clear this terminal and this is it, so a future
// caller cannot copy a clear()+reset() pair out of here into one.
this._resetTerminalForReplay();
this.toast('All sessions and tmux killed', 'success');
}
} else {
@@ -5286,8 +5288,7 @@ Object.assign(CodemanApp.prototype, {
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
this.renderSessionTabs();
this.terminal.clear();
this.terminal.reset();
this._resetTerminalForReplay();
this.toast('All tabs removed, tmux still running', 'info');
}
} catch (err) {
+3 -4
View File
@@ -155,10 +155,9 @@ Object.assign(CodemanApp.prototype, {
if (xtermViewport && scrollTop !== undefined) {
xtermViewport.scrollTop = scrollTop;
}
// Refit terminal to new container size
if (this.terminal && this.fitAddon) {
this.fitAddon.fit();
}
// Refit terminal to new container size. Through the one owner so the
// floor that is reported to the PTY is also the one xterm holds (#464).
this.syncTerminalGeometry?.();
});
},
+281 -454
View File
@@ -11,6 +11,119 @@
* @loadorder 12 of 15 — loaded after panels-ui.js, before ralph-wizard.js
*/
/**
* PR B2: the single source for every non-Claude, non-Shell run mode's launch
* shape, consumed by `_runCliMode()` below. Before this table existed, each of
* `runOpenCode`/`runCodex`/`runGemini`/`runAntigravity`/`runPi`/`runOmp`/
* `runGrok`/`runDeepSeek` was a ~45-line copy of the same probe/launch/select
* skeleton with only the CLI-specific pieces below actually differing — eight
* near-identical bodies guaranteed to drift, exactly what the CLI registry's
* own no-id-branching rule exists to prevent server-side.
*
* Deliberately a LOCAL table rather than a server-injected catalogue: several
* unit tests exercise these run*() methods inside a bare `vm.createContext()`
* sandbox with no `window` global at all (see test/run-mode-ui.test.ts) —
* referencing `window` there unguarded would throw, not degrade. `buildConfig`
* returns the CLI's top-level legacy config field for a LOCAL launch, or
* `null` for a CLI that sends none (pi: no bypass flag exists, so there is
* nothing to send — see runPi's own history below for why that must stay
* true).
*/
const RUN_MODE_LAUNCH = {
opencode: {
label: 'OpenCode',
installHint: 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash',
supportsCustomModel: true,
buildConfig: () => ({ openCodeConfig: { autoAllowTools: true } }),
},
codex: {
label: 'Codex',
installHint: 'Codex CLI not found. Install with: npm install -g @openai/codex',
supportsCustomModel: true,
buildConfig: (globalSettings) => ({
codexConfig: {
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
animations: globalSettings.codexAnimationsEnabled ?? false,
renderMode: 'hybrid',
},
}),
},
gemini: {
label: 'Gemini',
installHint: 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli',
supportsCustomModel: true,
buildConfig: () => ({ geminiConfig: { approvalMode: 'yolo' } }),
},
antigravity: {
label: 'Antigravity',
installHint: 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash',
// antigravity has no customModelInjection recipe (docs/custom-model-endpoints-plan.md
// calls it `unsupported`) — never fold a pending pick into its launch body.
supportsCustomModel: false,
buildConfig: () => ({ antigravityConfig: { dangerouslySkipPermissions: true } }),
},
pi: {
label: 'Pi',
installHint: 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent',
supportsCustomModel: true,
// Deliberately NO piConfig: pi has no permission prompts, so there is no
// bypass to opt into, and project trust is pi's own `defaultProjectTrust`
// decision (an interactive prompt the user answers in the terminal).
// Sending `approveProjectTrust: true` here would silently opt every
// browser-launched pi session into executing repo-supplied TypeScript.
buildConfig: () => null,
},
omp: {
label: 'OMP',
installHint: 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh',
supportsCustomModel: true,
buildConfig: () => null,
},
grok: {
label: 'Grok',
installHint: 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash',
supportsCustomModel: true,
// Sends `grokConfig: { alwaysApprove: true }` the way antigravity sends
// `dangerouslySkipPermissions: true`: Codeman sessions exist for autonomous
// work, so the Run button opts into grok's bypassPermissions mode
// (`--always-approve`; config-level deny rules still apply on top). The
// multi-user clamp forces it back off for non-granted owners server-side.
buildConfig: () => ({ grokConfig: { alwaysApprove: true } }),
},
deepseek: {
label: 'DeepSeek',
installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh',
// The two-part availability check is deliberate. `dsh` being installed is
// not enough — DeepSeek ships no terminal front door, so a box can have a
// perfect binary and nothing a pane can run.
unrunnableHint:
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' +
'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' +
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui',
supportsCustomModel: true,
// Sends `permissionMode: 'danger-full-access'` for the same reason every
// sibling Run button sends its bypass switch. The harness has no bypass
// FLAG, so this rides the `DSH_PERMISSION_MODE` export instead, and the
// multi-user clamp forces it back down to `workspace-write` server-side.
//
// `statusReporting` is deliberately LEFT UNSET, i.e. ON: it is what upgrades
// this mode from output-stabilization guessing to definitive idle/blocked
// hook events (the harness reports to Codeman as its supervisor, see
// deepseek-status-shim.ts). Never send `statusReporting: false` from here.
buildConfig: () => ({ deepSeekConfig: { permissionMode: 'danger-full-access' } }),
},
};
/**
* External (non-Claude, non-Shell) CLI run modes — the keys of RUN_MODE_LAUNCH
* above, kept as its own Set (`EXTERNAL_CLI_MODES.has(mode)`) rather than an
* array recomputed per call. Single source for what used to be two hand-copied
* 8-way `session.mode === '<id>' || ...` chains inside one function
* (`openSessionOptions`), guaranteed to drift from each other the moment a
* ninth CLI landed in one and not the other.
*/
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
Object.assign(CodemanApp.prototype, {
/**
* Build envOverrides payload from case + global settings.
@@ -395,34 +508,13 @@ Object.assign(CodemanApp.prototype, {
try {
const mode = this._runMode || 'claude';
if (mode === 'opencode') {
return await this.runOpenCode();
}
if (mode === 'codex') {
return await this.runCodex();
}
if (mode === 'gemini') {
return await this.runGemini();
}
if (mode === 'antigravity') {
return await this.runAntigravity();
}
if (mode === 'omp') {
return await this.runOmp();
}
if (mode === 'pi') {
return await this.runPi();
}
if (mode === 'grok') {
return await this.runGrok();
}
if (mode === 'deepseek') {
return await this.runDeepSeek();
}
if (mode === 'shell') {
return await this.runShell();
}
return await this.runClaude();
if (mode === 'claude' || !EXTERNAL_CLI_MODES.has(mode)) {
return await this.runClaude();
}
return await this._runCliMode(mode);
} finally {
const remaining = minLockMs - (Date.now() - startedAt);
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
@@ -633,14 +725,101 @@ Object.assign(CodemanApp.prototype, {
if (models.length === 1) {
return this.runCustomModelEntry(mode, endpointId, models[0]);
}
this._openCustomModelPickModal(mode, host);
await this._openCustomModelPickModal(mode, host);
},
/** Renders the "which model" picker for a (harness, endpoint) pair with more than one discovered model. */
_openCustomModelPickModal(mode, host) {
/** localStorage key for the last model launched on a given (harness, endpoint) pair — per-device by design, like every other `codeman:*` UI preference, never synced. */
_customModelLastUsedKey(mode, endpointId) {
return `codeman:customModelLastUsed:${mode}:${endpointId}`;
},
/** Reads the last model chosen for this (harness, endpoint) pair, or null. Never throws — a blocked/full localStorage just means no promotion, not a broken picker. */
_getCustomModelLastUsed(mode, endpointId) {
try {
return localStorage.getItem(this._customModelLastUsedKey(mode, endpointId));
} catch {
return null;
}
},
/** Remembers `modelId` as the last one launched for this (harness, endpoint) pair. */
_setCustomModelLastUsed(mode, endpointId, modelId) {
try {
localStorage.setItem(this._customModelLastUsedKey(mode, endpointId), modelId);
} catch {
// best-effort — losing the "last used" hint is cosmetic, never worth surfacing
}
},
/**
* Best-effort lookup of the model llama-swap currently has loaded and ready on this
* endpoint, so the picker can offer it first instead of making the user remember what
* they picked last time it mattered. Mirrors `_watchLlamaSwapLoading`'s own
* `state === 'ready'` check. Returns null for a plain (non-llama-swap) server, an
* unreachable endpoint, or a loaded model this host no longer lists as discovered —
* never throws, since a failed probe should just skip promotion, not break the picker.
*
* ⚠️ Client-side bounded to ~800ms via Promise.race, on top of (never instead of) the
* route's own 5s server-side timeout (`RUNNING_TIMEOUT_MS`, custom-model-routes.ts) —
* a saved endpoint keeps its discovered models cached, so "the box behind this endpoint
* is asleep or firewalled" is a normal way to reach this path, not an exotic one, and
* the modal must not sit invisible (Run menu already closed, nothing else on screen)
* for the full 5s a slow/dead endpoint can take. The losing side of the race is left to
* resolve on its own — `.catch(() => null)` only stops an unhandled-rejection warning
* when it eventually fails, it never cancels the in-flight fetch.
*
* `timeoutMs` exists to let a test drive this in milliseconds instead of the real
* 800 — same reasoning as `_watchLlamaSwapLoading`'s own `pollIntervalMs`: this code
* runs inside a JSDOM window's own realm, whose `setTimeout` is not the one
* `vi.useFakeTimers()` patches, so a param is the only way to test the timeout without
* actually waiting on it. Real callers never pass it.
*/
async _getCustomModelCurrentlyLoaded(host, timeoutMs = 800) {
const probe = this._apiJson(`/api/model-endpoints/${encodeURIComponent(host.id)}/running-status`).catch(
() => null
);
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs));
const status = await Promise.race([probe, timeout]);
if (!status?.isLlamaSwap) return null;
const ready = (status.running || []).find((r) => r.state === 'ready' && (host.models || []).includes(r.model));
return ready?.model || null;
},
/**
* Renders the "which model" picker for a (harness, endpoint) pair with more than one
* discovered model. Async since it now awaits the currently-loaded-model probe below,
* so a SECOND call (a different custom-model entry clicked while the first one's probe
* is still in flight — the probe has its own 5s timeout) must not let the first call's
* later-arriving response clobber the second's already-rendered, already-correct modal.
* `_customModelPickGeneration` is the same guard-a-mutable-counter pattern
* `_watchLlamaSwapLoading` uses for the same reason: every DOM write below, including
* `_pendingCustomModelPick` itself, stays deferred until after the await, and a call
* that finds a newer generation already claimed bails out untouched rather than only
* skipping the model-list write and leaving title/hint/`_pendingCustomModelPick`
* inconsistent with what's on screen.
*/
async _openCustomModelPickModal(mode, host) {
const modal = document.getElementById('customModelPickModal');
const list = document.getElementById('customModelPickList');
if (!modal || !list) return;
const generation = (this._customModelPickGeneration = (this._customModelPickGeneration || 0) + 1);
const isCurrent = () => this._customModelPickGeneration === generation;
// Whichever model llama-swap actually has loaded right now beats a merely
// remembered choice — it's what a launch would attach to with zero wait, while
// "last used" might have been swapped out by another session since. Neither
// reorders past the top: exactly one model is promoted, everything else keeps
// its discovery order.
const currentlyLoaded = await this._getCustomModelCurrentlyLoaded(host);
if (!isCurrent()) return; // a newer pick opened (and possibly already rendered) while this probe was in flight
const lastUsed = currentlyLoaded ? null : this._getCustomModelLastUsed(mode, host.id);
const promoted = currentlyLoaded || lastUsed;
const models = [...(host.models || [])];
if (promoted && models.includes(promoted)) {
models.splice(models.indexOf(promoted), 1);
models.unshift(promoted);
}
this._pendingCustomModelPick = { mode, endpointId: host.id };
const cliLabel = (window.__codemanCustomModelClis || []).find((c) => c.id === mode)?.label || mode;
// A static title (translatable by i18n.js's exact-string walker) plus a
@@ -649,13 +828,19 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('customModelPickTitle').textContent = 'Choose a model';
document.getElementById('customModelPickHint').textContent =
`${cliLabel} → ${host.label} — ${(host.models || []).length} models discovered.`;
list.innerHTML = (host.models || [])
list.innerHTML = models
.map((m) => {
const isDefault = m === host.defaultModelId;
// Two independent tags, never one exclusive slot: the promotion tag says what
// llama-swap (or this device's history) knows about the model, the Default pill
// says what the saved endpoint says about it, and on a single-purpose GPU box the
// promoted model IS the default more often than not. One slot holding whichever
// applied first silently dropped the Default marking for exactly that row.
const promotion = m === currentlyLoaded ? 'Currently loaded' : m === lastUsed ? 'Last used' : null;
const tags = [promotion, m === host.defaultModelId ? 'Default' : null].filter(Boolean);
const arg = escapeHtml(JSON.stringify(m));
return `
<button class="run-mode-option" onclick="app.chooseCustomModelAndRun(${arg})">
<span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${isDefault ? ' <span class="set-scope">Default</span>' : ''}
<span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${tags.map((t) => ` <span class="set-scope">${escapeHtml(t)}</span>`).join('')}
</button>`;
})
.join('');
@@ -771,6 +956,12 @@ Object.assign(CodemanApp.prototype, {
* (Codex, confirmed live) than on claude's own `--resume`-based restart.
*/
async runCustomModelEntry(mode, endpointId, modelId) {
// "Last used" is recorded by each path itself, ONLY once the model is actually
// applied — never here, unconditionally, on the mere attempt. A context-window
// warning or a swap-conflict question can still say no after this call, and the
// context-warning case is the one that bites: declining it means this exact
// model cannot work with this CLI at all, so promoting it as "Last used" next
// time the picker opens would be actively wrong, not just premature.
if (mode === 'claude') {
return this._runCustomModelEntryViaRestart(mode, endpointId, modelId);
}
@@ -863,7 +1054,15 @@ Object.assign(CodemanApp.prototype, {
answered = { ...answered, confirmedSwap: true };
data = await post({ ...bodyObj, customModel: { ...bodyObj.customModel, ...answered } });
}
this._lastCustomModelLaunchResult = data?.success !== false ? data?.data : undefined;
const launched = data?.success !== false;
this._lastCustomModelLaunchResult = launched ? data?.data : undefined;
// Only once actually launched, and only for a call that carried a custom-model pick
// at all — `_launchQuickStartInstances` runs every quick-start body (custom-model or
// not) through this same function, so a plain launch must not fall through here with
// an undefined endpointId/modelId that quietly no-ops the (mode, endpointId) key.
if (launched && bodyObj.customModel) {
this._setCustomModelLastUsed(bodyObj.mode, bodyObj.customModel.endpointId, bodyObj.customModel.modelId);
}
return data;
},
@@ -991,6 +1190,11 @@ Object.assign(CodemanApp.prototype, {
return;
}
// The apply has actually succeeded and both questions (if asked) are answered
// yes — only now is this a real "last used" for the picker's next open, not
// before either confirmation had a chance to decline it.
this._setCustomModelLastUsed(mode, endpointId, modelId);
// The apply above already succeeded — the session IS pointed at the endpoint — but
// llama-swap itself may still be unloading the old model and loading this one, which
// can take well over a minute. Without this, a prompt sent during that window either
@@ -1917,110 +2121,65 @@ Object.assign(CodemanApp.prototype, {
return firstSessionId;
},
async runOpenCode() {
/**
* Shared launcher for every RUN_MODE_LAUNCH entry (every run mode except
* claude/shell, which have their own flows — claude for its remote/docker
* branching and parallel-create path, shell for needing no CLI probe at
* all). The eight run<Mode>() methods below are thin named wrappers: their
* names stay because index.html's welcome-screen buttons and the run-mode
* menu call them directly by name (`app.runOpenCode()` etc.), and several
* tests assert on that name directly too.
*/
async _runCliMode(mode) {
const entry = RUN_MODE_LAUNCH[mode];
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote cases run the CLI on the REMOTE host — the local /api/opencode/status
// probe and the local-only config/env below don't apply (quick-start rejects them).
// Remote/docker cases run the CLI on the OTHER side — the local status
// probe and the local-only config/env below don't apply (quick-start
// rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} OpenCode session(s) in ${caseName}...`
`Starting ${tabCount} ${entry.label} session(s) in ${caseName}...`
);
// Focus in sync gesture context (see runClaude comment)
this.terminal.focus();
try {
// Check if OpenCode is available (local sessions only)
if (!isRemote) {
const statusRes = await fetch('/api/opencode/status');
const statusRes = await fetch(`/api/${mode}/status`);
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
);
this._reportSessionLaunchError(ownsLaunchTerminal, entry.installHint);
return;
}
}
// Quick-start with opencode mode (auto-allow tools by default).
// No `effort` field — it's Claude-specific (OpenCode has no /effort).
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const firstSessionId = await this._launchQuickStartInstances(
caseName,
tabCount,
'OpenCode',
(sessionName) => ({
caseName,
mode: 'opencode',
sessionName,
...(isRemote ? {} : {
openCodeConfig: { autoAllowTools: true },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
}),
}),
ownsLaunchTerminal
);
// Switch to the new session (don't pre-set activeSessionId — selectSession
// early-returns when IDs match, skipping buffer load and sendResize)
if (firstSessionId) {
await this.selectSession(firstSessionId);
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
async runCodex() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote cases run Codex on the REMOTE host — skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Codex session(s) in ${caseName}...`
);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/codex/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'Codex CLI not found. Install with: npm install -g @openai/codex'
);
if (entry.unrunnableHint && !status.runnable) {
this._reportSessionLaunchError(ownsLaunchTerminal, entry.unrunnableHint);
return;
}
}
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
// No `effort` field for ANY entry in RUN_MODE_LAUNCH: effort is
// Claude-specific (runClaude() alone sends it, and the backend turns it
// into `claude --settings`); none of these CLIs has an /effort. Each of
// the eight bodies this launcher replaced carried that rule as a comment.
const firstSessionId = await this._launchQuickStartInstances(
caseName,
tabCount,
'Codex',
entry.label,
(sessionName) => ({
caseName,
mode: 'codex',
mode,
sessionName,
...(isRemote ? {} : {
codexConfig: {
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
animations: globalSettings.codexAnimationsEnabled ?? false,
renderMode: 'hybrid',
},
...(entry.buildConfig(globalSettings) || {}),
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
...(entry.supportsCustomModel && this._pendingCustomModelForLaunch
? { customModel: this._pendingCustomModelForLaunch }
: {}),
}),
}),
ownsLaunchTerminal
@@ -2038,368 +2197,36 @@ Object.assign(CodemanApp.prototype, {
}
},
async runOpenCode() {
return this._runCliMode('opencode');
},
async runCodex() {
return this._runCliMode('codex');
},
async runGemini() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote cases run Gemini on the REMOTE host — skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Gemini session(s) in ${caseName}...`
);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/gemini/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
);
return;
}
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const firstSessionId = await this._launchQuickStartInstances(
caseName,
tabCount,
'Gemini',
(sessionName) => ({
caseName,
mode: 'gemini',
sessionName,
...(isRemote ? {} : {
geminiConfig: { approvalMode: 'yolo' },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
}),
}),
ownsLaunchTerminal
);
if (firstSessionId) {
await this.selectSession(firstSessionId);
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
return this._runCliMode('gemini');
},
async runAntigravity() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run agy on the OTHER side — skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Antigravity session(s) in ${caseName}...`
);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/antigravity/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
);
return;
}
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const firstSessionId = await this._launchQuickStartInstances(
caseName,
tabCount,
'Antigravity',
(sessionName) => ({
caseName,
mode: 'antigravity',
sessionName,
...(isRemote ? {} : {
antigravityConfig: { dangerouslySkipPermissions: true },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
}),
ownsLaunchTerminal
);
if (firstSessionId) {
await this.selectSession(firstSessionId);
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
return this._runCliMode('antigravity');
},
/**
* Launch a Pi (pi.dev) session.
*
* Deliberately sends NO piConfig: pi has no permission prompts, so there is no
* bypass to opt into, and project trust is pi's own `defaultProjectTrust`
* decision (an interactive prompt the user answers in the terminal). Sending
* `approveProjectTrust: true` here would silently opt every browser-launched pi
* session into executing repo-supplied TypeScript.
*/
async runPi() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run pi on the OTHER side — skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Pi session(s) in ${caseName}...`
);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/pi/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
);
return;
}
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const firstSessionId = await this._launchQuickStartInstances(
caseName,
tabCount,
'Pi',
(sessionName) => ({
caseName,
mode: 'pi',
sessionName,
...(isRemote || Object.keys(envOverrides).length === 0 ? {} : { envOverrides }),
...(!isRemote && this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
}),
ownsLaunchTerminal
);
if (firstSessionId) {
await this.selectSession(firstSessionId);
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
return this._runCliMode('pi');
},
async runOmp() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run omp on the OTHER side — skip the local status probe
// and the local-only config below (quick-start rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} OMP session(s) in ${caseName}...`
);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/omp/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh'
);
return;
}
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const firstSessionId = await this._launchQuickStartInstances(
caseName,
tabCount,
'OMP',
(sessionName) => ({
caseName,
mode: 'omp',
sessionName,
...(isRemote ? {} : {
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
}),
}),
ownsLaunchTerminal
);
if (firstSessionId) {
await this.selectSession(firstSessionId);
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
return this._runCliMode('omp');
},
/**
* Launch a Grok Build (xAI `grok`) session.
*
* Sends `grokConfig: { alwaysApprove: true }` the way runAntigravity() sends
* `dangerouslySkipPermissions: true`: Codeman sessions exist for autonomous
* work, so the Run button opts into grok's bypassPermissions mode
* (`--always-approve`; config-level deny rules still apply on top). The
* multi-user clamp forces it back off for non-granted owners server-side.
*/
async runGrok() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run grok on the OTHER side: skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Grok session(s) in ${caseName}...`
);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/grok/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash'
);
return;
}
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const firstSessionId = await this._launchQuickStartInstances(
caseName,
tabCount,
'Grok',
(sessionName) => ({
caseName,
mode: 'grok',
sessionName,
...(isRemote ? {} : {
grokConfig: { alwaysApprove: true },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
}),
}),
ownsLaunchTerminal
);
if (firstSessionId) {
await this.selectSession(firstSessionId);
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
return this._runCliMode('grok');
},
/**
* Launch a DeepSeek Harness (`dsh`) session.
*
* Sends `permissionMode: 'danger-full-access'` for the same reason every
* sibling Run button sends its bypass switch: Codeman sessions exist for
* autonomous work. The harness has no bypass FLAG, so this rides the
* `DSH_PERMISSION_MODE` export instead, and the multi-user clamp forces it
* back down to `workspace-write` for non-granted owners server-side.
*
* `statusReporting` is left unset, i.e. ON: it is what upgrades this mode from
* output-stabilization guessing to definitive idle/blocked hook events.
*
* The two-part availability check is deliberate. `dsh` being installed is not
* enough — DeepSeek ships no terminal front door, so a box can have a perfect
* binary and nothing a pane can run. Reporting that precisely, with the exact
* command that fixes it, is the difference between "the Run button is broken"
* and a 30-second fix.
*/
async runDeepSeek() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run dsh on the OTHER side: skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const tabCount = this._readTabCount();
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} DeepSeek session(s) in ${caseName}...`
);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/deepseek/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh'
);
return;
}
if (!status.runnable) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' +
'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' +
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
);
return;
}
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const firstSessionId = await this._launchQuickStartInstances(
caseName,
tabCount,
'DeepSeek',
(sessionName) => ({
caseName,
mode: 'deepseek',
sessionName,
...(isRemote ? {} : {
deepSeekConfig: { permissionMode: 'danger-full-access' },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
}),
}),
ownsLaunchTerminal
);
if (firstSessionId) {
await this.selectSession(firstSessionId);
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
return this._runCliMode('deepseek');
},
@@ -2467,7 +2294,7 @@ Object.assign(CodemanApp.prototype, {
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
const isAltMode = EXTERNAL_CLI_MODES.has(session.mode);
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
// Update respawn status display and buttons
@@ -2497,7 +2324,7 @@ Object.assign(CodemanApp.prototype, {
}
// Hide Claude-specific options for external CLI sessions
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
const isExternalCli = isAltMode;
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
+32 -2
View File
@@ -14,6 +14,14 @@
Object.assign(CodemanApp.prototype, {
// Hooks (Claude Code hook events)
_onHookIdlePrompt(data) {
// A prompt the server opened ALREADY acknowledged raises no alert here. Today that
// means the session is watching work it started itself (`acknowledgedReason` reads
// "watching 1 monitor"), so the pane is quiet because the agent is waiting for its
// own monitor, not for you. The item still exists and still shows in the drawer;
// only the tab alert and the desktop notification are declined. A page that reloads
// instead of receiving this event reaches the same conclusion from `acknowledgedAt`
// in seedApprovals (approvals-ui.js).
if (data.acknowledgedReason) return;
// Always track pending hook - alert will show when switching away from session
if (data.sessionId) {
this.setPendingHook(data.sessionId, 'idle_prompt');
@@ -407,6 +415,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false;
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
// Phone overview home screen: only meaningful under 600px, so the row is
@@ -445,6 +454,8 @@ Object.assign(CodemanApp.prototype, {
// overwrites the system clipboard on a gesture the user may have meant only as
// a way to read, so it is opt-in rather than a default anyone has to discover.
document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true;
// Default ON, so an absent key reads as enabled rather than as off.
document.getElementById('appSettingsCopyStripMargin').checked = settings.copyStripMargin !== false;
document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || '';
this.populateTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight'), settings.terminalFontWeight);
this.populateTerminalFontWeight(
@@ -2120,6 +2131,7 @@ Object.assign(CodemanApp.prototype, {
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
showSplitButton: document.getElementById('appSettingsShowSplitButton').checked,
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
@@ -2135,6 +2147,7 @@ Object.assign(CodemanApp.prototype, {
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
copyStripMargin: document.getElementById('appSettingsCopyStripMargin').checked,
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
terminalFontWeight: this.readTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight')),
terminalFontWeightBold: this.readTerminalFontWeight(
@@ -2350,6 +2363,10 @@ Object.assign(CodemanApp.prototype, {
showPlanUsageLimits: _pul,
showAttachmentsButton: _ahb,
showFileViewerButton: _fvb,
// Desktop-only header button, per-device, and absent from
// SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT
// (moving it into displayKeys alone is not the strip; this is).
showSplitButton: _ssp,
webglRendererEnabled: _wgl,
terminalWheelLocalScrollback: _twls,
// Copy-on-select. Per-device (clipboard access differs by device and by
@@ -2357,6 +2374,10 @@ Object.assign(CodemanApp.prototype, {
// and absent from SettingsUpdateSchema (.strict()), so sending it would
// 400 the whole settings PUT.
autoCopySelection: _acs,
// What the clipboard gets is a property of what this device is looking
// at, and the key is absent from SettingsUpdateSchema (.strict()), so
// sending it would 400 the whole settings PUT.
copyStripMargin: _csm,
// Per-device by nature (the font must exist on the device) and absent
// from SettingsUpdateSchema (.strict()) — sending it would 400 the PUT.
terminalFontFamily: _tff,
@@ -2742,6 +2763,7 @@ Object.assign(CodemanApp.prototype, {
showUltracodeAgents: false,
ultracodeFloatingWindows: false,
showMultiMonitorButton: false,
showSplitButton: false,
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
// OFF so the phone header stays minimal and the mobile-header-buttons
// policy guard keeps passing.
@@ -2947,6 +2969,13 @@ Object.assign(CodemanApp.prototype, {
multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton);
}
// Split button — hidden by default, and hard-gated to desktop widths
// regardless of the setting (window.CodemanSplitPane.SPLIT_PANE_MIN_WIDTH,
// matching HOME_SESSIONS_MIN_WIDTH's JS-check + media-query-backstop
// pattern — the CSS in styles.css is the backstop, this is the check).
const showSplitButton = settings.showSplitButton ?? defaults.showSplitButton ?? false;
this._applySplitButtonVisibility?.(showSplitButton);
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
// from the mobile-header-buttons-policy guard.
@@ -3083,7 +3112,7 @@ Object.assign(CodemanApp.prototype, {
const changed = orientationChanged || previousDetail !== detail || previousSort !== sort;
if (orientationChanged) {
this.updateTabOverflowMode?.();
if (!settleRailWidth) this.fitAddon?.fit();
if (!settleRailWidth) this.syncTerminalGeometry?.();
}
// applyTabWrapSettings() is the ONE owner of tabs-show-folder and is
// rail-aware, so it has to run AFTER the two attributes above — the
@@ -3358,11 +3387,12 @@ Object.assign(CodemanApp.prototype, {
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
'terminalWheelLocalScrollback',
'autoCopySelection',
'autoCopySelection', 'copyStripMargin',
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
'showTabDetachButton',
'mobileOverviewEnabled',
'sessionLineageLines',
'showSplitButton',
]);
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
+434 -5
View File
@@ -675,6 +675,12 @@ body.tab-rail-resizing * {
user-select: none !important;
}
body.split-pane-resizing,
body.split-pane-resizing * {
cursor: col-resize !important;
user-select: none !important;
}
@media (prefers-reduced-motion: reduce) {
.tab-rail,
.tab-rail-resize-handle {
@@ -2282,6 +2288,39 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
display: none !important;
}
/* Split-pane header button: hidden by default (opt-in via App Settings →
Header & Panels → Header buttons → Split, `showSplitButton`). Pure
client-side toggle applied by applyHeaderVisibilitySettings(). */
.btn-split--hidden {
display: none !important;
}
/* While a split is open, the button's own click closes it instead of opening
the picker — the accent fill is what tells the user which of its two
behaviours the next click will get (aria-pressed carries the same state
for assistive tech). */
.btn-icon-header.btn-split.split-open {
background: var(--accent);
color: var(--accent-ink);
}
.btn-icon-header.btn-split.split-open:hover {
background: var(--accent);
color: var(--accent-ink);
}
/* Split-pane hard desktop gate, independent of the showSplitButton setting:
two 240px min-width panes plus the divider need ~486px, the divider has no
touch handlers, and mobile.css only loads up to 1023px so it cannot cover
the 1024-1179px tablet range on its own. Same threshold and reasoning as
HOME_SESSIONS_MIN_WIDTH (home-sessions.js) / SPLIT_PANE_MIN_WIDTH
(constants.js) — keep the three in sync. */
@media (max-width: 1179px) {
.btn-icon-header.btn-split {
display: none !important;
}
}
.btn-icon-header.btn-settings {
width: 30px;
height: 30px;
@@ -2394,6 +2433,51 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
display: inline-flex;
}
/* The agent inside the pane has exited (Ark0N/Codeman#446). Unlike the badge
above this one carries text the server computed, so it is added and removed
by the renderer rather than toggled by a class. */
.session-tab .tab-exited-badge {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 1px 4px;
margin-left: 4px;
border-radius: 3px;
white-space: nowrap;
background: rgba(248, 113, 113, 0.16);
color: var(--danger);
}
/* An exited agent quiets the status dot, which otherwise reports `status` and
so stays green (or pulses, for a session the exit caught mid-turn) beside a
badge saying the agent is gone. `status` itself is deliberately untouched —
`error` is the PTY-exit breaker's value and makes the browser offer a restart
— so this is a rendering rule only, and it matches `.tab-status.ended`.
⚠ Three states are excluded BY HAND rather than by cascade, the same way the
rich-rail dot rules below do it: a dot turning red or yellow because a
session is blocked on a human outranks "the agent exited", and this selector
is specific enough (0,5,0) to have beaten those (0,3,0) rules otherwise. The
third is `.tab-status.error` (0,2,0), which is the PTY-exit breaker's state
and the one the browser answers with a "restart it?" confirm — the same
argument that protects the two alert classes, and it reaches the dot rather
than the tab, which is why the `:not()` sits on `.tab-status` here and on
`.session-tab` there. The spinner ring is a pseudo-element the skin blocks
cannot reach, so it needs hiding explicitly rather than by unsetting the
animation. */
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status:not(.error) {
background: var(--text-muted);
opacity: 0.5;
animation: none;
}
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle) .tab-status:not(.error)::after {
display: none;
}
/* ===== Tab action icons: active tab only =================================
All three per-tab icons live in a .tab-actions wrapper (in flow; it adds
no width of its own while the children keep the width:0 collapse above).
@@ -2547,6 +2631,7 @@ body.solo-mode .header-system-stats,
body.solo-mode .header-tokens,
body.solo-mode .btn-notifications,
body.solo-mode .btn-multimonitor,
body.solo-mode .btn-split,
body.solo-mode .header-plan-usage,
/* A solo window shows ONE session and has no tab strip to put restored ones in,
so offering to rebuild a list of them there is an offer it cannot show the
@@ -3777,6 +3862,37 @@ body.solo-mode .btn-lifecycle-log {
background: transparent !important;
}
/* The terminal is wider than the box that shows it (issue #464). Two causes,
one affordance: another device holds the sizing claim so this terminal has
adopted a width it did not ask for, or the 40-column floor has widened it
past a narrow container. Either way the text is rendered CORRECTLY and simply
does not fit, and without horizontal reach the right-hand columns sit behind
.terminal-container's clip with no gesture that can get to them — measured at
360px, font 24: 218px of the pane, 38% of it, unreachable.
Present only while that is true; _syncTerminalOverflowAffordance() owns it. */
.terminal-container.term-overflows-x {
/* ⚠️ BOTH axes, explicitly. mobile.css loads after this file and sets
`.terminal-container { overflow: visible }` — a bare `overflow-x` would
then leave overflow-y computing to `auto` (CSS promotes a `visible` paired
with a non-visible axis), handing the browser a vertical scroll container
the terminal's own touch handler does not know about. */
overflow-x: auto;
overflow-y: hidden;
}
/* xterm's own element is width:100% above, so the container would see no
overflow to scroll even though .xterm-screen is wider than both. */
.terminal-container.term-overflows-x .xterm {
width: max-content;
min-width: 100%;
}
/* ⚠️ NO `touch-action: pan-x` here, deliberately. The terminal's own touchmove
handler pans this container (see `canPanHorizontally` in terminal-ui.js),
because `touchstart` preventDefault()s every 'content' tap and that cancels
a native pan before it can start. Granting the browser pan-x as well would
double-handle the gestures where that preventDefault does NOT run — a tap on
a scrolled-up viewport — moving the pane twice for one finger. The
`touch-action: none` the other rules set is what keeps JS the sole owner. */
/* Touch devices: prevent browser from claiming the touch gesture before
our JS touchmove handler fires. Without this, the browser starts native
scrolling during the first few px of finger travel and ignores our
@@ -6829,6 +6945,27 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
min-height: 0;
}
/* The picker's row tags ("Currently loaded", "Last used", "Default") reuse the
settings surface's .set-scope pill, but that pill is styled only inside
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) (see the
settings-surface block), so in here it rendered as plain body text and
"qwen3 Currently loaded" read as one model name. Same pill, same skin
tokens (never a hardcoded colour: --text-muted / --border are what each
html[data-skin] block redefines), and nothing about the modal's layout or
z-index. The row is a flex container, so the pills sit after the name
with the row's own gap and the trailing whitespace collapses. */
#customModelPickModal .set-scope {
font-size: 0.52rem;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
padding: 1px 4px;
white-space: nowrap;
color: var(--text-muted);
border: 1px solid var(--border);
opacity: 0.8;
}
/* Custom Model Endpoint Profiles: llama-swap model-swap confirmation — replaces a native
confirm() popup (docs/custom-model-endpoints-plan.md) so it looks and feels like the
rest of the app instead of a browser chrome dialog. Shares the context-window-too-small
@@ -12321,6 +12458,17 @@ kbd {
color: var(--text-dim);
font-size: 0.68rem;
}
/* Why this card is not blinking at anyone: the session is watching work it
started itself. Accent, like the watching badge on a session row, and never
the red or yellow that mean a human is needed. */
.approval-quiet {
margin-bottom: 6px;
color: var(--accent);
font-size: 0.68rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.approval-summary {
color: var(--text);
font-size: 0.76rem;
@@ -13613,6 +13761,14 @@ body.touch-device.cjk-input-visible .main {
background: var(--control-bg-hover);
}
.accessory-btn-compose.has-draft::after {
width: 6px;
height: 6px;
background: var(--yellow);
border-radius: 50%;
content: '';
}
.accessory-btn svg {
width: 14px;
height: 14px;
@@ -13733,6 +13889,64 @@ body.touch-device.cjk-input-visible .main {
font-weight: 600;
}
.prompt-composer-overlay {
overflow-y: auto;
padding: min(15dvh, 72px) 0 calc(12px + env(safe-area-inset-bottom));
}
.prompt-composer-dialog {
max-width: 560px;
/* The overlay's top inset and bottom gutter, plus the strip a folding device
reserves below the dialog (0px everywhere else; see the fold rules at the
end of this file). */
max-height: calc(100dvh - min(15dvh, 72px) - 12px - env(safe-area-inset-bottom) - var(--fold-block-end));
overflow-y: auto;
}
.prompt-composer-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin: 2px 2px 10px;
color: var(--text);
}
.prompt-composer-header span {
color: var(--text-dim);
font-size: 12px;
}
.prompt-composer-textarea {
min-height: min(34dvh, 240px);
max-height: 50dvh;
resize: vertical;
}
.prompt-composer-actions {
flex-wrap: wrap;
}
.prompt-composer-actions button {
min-height: var(--touch-target-min);
}
.prompt-composer-terminal {
flex: 1 0 100%;
padding: 8px 12px;
color: var(--text-dim);
font-size: 14px;
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
}
.prompt-composer-terminal:active {
color: var(--text);
background: var(--bg-input);
}
/* Shared lazy filesystem path picker (case linking + mobile input). */
.path-input-group {
display: flex;
@@ -15015,11 +15229,31 @@ html:not([data-skin="og"]) {
color: #061c20;
}
.btn-toolbar.btn-run.mode-codex:hover { box-shadow: 0 0 14px -2px rgba(43, 203, 187, 0.45); }
/* Pi keeps its rose identity on the non-og skins. This rule has to live INSIDE
this nested block: the generic `.btn-toolbar.btn-run` above resolves to (0,3,1)
here and would otherwise beat the base sheet's (0,3,0) `.mode-pi` pair, which
is exactly why gemini's and antigravity's gradients render as generic claude
blue on the default skin. */
/* Gemini, Antigravity, Pi, OMP, Grok and DeepSeek all keep their own identity
on the non-og skins. Each rule has to live INSIDE this nested block: the
generic `.btn-toolbar.btn-run` above resolves to (0,3,1) here and would
otherwise beat the base sheet's (0,3,0) `.mode-<id>` pair — which is exactly
what gemini/antigravity/omp did until this fix, rendering as generic claude
blue on `daylight-blue`, the actual DEFAULT skin for a fresh install
(`index.html`'s pre-paint script), not just a corner case on `og`. */
.btn-toolbar.btn-run.mode-gemini {
background: linear-gradient(135deg, #174ea6, #4f46e5);
border-color: #123a7a;
color: #dbeafe;
}
.btn-toolbar.btn-run.mode-gemini:hover { box-shadow: 0 0 14px -2px rgba(96, 165, 250, 0.45); }
.btn-toolbar.btn-run.mode-antigravity {
background: linear-gradient(135deg, #0e7490, #0891b2);
border-color: #0b5566;
color: #cffafe;
}
.btn-toolbar.btn-run.mode-antigravity:hover { box-shadow: 0 0 14px -2px rgba(34, 211, 238, 0.45); }
.btn-toolbar.btn-run.mode-omp {
background: linear-gradient(135deg, #4f46e5, #6366f1);
border-color: #3730a3;
color: #e0e7ff;
}
.btn-toolbar.btn-run.mode-omp:hover { box-shadow: 0 0 14px -2px rgba(129, 140, 248, 0.45); }
.btn-toolbar.btn-run.mode-pi {
background: linear-gradient(135deg, #be185d, #f472b6);
border-color: #be185d;
@@ -15299,6 +15533,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
}
.main.webview-active .webview-layer { display: flex; }
.main.webview-active .terminal-wrap { display: none; }
/* A split (Pane A + divider + Pane B) hides as one unit when a web tab is
active, mirroring the .terminal-wrap rule above — .terminal-wrap is
reparented INSIDE .terminal-split-container while a split is open, so
hiding only .terminal-wrap would leave Pane B and the divider stranded on
screen over the dashboard iframe. No state is destroyed, so returning to
the session tab shows the split intact. */
.main.webview-active .terminal-split-container { display: none; }
.webview-frame {
display: none;
@@ -16115,6 +16356,17 @@ html[data-tab-orientation='vertical'] .home-sessions {
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
}
/* Accent, deliberately none of the three above: a session that is watching something
it started (a monitor, a backgrounded shell, a cloud session) is not asking for
anything, so it must not borrow the red or the yellow that mean it is. This badge
sits BESIDE the state pill rather than replacing it, since an agent can arm a
monitor and ask a question in the same breath. */
.home-sessions-pill--watching {
background: color-mix(in srgb, var(--accent) 14%, transparent);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
}
/* Row accents: same language as the session tabs and the phone overview — red
means a question is pending, yellow means it wants input, green means work is
happening. Nothing else on this screen may reuse these colors. */
@@ -18177,6 +18429,25 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
}
/* An exited agent (Ark0N/Codeman#446): neutral, like the muted dot beside it.
No green at all, since nothing is running behind this row. */
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--exited,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--exited {
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
border-color: color-mix(in srgb, var(--text-muted) 30%, var(--border));
color: var(--text-muted);
}
/* Accent, deliberately none of the three above: a watching session is waiting for
work it started itself, not for the user, and the red and yellow here are spoken
for by sessions that ARE waiting for the user. */
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--watching,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--watching {
background: color-mix(in srgb, var(--accent) 14%, transparent);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
}
/* Three lines of content per row instead of two, so give them room to breathe
and stop the row actions crowding the pill.
@@ -18323,6 +18594,26 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
border-width: 2px;
}
/* The exited-agent mute, again, for the rich rail (Ark0N/Codeman#446).
The strip's rule is (0,5,0) and the three state rules above are (0,9,1), so
on this rail an exited session kept a full green dot and the working halo
beside a badge reading "exited" — measured, the contradiction the mute
exists to remove. This twin matches their (0,9,1) exactly and therefore MUST
stay below them in source order; moving it above silently restores the green
dot. It also clears the halo, which is a box-shadow the strip's rule never
had to think about.
⚠ The alert exclusions are repeated by hand for the same reason they are on
the rules above: a dot turning red or yellow because a session is blocked on
a human outranks "the agent exited". */
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact)
.tab-rail
.session-tab.tab-agent-exited:not(.tab-alert-action):not(.tab-alert-idle)
.tab-status:not(.error) {
background: var(--text-muted);
opacity: 0.5;
box-shadow: none;
}
/* Card accents: the same three colours as every other session surface, and the
same two blinks the home rail runs. `tab-alert-*` draws its own ::before ring
on top of this for the sessions that are genuinely blocked on a human; these
@@ -18552,6 +18843,19 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
padding-bottom: var(--fold-block-end);
}
/* The mobile prompt composer is a .paste-overlay with a gutter of its own: a
three-value `padding` shorthand whose bottom is 12px plus the safe area. The
generic rule above is a later longhand at the same specificity, so it ERASED
that gutter (measured at 393x852: padding-bottom 0 instead of 12px, and with
the fold variables set the hinge strip stood in for the gutter instead of
adding to it). Restate the composer's bottom gutter on top of the strip; the
side has no gutter of its own. The dialog's height cap subtracts the same
strip where it is declared (.prompt-composer-dialog). */
.prompt-composer-overlay {
padding-right: var(--fold-inline-end);
padding-bottom: calc(12px + env(safe-area-inset-bottom) + var(--fold-block-end));
}
/* The response viewer is a bottom sheet, so a vertical hinge running through it
is fine, since it is a wide surface like the terminal and inset dialogs are what
the fold guidance is about. A horizontal hinge is not: in tabletop pose the
@@ -18565,3 +18869,128 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
}
}
/* Split-Pane Sessions: container inserted only while a split is active.
.terminal-wrap (Pane A) is reparented into this as the first child; it
keeps every existing rule unchanged since nothing here restyles it. */
.terminal-split-container {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
min-height: 0;
}
.terminal-split-container > .terminal-wrap {
/* flex-shrink 1 (not 0): the divider's own 6px is fixed-width, and Pane A
+Pane B's inline flex-basis (openSplitPane/onMove) always sums to 100%,
so with flex-shrink 0 on both panes the row is 100% + 6px wide and
.main's overflow clips Pane B's right edge by exactly the divider's
width. Shrinking lets the two panes give up that 6px between them. */
flex: 0 1 auto;
min-width: 240px;
overflow: hidden;
}
.split-divider {
flex: 0 0 6px;
cursor: col-resize;
background: var(--border-color, #333);
position: relative;
}
.split-divider:hover,
.split-divider.dragging {
background: var(--accent-color, #4a9eff);
}
.terminal-pane-b {
/* flex-shrink 1, matching .terminal-wrap above — see its comment. */
flex: 0 1 auto;
min-width: 240px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.terminal-pane-b-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
font-size: 12px;
background: var(--bg-secondary, #1a1a1a);
border-bottom: 1px solid var(--border-color, #333);
flex: 0 0 auto;
}
.terminal-pane-b-close {
/* A native <button> now backs this (keyboard-reachable close), so reset its
default chrome back to the plain glyph this rule always drew. */
border: none;
background: none;
font: inherit;
color: inherit;
cursor: pointer;
padding: 0 6px;
opacity: 0.7;
}
.terminal-pane-b-close:hover {
opacity: 1;
}
.terminal-pane-b-container {
flex: 1 1 auto;
min-height: 0;
}
/* Split-picker menu: a small popover listing sessions to split with, appended
to document.body and positioned `fixed` by openSplitPicker() (JS sets
top/right against the Split header button's own rect). z-index above the
header (100) with headroom to spare, matching the sibling .run-mode-menu
dropdown's 1000. Dismissed by openSplitPane()'s own inline onclick, or by
the click-outside/Escape listeners installed alongside it. */
.split-picker-menu {
position: fixed;
z-index: 1000;
min-width: 200px;
max-width: 320px;
padding: 4px;
background: var(--floating-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--control-border);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
}
.split-picker-item {
/* A native <button> now backs each row (keyboard-reachable picker), so
reset its default chrome back to the plain list-row look this always
drew — full-width, left-aligned, no border/background of its own. */
display: block;
width: 100%;
text-align: left;
border: none;
background: none;
font: inherit;
color: inherit;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.split-picker-item:hover {
background: var(--control-bg-hover, rgba(255, 255, 255, 0.08));
}
.split-picker-empty {
padding: 8px 10px;
font-size: 13px;
color: var(--text-muted);
}
+36 -17
View File
@@ -18,7 +18,16 @@
* @see src/push-store.ts -- server-side VAPID key management and subscription CRUD
*/
const CACHE_NAME = 'codeman-v1';
// Build identity. scripts/build.mjs rewrites this declaration after it content-
// hashes the assets; the literal below is what dev serves, and dev wants a
// stable key.
//
// Why the cache key MUST carry it: `activate` deletes every cache whose key is
// not the current one, so the old constant key meant that cleanup never deleted
// anything — hashed assets from every release ever deployed accumulated in one
// bucket until the origin hit its storage quota.
const BUILD_ID = 'dev';
const CACHE_NAME = `codeman-${BUILD_ID}`;
// Reverse-proxy base path: the worker is served at `<base>/sw.js`, so its own
// location tells us the mount prefix ('' at root, or '/codeman'). Every URL below
@@ -27,27 +36,27 @@ const CACHE_NAME = 'codeman-v1';
const SW_BASE = self.location.pathname.replace(/\/sw\.js$/, '');
const B = (p) => (p && p[0] === '/' ? SW_BASE + p : p);
// Content-hashed assets. scripts/build.mjs rewrites this declaration with the
// filenames it actually emitted; dev has no hashing, so the empty literal below
// is correct there and the unhashed modules are simply cached on first use by
// the runtime handler further down.
//
// This list used to be maintained by hand with the PRE-hash names, which the
// build then renamed — so in production every entry 404'd and the silent
// `.catch()` in install swallowed all of it. Measured against a running
// instance: 15 of 23 entries failed. Offline still worked, because the fetch
// handler caches every successful GET at runtime, but the precache warmed
// nothing while looking like it did. Deriving it from the same manifest that
// renames the files is the only thing that keeps the two from drifting again.
const HASHED_ASSETS = [];
// Core app shell -- cached on install for instant startup
const APP_SHELL = [
'/',
'/styles.css',
'/mobile.css',
'/constants.js',
'/app.js',
'/api-client.js',
'/terminal-ui.js',
'/session-ui.js',
'/settings-ui.js',
'/panels-ui.js',
'/notification-manager.js',
'/mobile-handlers.js',
'/keyboard-accessory.js',
'/voice-input.js',
...HASHED_ASSETS.map((p) => '/' + p),
'/vendor/xterm.min.js',
'/vendor/xterm-addon-fit.min.js',
'/vendor/xterm-addon-unicode11.min.js',
'/vendor/xterm-zerolag-input.js',
'/vendor/xterm-predictive-echo.js',
'/vendor/xterm.css',
'/icon-192.png',
'/icon-512.png',
@@ -102,7 +111,17 @@ self.addEventListener('fetch', (event) => {
}
return response;
})
.catch(() => caches.match(request))
// ignoreSearch, or the precache can never be hit. `renderIndexHtml` runs
// `cacheBustAssets`, which appends `?v=<mtime>` to EVERY same-origin
// `.js`/`.css` reference — content-hashed names included, so the page asks
// for `/app.556be563.js?v=1789423735875` while the precache stored
// `/app.556be563.js`. `caches.match` is query-sensitive by default, so
// every precached entry was unreachable and only `/`, the icons and the
// manifest could ever be served offline.
//
// It also makes runtime-cached entries survive an mtime change: the same
// file re-requested under a new `?v=` still matches the copy already held.
.catch(() => caches.match(request, { ignoreSearch: true }))
);
});
+1 -1
View File
@@ -154,7 +154,7 @@ Object.assign(CodemanApp.prototype, {
this._persistTabRailWidth(preferred);
try {
if (this.activeSessionId && this.sendResize) await this.sendResize(this.activeSessionId);
else this.fitAddon?.fit();
else this.syncTerminalGeometry?.();
this._updateConnectionLinesImmediate?.();
} catch (error) {
console.warn('Failed to resize terminal after rail resize:', error);
+815
View File
@@ -0,0 +1,815 @@
// src/web/public/terminal-split.js
/**
* @fileoverview SplitTerminalPane — a second, independent live terminal pane
* ("Pane B") for split-view sessions. Deliberately plainer than the primary
* pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
* CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
* feature by nature — see docs/split-pane-sessions-plan.md.
*
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
*/
(function (global) {
/**
* Minimal chunked write for Pane B's own xterm instance — write() in
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
* giant synchronous write that blocks the main thread while parsing a long
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
* (terminal-ui.js): that one is wired into session-switch generation
* counters and the live-output gate this simpler, independently
* created/destroyed pane has no equivalent of.
*/
function writeChunked(terminal, buffer, isDestroyed) {
if (!buffer) return Promise.resolve();
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
terminal.write(buffer);
return Promise.resolve();
}
// Resolves once the LAST chunk is written (or the pane was destroyed
// mid-replay), so _loadBuffer() below can hold its single-flight flag
// across the whole replay rather than just the fetch that precedes it.
return new Promise((resolve) => {
let offset = 0;
const writeNext = () => {
if (isDestroyed() || !terminal) {
resolve();
return;
}
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
offset += chunk.length;
terminal.write(chunk);
if (offset < buffer.length) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
else setTimeout(writeNext, 16);
} else {
resolve();
}
};
writeNext();
});
}
class SplitTerminalPane {
constructor(sessionId, mountEl, opts = {}) {
this.sessionId = sessionId;
this.mountEl = mountEl;
this.sessionMode = opts.mode;
this.fontSettings = opts.fontSettings || {};
// Live reference (not a snapshot) to the app's detachedSessions Set —
// detaching this session AFTER the split is already open must still be
// seen by _sendResize() below, or it re-creates the exact PTY-size
// fight the split picker already refuses to open at pick time.
this.detachedSessions = opts.detachedSessions;
this.terminal = null;
this.fitAddon = null;
this.ws = null;
this._wsReady = false;
this._destroyed = false;
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
this._bufferLoading = false;
this._bufferRefreshPending = false;
}
async connect() {
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
this.terminal = new Terminal({
theme: { ...global.codemanCurrentXtermTheme() },
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
lineHeight: 1.2,
cursorBlink: false,
cursorStyle: 'block',
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
scrollback: DEFAULT_SCROLLBACK,
allowTransparency: true,
allowProposedApi: true,
});
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
this.terminal.open(this.mountEl);
this.fitAddon.fit();
this.terminal.onData((data) => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ t: 'i', d: data }));
}
});
// Pane B has no gates of its own by default, so every app-level chord
// that the document capture-phase handler (app.js) only preventDefault()s
// — never stopPropagation()s — reaches xterm here too and writes its raw
// byte/escape sequence into THIS session's PTY on top of whatever the app
// action already did to Pane A (COD-153; mirrors the primary pane's own
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
// registry-aware predicates so a rebind or a disable restores plain
// terminal behavior here too. Ctrl+V is deliberately left on xterm's own
// default (plain-text paste): Pane B has no image-paste trap to route it
// to, so intercepting it here would only break paste.
this.terminal.attachCustomKeyEventHandler((ev) => {
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
if (
ev.altKey &&
!ev.ctrlKey &&
!ev.shiftKey &&
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
) {
return false;
}
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
return false;
}
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
return false;
}
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
// swallow — in a plain shell session this is the user's own
// job-control tool and must reach the PTY, but in every other mode
// (claude/omp/pi/codex/...) it silently stops an unattended agent
// loop dead. Pane B has its own PTY/session and must not send a
// suspend into a non-shell one just because the primary pane's own
// gate lives elsewhere.
if (
ev.type === 'keydown' &&
ev.key.toLowerCase() === 'z' &&
ev.ctrlKey &&
!ev.altKey &&
!ev.metaKey &&
!ev.shiftKey &&
this.sessionMode !== 'shell'
) {
return false;
}
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
// every Enter variant, so an Ink app (Claude Code) can't tell a
// newline from a submit. Without this gate, Pane B's onData would
// send that bare \r straight over the WS and submit an incomplete
// prompt instead of adding a line to it. Targets THIS pane's own
// session (this.sessionId), never the primary pane's
// activeSessionId, and has no local-echo overlay of its own to flush
// first (Pane B is deliberately plainer — see the fileoverview).
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
fetch(`/api/sessions/${this.sessionId}/send-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
}).catch(() => {
/* Best-effort, matching this pane's tolerance elsewhere. */
});
return false;
}
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
// selection, Ctrl+C copies THIS pane's own selection instead of
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
// explicit, never-falls-through copy chord, and the predicate above
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
// a branch that assigns c.key only for a couple of special cases
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
// for Ctrl+Shift+C either way — this is not about an accidental
// interrupt byte reaching the PTY (verified live: it does not).
// Gating this whole block on hasSelection() (an earlier draft) meant
// that with no selection Ctrl+Shift+C skipped straight to `return
// true`, silently ceding the keystroke to the BROWSER's own handling
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
// attempt to copy, unlike Pane A, which always intercepts it.
// Re-implemented against this.terminal rather than reusing
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
// and would copy the wrong pane's selection.
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
const raw = this.terminal?.getSelection?.() || '';
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
// Both clean options are read for THIS pane, never the primary one:
// the gutter width comes from this.sessionId's own run mode, and the
// partial-first-line flag from this terminal's own selection range.
// Passing neither left Pane B keeping a margin Pane A dropped, on the
// same split and the same keystroke.
const range = global.app?._normalisedSelectionRange?.(this.terminal);
const selection = isColumnSelection
? raw
: (global.CodemanCopySelection?.clean?.(raw, {
margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0,
firstLinePartial: !!range && range.start.x > 0,
}) ?? raw);
if (selection.trim()) {
ev.preventDefault();
void global.app._copyText?.(selection).then((ok) => {
this.terminal?.clearSelection?.();
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
});
return false;
}
// Nothing worth copying — clear for feedback (a padding-only
// selection cleans to '' and this press still falls through to the
// PTY as 0x03, matching the primary pane's own rule).
if (this.terminal?.hasSelection?.()) {
this.terminal.clearSelection?.();
global.app.showToast?.('Nothing to copy', 'warning');
}
// Ctrl+Shift+C never falls through, even with nothing to copy —
// matches terminal-ui.js's own ev.shiftKey branch.
if (ev.shiftKey) {
ev.preventDefault();
return false;
}
}
return true;
});
// Load existing scrollback before going live. The WS below is
// subscribe-only (ws-routes.ts sends nothing on connect, only future
// 'terminal' events), so without this Pane B stays blank until the
// target session happens to produce new output. It LOOKED
// intermittent rather than always-broken because _sendResize() below
// often nudges the shared session's real tmux window to a new size,
// and tmux repaints its current screen on resize — that repaint was
// getting captured and streamed here, incidentally populating the
// pane. When Pane B's computed dimensions happened to already match
// the session's last-known size, Session.resize() (session.ts) skips
// the resize as a no-op, no repaint fires, and the pane stayed blank.
// The await covers the whole chunked replay, not just the fetch, so a
// live frame from the socket below can never land in the middle of it.
await this._loadBuffer();
if (this._destroyed) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this._wsReady = true;
this._sendResize();
};
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.t === 'o') {
this.terminal.write(msg.d);
} else if (msg.t === 'c') {
this.terminal.clear();
} else if (msg.t === 'r') {
// Server-triggered refresh (SSE backpressure cleared, terminal
// data was dropped). The primary pane routes this to
// _onSessionNeedsRefresh (app.js:2990) — Pane B has its own
// buffer loader for the same reason connect() does.
this._refreshBuffer();
}
} catch {
/* Malformed frame — ignore, matches primary pane's tolerance. */
}
};
// Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
// must go false on a drop or fit()/_sendResize() silently no-ops on a
// closed socket per the WebSocket spec (no exception, no log). No
// reconnect logic here — Pane B is deliberately plainer than the
// primary pane (see the fileoverview above); a drop just stops
// resizing until the parent recreates the pane. But onData already
// silently drops keystrokes while _wsReady is false (below), so
// without a visible marker a dropped socket left Pane B looking
// normal while it quietly ate everything typed into it. v1 scope is
// "say so", not reconnect — collapsing the split would lose the
// user's place in Pane B's scrollback for a transient blip.
this.ws.onclose = () => {
this._wsReady = false;
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
};
this.ws.onerror = () => {
// onclose fires after onerror — cleanup happens there.
};
}
// Fetches and writes the session's current scrollback. Used both by
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
// scoped to `this.activeSessionId` and clears/rewrites the primary
// terminal, neither of which applies to this independent pane, so this is
// a standalone equivalent rather than a call into it.
//
// Mirrors the primary pane's own mode check (app.js's selectSession /
// _onSessionNeedsRefresh): a shell session can retain hundreds of
// thousands of plain scrollback lines, so pulling `?full=1` there parses
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
// get one full replay. `fetch` here goes through the global wrapper
// (constants.js), which already prefixes CodemanBase — unlike the raw
// WebSocket URL above, which does not.
//
// Single-flight: the flag is held across the fetch AND the chunked write
// (writeChunked resolves after its last chunk), so two replays can never
// interleave their chunks into one terminal. A second call while one is
// in flight is dropped here; _refreshBuffer() is the caller that queues
// a trailing re-run instead.
async _loadBuffer() {
if (this._bufferLoading) return;
this._bufferLoading = true;
try {
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
const payload = (await res.json())?.data ?? {};
if (payload.terminalBuffer && this.terminal) {
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
}
} catch {
/* Best-effort — live output still arrives once the socket connects. */
} finally {
this._bufferLoading = false;
}
if (this._bufferRefreshPending && !this._destroyed) {
this._bufferRefreshPending = false;
this._refreshBuffer();
}
}
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
// frames in a row used to start two concurrent replays, each clearing
// the terminal under the other's chunked write. A refresh that arrives
// mid-replay is COALESCED into one trailing re-run rather than ignored:
// the in-flight fetch may predate the drop the new frame is reporting,
// and no further frame is coming to correct stale content.
_refreshBuffer() {
if (this._bufferLoading) {
this._bufferRefreshPending = true;
return;
}
this.terminal?.clear();
void this._loadBuffer();
}
// Local reflow only — no PTY resize frame. Split out so a divider drag
// can reflow both panes at the browser's paint rate (rAF) while sending
// the actual `{t:'z'}` resize once, at drag end, matching the primary
// pane's own convention (throttledResize in terminal-ui.js).
localFit() {
if (!this.fitAddon) return;
this.fitAddon.fit();
}
fit() {
this.localFit();
this._sendResize();
}
_sendResize() {
if (!this._wsReady || !this.fitAddon) return;
// One PTY cannot hold two sizes (mirrors sendResize's own
// detachedElsewhere yield in terminal-ui.js): the session got detached
// to its own window AFTER this split was opened, so its own window now
// owns the PTY's size and Pane B must stand aside.
if (this.detachedSessions?.has(this.sessionId)) return;
const dims = this.fitAddon.proposeDimensions();
if (!dims) return;
// Send the real proposed dimensions unclamped, matching the primary
// pane's convention (terminal-ui.js's getTerminalDimensions()) — the
// server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
// A 40/10 floor here misreported Pane B's real width to the PTY at the
// divider's own reachable 20% floor position, causing real
// output-wrapping bugs.
this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
}
destroy() {
this._destroyed = true;
if (this.ws) {
this.ws.onopen = null;
this.ws.onmessage = null;
// onclose fires asynchronously AFTER close(); without this it ran
// its "disconnected" write against a pane already torn down.
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.close();
this.ws = null;
}
if (this.terminal) {
this.terminal.dispose();
this.terminal = null;
}
this.fitAddon = null;
}
}
global.SplitTerminalPane = SplitTerminalPane;
})(window);
Object.assign(CodemanApp.prototype, {
/**
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
* + matchMedia backstop: a JS width check (so openSplitPane() below can
* refuse even if a click somehow reaches the button) plus a live listener,
* because a window narrowed WHILE the button is showing must hide it
* without waiting for a settings save or reload. The CSS `@media
* (max-width: 1179px)` rule in styles.css is the backstop for the reverse
* direction: it hides the button even if this JS never runs at all.
*/
_applySplitButtonVisibility(enabled) {
this._splitButtonSettingEnabled = enabled;
const splitBtn = document.querySelector('.btn-split');
const wide = window.innerWidth >= SPLIT_PANE_MIN_WIDTH;
// Narrowing past the gate must not leave an open split on screen with no
// way to reach the button that would close it — the two 240px min-widths
// plus the divider overflow a narrow window and .main clips Pane B's edge.
if (!wide && this._splitPane) this.closeSplitPane();
if (!splitBtn) return;
splitBtn.classList.toggle('btn-split--hidden', !enabled || !wide);
if (!this._splitButtonWidthListenerInstalled && window.matchMedia) {
this._splitButtonWidthListenerInstalled = true;
const mq = window.matchMedia(`(min-width: ${SPLIT_PANE_MIN_WIDTH}px)`);
mq.addEventListener('change', () => this._applySplitButtonVisibility(this._splitButtonSettingEnabled));
}
},
openSplitPicker(event) {
// Mirrors toggleRunModeMenu (session-ui.js): stopPropagation on the
// OPENING click so it never reaches the outside-click listener this
// same call is about to register — without it, a click landing on the
// button's own inner <svg> (matched by neither `menu.contains()` nor
// the old exact-node check below) bubbled straight through to
// `document` and self-closed the menu it just opened.
event?.stopPropagation();
if (this._splitPane) {
this.closeSplitPane();
return;
}
const candidates = window.CodemanSplitPane.buildSplitPickerSessions(
this.sessions,
this.sessionOrder,
this.activeSessionId,
this.detachedSessions
);
// Route a pre-existing menu through the SAME dismiss path used
// everywhere else, instead of a raw `.remove()`: a genuinely still-open
// menu has live document listeners (see below), and a raw removal left
// them attached forever — only the single-slot field below got
// overwritten, so every prior pair but the last was orphaned on
// `document` with no way to ever find and remove it again.
this._dismissSplitPicker();
const menu = document.createElement('div');
menu.id = 'splitPickerMenu';
menu.className = 'split-picker-menu';
if (candidates.length === 0) {
menu.innerHTML = '<div class="split-picker-empty">No other sessions to split with</div>';
} else {
menu.innerHTML = candidates
.map(
(c) =>
// data-i18n-skip: the whole row's text IS a session name — i18n.js
// does exact-string lookup over text nodes, and a session
// literally named e.g. "Sessions" would otherwise get translated
// on zh-CN (see the .session-name skip on the pane header below).
`<button type="button" class="split-picker-item" data-i18n-skip onclick="app.openSplitPane(${escapeHtml(JSON.stringify(c.id))}); app._dismissSplitPicker();">${escapeHtml(c.label)}</button>`
)
.join('');
}
document.body.appendChild(menu);
const splitBtn = document.querySelector('.btn-split');
if (splitBtn) {
const rect = splitBtn.getBoundingClientRect();
menu.style.position = 'fixed';
menu.style.top = `${rect.bottom + 4}px`;
menu.style.right = `${window.innerWidth - rect.right}px`;
}
// Dismiss on outside click or Escape — same one-shot listener pattern as
// session-ui.js's other transient popovers (toggleCaseSettings(),
// toggleRunModeMenu()). Deferred by a tick so the click that OPENED the
// menu (still bubbling) doesn't immediately close it — reinforced by
// the button's own stopPropagation() above, which is what actually
// stops that same click reaching `document` at all. Picking an item
// (above) calls the SAME dismiss method, so these listeners never
// outlive the menu either way.
//
// Self-removing by identity: each handler removes ITSELF (and its
// sibling) the moment it fires, rather than leaning solely on the
// `this._splitPickerDismissHandlers` field. That field is still kept in
// sync (so `_dismissSplitPicker()` called from elsewhere — the picker
// item's onclick above, or a still-open menu at the top of this method
// — can find and remove the CURRENT pair), but no path here can ever
// again leave a pair attached to `document` with nothing referencing it.
const closeOnOutsideClick = (e) => {
if (menu.contains(e.target) || e.target.closest('.btn-split')) return;
document.removeEventListener('click', closeOnOutsideClick);
document.removeEventListener('keydown', closeOnEscape);
this._splitPickerDismissHandlers = null;
menu.remove();
};
const closeOnEscape = (e) => {
if (e.key !== 'Escape') return;
document.removeEventListener('click', closeOnOutsideClick);
document.removeEventListener('keydown', closeOnEscape);
this._splitPickerDismissHandlers = null;
menu.remove();
};
this._splitPickerDismissHandlers = { closeOnOutsideClick, closeOnEscape };
setTimeout(() => document.addEventListener('click', closeOnOutsideClick), 0);
document.addEventListener('keydown', closeOnEscape);
},
_dismissSplitPicker() {
document.getElementById('splitPickerMenu')?.remove();
if (this._splitPickerDismissHandlers) {
document.removeEventListener('click', this._splitPickerDismissHandlers.closeOnOutsideClick);
document.removeEventListener('keydown', this._splitPickerDismissHandlers.closeOnEscape);
this._splitPickerDismissHandlers = null;
}
},
openSplitPane(sessionId) {
// Desktop-only hard gate, independent of the button's own hidden state —
// see _applySplitButtonVisibility's comment for why both a JS check and
// a CSS backstop exist.
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
// No active session means there is no `.terminal-wrap` to split against
// (the welcome overlay is showing) — without this, a split opened from
// the home screen still created the container and connected Pane B, just
// behind the opaque overlay with nothing visible to show for it.
if (!this.activeSessionId) return;
// A web tab hides `.terminal-wrap`'s container via CSS with nothing
// gating the button itself, and `activeSessionId` survives openWebview()
// — without this, picking a session opens Pane B's socket behind a
// hidden container with nothing on screen to show for it.
if (this.activeWebviewId) return;
// A stale picker click (opened before switching tabs) or clicking Pane
// B's own session tab while split can otherwise land here with
// sessionId === activeSessionId: two live WebSockets to the same
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
// resize frame. Refuse before creating any DOM or SplitTerminalPane.
if (sessionId === this.activeSessionId) return;
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
// re-applied here: the menu can sit open while a listed session's CLI
// exits (pid → null) or gets popped out to its own window, and nothing
// re-runs the picker filter for a row that already rendered. Same
// outcome as the picker gives such a session (not offered, silently):
// one with no PTY has nothing reading its pane, so Pane B would show
// nothing and drop every keystroke behind a healthy-looking socket, and
// a detached session's own window already owns its PTY size.
const session = this.sessions.get(sessionId);
if (!session || session.pid === null) return;
if (this.detachedSessions?.has?.(sessionId)) return;
if (this._splitPane) this.closeSplitPane();
const wrap = document.querySelector('.terminal-wrap');
const parent = wrap.parentElement;
const container = document.createElement('div');
container.className = 'terminal-split-container';
const divider = document.createElement('div');
divider.className = 'split-divider';
const paneB = document.createElement('div');
paneB.className = 'terminal-pane-b';
paneB.innerHTML = `
<div class="terminal-pane-b-header">
<span class="session-name">${escapeHtml(session?.name || 'Session')}</span>
<button type="button" class="terminal-pane-b-close" onclick="app.closeSplitPane()" aria-label="Close split">&times;</button>
</div>
<div class="terminal-pane-b-container"></div>
`;
parent.insertBefore(container, wrap);
container.appendChild(wrap);
wrap.style.flexBasis = '50%';
container.appendChild(divider);
container.appendChild(paneB);
paneB.style.flexBasis = '50%';
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
mode: session?.mode,
fontSettings: this.loadAppSettingsFromStorage?.() || {},
detachedSessions: this.detachedSessions,
});
this._splitPane.connect().catch(() => {
/* Best-effort, matching the primary pane's own tolerance for a failed
initial load — live output still arrives once/if the socket connects. */
});
this._splitSessionId = sessionId;
// Pane A just went from full width to 50%, but nothing has told its
// session's PTY/tmux window about it yet — the passive ResizeObserver in
// terminal-ui.js debounces 300ms and would eventually catch up, but
// relying on that left the pane showing stale-width content (existing
// box-drawing lines, banners) until the user hit "Redraw Terminal".
// Force it immediately, mirroring closeSplitPane()'s symmetric call.
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
this._installSplitDividerDrag(divider, wrap, paneB);
this._updateSplitButtonState(true);
},
closeSplitPane(options = {}) {
if (!this._splitPane) return;
// A split can collapse MID-DRAG (either session ending, the window
// narrowing past the gate, a click on Pane B's own tab). The drag's own
// onUp is what normally clears `body.split-pane-resizing` (a col-resize
// cursor plus user-select:none on EVERY element, styles.css), and it
// relied on pointer capture routing pointerup back to a divider this
// method detaches below, so a mid-drag collapse left the whole page
// locked in resize mode until a reload. Tear the drag down first.
this._splitDividerDragTeardown?.();
this._splitDividerDragTeardown = null;
this._splitPane.destroy();
this._splitPane = null;
this._splitSessionId = null;
this._updateSplitButtonState(false);
const container = document.querySelector('.terminal-split-container');
if (!container) return;
const wrap = container.querySelector('.terminal-wrap');
const parent = container.parentElement;
wrap.style.flexBasis = '';
parent.insertBefore(wrap, container);
container.remove();
if (this.fitAddon) this.fitAddon.fit();
// The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split
// while activeSessionId is still the id the server just removed, so a
// resize from here would be aimed at a session that no longer exists;
// the promoted session gets its own resize from selectSession().
if (!options.skipPrimaryResize) {
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
}
},
// A click on .btn-split does one of two things — open the picker, or
// (openSplitPicker's own early return) close an already-open split — and
// nothing on the button said which. `.split-open` + aria-pressed give it
// the same active-state language as the codebase's other toggle buttons
// (keyboard-accessory's Ctrl key, the voice-input mic).
_updateSplitButtonState(open) {
const btn = document.querySelector('.btn-split');
if (!btn) return;
btn.classList.toggle('split-open', open);
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
const title = open ? 'Split: close the second session' : 'Split: open a second session beside this one';
btn.title = title;
btn.setAttribute('aria-label', title);
},
_installSplitDividerDrag(divider, wrap, paneB) {
let dragging = false;
let dragRaf = null;
let pendingClientX = null;
let capturedPointerId = null;
// Local-only reflow (flexBasis + both panes' xterm fit, no PTY resize
// frame). Coalesced to one call per animation frame below — a raw
// mousemove stream fires far faster than the browser repaints, and
// without the rAF gate each event did a full xterm reflow on BOTH
// panes AND sent Pane B a `{t:'z'}` resize frame (SplitTerminalPane has
// no client-side "dims unchanged" skip), which fanned out into a
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
// of each dragging across half a wide viewport.
const applyDragPercent = (clientX) => {
const container = divider.parentElement;
// The split can auto-collapse mid-drag (the other pane's session
// ending, or the picker's own close button) — closeSplitPane() removes
// `.terminal-split-container` from the DOM, which detaches `divider`
// too, so `divider.parentElement` is null on the very next frame and
// every drag threw here until mouseup finally removed the listener.
if (!container) return;
const rect = container.getBoundingClientRect();
const rawPercent = ((clientX - rect.left) / rect.width) * 100;
const percent = window.CodemanSplitPane.clampDividerPercent(rawPercent);
wrap.style.flexBasis = `${percent}%`;
paneB.style.flexBasis = `${100 - percent}%`;
if (this.fitAddon) this.fitAddon.fit();
this._splitPane?.localFit();
};
const onMove = (e) => {
if (!dragging) return;
pendingClientX = e.clientX;
if (dragRaf) return;
dragRaf = requestAnimationFrame(() => {
dragRaf = null;
applyDragPercent(pendingClientX);
});
};
// Everything pointerdown ARMS, undone in one place: the body-level
// cursor/selection lock, the divider's dragging class, pointer capture,
// the move/up/cancel listeners and a queued reflow frame. Shared by onUp
// (a normal drag end) and by closeSplitPane(), via the teardown handle
// stored below, for a split that collapses mid-drag: the pointerup that
// would have run onUp is routed by pointer capture to a divider
// closeSplitPane() has detached, so it never arrives. Idempotent, since
// the teardown runs whether or not a drag is in progress.
const endDrag = () => {
dragging = false;
divider.classList.remove('dragging');
document.body.classList.remove('split-pane-resizing');
if (capturedPointerId !== null) {
try {
divider.releasePointerCapture(capturedPointerId);
} catch {
/* Already released (pointercancel/lostpointercapture beat us here). */
}
capturedPointerId = null;
}
divider.removeEventListener('pointermove', onMove);
divider.removeEventListener('pointerup', onUp);
divider.removeEventListener('pointercancel', onUp);
if (dragRaf) {
cancelAnimationFrame(dragRaf);
dragRaf = null;
}
};
const onUp = () => {
// A reflow frame still queued at release carries the final pointer
// position; apply it once, synchronously, so the panes end where the
// pointer did rather than one frame short.
const hadQueuedFrame = dragRaf !== null;
endDrag();
if (hadQueuedFrame) applyDragPercent(pendingClientX);
// Send the real PTY resize exactly once here, at drag end, for BOTH
// panes — never per-move (matching the codebase's established
// trailing-edge debounce convention, see throttledResize in
// terminal-ui.js) so a fast drag doesn't flood dozens of intermediate
// SIGWINCH/reflow states into scrollback or spawn a `tmux
// resize-window` child per frame.
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
this._splitPane?.fit();
};
// Pointer events + setPointerCapture (mirrors tab-rail-resize.js) instead
// of mousedown/document-level mousemove: a plain mousedown drag selects
// the text under the cursor as it crosses both terminals, and pointer
// capture routes move/up straight to `divider` regardless of what's under
// the cursor mid-drag, so no document-level listener leak is possible if
// the pointer is released off-window. `body.split-pane-resizing` (mirrors
// `body.tab-rail-resizing`) locks the cursor/selection for the drag.
divider.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
dragging = true;
divider.classList.add('dragging');
document.body.classList.add('split-pane-resizing');
try {
divider.setPointerCapture(e.pointerId);
capturedPointerId = e.pointerId;
} catch {
/* Capture failed — the drag still works via the listeners below. */
}
divider.addEventListener('pointermove', onMove);
divider.addEventListener('pointerup', onUp);
divider.addEventListener('pointercancel', onUp);
});
this._splitDividerDragTeardown = endDrag;
},
});
const _originalOnSessionDeleted = CodemanApp.prototype._onSessionDeleted;
CodemanApp.prototype._onSessionDeleted = function (data) {
if (this._splitSessionId === data.id) {
this.closeSplitPane();
} else if (this._splitPane && this.activeSessionId === data.id) {
// Pane A's session ended: promote Pane B by closing the split and
// selecting its session as the new (single) active pane. This is an
// app-driven selection, not the user clicking a tab, so it must not
// spend the promoted session's idle alert (see the Approvals Inbox
// acknowledgement rule in CLAUDE.md — only a human opening a session
// acknowledges it).
const promoted = this._splitSessionId;
// activeSessionId is still data.id here (the original handler below is
// what retires it), so closeSplitPane()'s closing resize would be aimed
// at the session the server just removed. Skip it; selectSession() sizes
// the promoted session itself.
this.closeSplitPane({ skipPrimaryResize: true });
// Closing Pane A's own tab (closeSession(), app.js) adds data.id to
// _closingSessions BEFORE awaiting the delete, then owns the follow-up
// selection itself once the delete lands — same race _onSessionDeleted's
// own active-session handoff guards against (see its comment). Selecting
// here too would fight it for which tab wins.
if (promoted && !this._closingSessions.has(data.id)) {
this.selectSession(promoted, { auto: true });
}
}
return _originalOnSessionDeleted.call(this, data);
};
// I2: closes an active split BEFORE the primary pane rebinds to the same
// session Pane B is showing (clicking Pane B's own session tab while split,
// or any other selectSession() call that targets _splitSessionId). Without
// this, Pane A rebinds to a session that Pane B's independent WebSocket is
// still attached to — two live WebSockets to one session, each claiming PTY
// dimensions via its own `{t:'z',...}` resize frame.
const _originalSelectSession = CodemanApp.prototype.selectSession;
CodemanApp.prototype.selectSession = function (sessionId, ...args) {
if (this._splitPane && this._splitSessionId === sessionId) {
this.closeSplitPane();
}
return _originalSelectSession.call(this, sessionId, ...args);
};
+613 -69
View File
@@ -368,10 +368,18 @@ Object.assign(CodemanApp.prototype, {
// part of a row selects real padding spaces, which are truthy, so testing
// the raw text would spend this press on a copy of nothing and make the
// user press again to interrupt.
const selection = this.cleanedTerminalSelection();
//
// ⚠️ The gate cleans, and the copy is handed the RAW selection, because
// copyTerminalSelection cleans again on its own. The margin strip is not
// idempotent: a second pass takes up to `margin` more columns off what
// the first pass left, so passing the cleaned string through dedented a
// Claude or Codex copy twice. Every other copy path already hands over
// the raw selection or reads it live.
const raw = this.terminal?.hasSelection?.() ? this.terminal.getSelection() : '';
const selection = this.cleanedTerminalSelection(raw);
if (selection.trim()) {
ev.preventDefault();
void this.copyTerminalSelection(selection);
void this.copyTerminalSelection(raw);
return false;
}
// Nothing worth copying. The clear is for feedback, not for the
@@ -543,6 +551,14 @@ Object.assign(CodemanApp.prototype, {
this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor());
}
// Renderer liveness — see _startRenderLivenessWatchdog. Registered for every
// device, not just touch: the rAF-discard behaviour is worst on an iOS PWA
// but a stale handle wedges the debouncer identically anywhere it happens.
this.terminal.onRender(() => {
this._lastRenderAt = Date.now();
});
this._startRenderLivenessWatchdog();
// CJK IME input — textarea in index.html, just wire up send
this._cjkInput = null;
if (typeof CjkInput !== 'undefined') {
@@ -579,12 +595,12 @@ Object.assign(CodemanApp.prototype, {
if (isMobileSafari) {
// Wait for layout, then fit multiple times to ensure proper sizing
requestAnimationFrame(() => {
this.fitAddon.fit();
this.syncTerminalGeometry();
// Double-check after another frame
requestAnimationFrame(() => this.fitAddon.fit());
requestAnimationFrame(() => this.syncTerminalGeometry());
});
} else {
this.fitAddon.fit();
this.syncTerminalGeometry();
}
// Whenever that first fit runs — on this line, or a frame or two later on
// the mobile-Safari branch above — it measures whatever font the browser has
@@ -756,6 +772,32 @@ Object.assign(CodemanApp.prototype, {
let longPressStartX = 0;
let longPressStartY = 0;
let touchStartY = 0;
let touchStartX = 0;
// 'x' | 'y' | null — locked on the first travel past the tap slop, so a
// diagonal drag cannot pan and scroll at the same time.
let panAxis = null;
/**
* Can this gesture pan sideways? Only while the terminal is wider than
* the box showing it (`.term-overflows-x`, set by
* `_syncTerminalOverflowAffordance`).
*
* ⚠️ This has to be done in JS. `touch-action: pan-x` alone does nothing
* for the sessions the affordance targets: `touchstart` calls
* preventDefault() for every 'content' tap — the normal case for a
* mouse-tracking TUI sitting at the bottom of its buffer — which cancels
* the browser's pan before it starts. Measured under touch emulation, a
* 140px horizontal swipe reached scrollLeft 141 without that
* preventDefault and 0 with it. It only ever worked for shell sessions,
* while scrolled up, or with a mouse.
*/
const canPanHorizontally = () =>
// Both halves. The class is what makes the container a scroller at all
// (`overflow-x: auto`); without it `scrollLeft` silently stays 0, and a
// gesture locked to 'x' on that basis would do nothing AND suppress the
// vertical scroll it should have been. The measurement is the second
// half because sub-pixel cell widths can leave a stray pixel of
// scrollWidth on a terminal that fits perfectly well.
container.classList.contains('term-overflows-x') && container.scrollWidth - container.clientWidth > 1;
let tapStartedWithTerminalFocus = false;
let tapStartIntentCache = null;
// px — ignore micro-drift to distinguish tap from scroll. Shared with the
@@ -775,6 +817,8 @@ Object.assign(CodemanApp.prototype, {
touchLastX = ev.touches[0].clientX;
touchLastY = ev.touches[0].clientY;
touchStartY = touchLastY;
touchStartX = touchLastX;
panAxis = null;
velocity = 0;
pixelAccum = 0;
isTouching = true;
@@ -843,8 +887,15 @@ Object.assign(CodemanApp.prototype, {
}
if (ev.touches.length === 1 && isTouching) {
const touchY = ev.touches[0].clientY;
if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) {
didScroll = true;
const touchX = ev.touches[0].clientX;
if (!didScroll) {
const travelY = Math.abs(touchY - touchStartY);
const travelX = Math.abs(touchX - touchStartX);
const sideways = canPanHorizontally() && travelX >= TAP_THRESHOLD;
if (travelY >= TAP_THRESHOLD || sideways) {
didScroll = true;
panAxis = sideways && travelX > travelY ? 'x' : 'y';
}
}
// Below the tap threshold, treat the gesture as a potential tap:
// don't preventDefault (iOS needs click synthesis to show the
@@ -854,6 +905,15 @@ Object.assign(CodemanApp.prototype, {
// fling, so a jittery tap would both position the cursor AND scroll.
if (!didScroll) return;
ev.preventDefault();
if (panAxis === 'x') {
// Pan the container, and touch nothing the vertical path owns —
// no pixelAccum, no velocity, so touchend cannot turn a sideways
// swipe into a momentum fling down the scrollback.
container.scrollLeft -= touchX - touchLastX;
touchLastX = touchX;
touchLastY = touchY;
return;
}
const delta = touchLastY - touchY; // positive = scroll down
pixelAccum += delta;
velocity = delta * 1.2;
@@ -991,10 +1051,6 @@ Object.assign(CodemanApp.prototype, {
this._resizeTimeout = null;
this._lastResizeDims = null;
// Minimum terminal dimensions to prevent vertical text wrapping
const MIN_COLS = 40;
const MIN_ROWS = 10;
const throttledResize = () => {
if (this._tabRailResizeOwnsObserver) return;
// Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens
@@ -1014,10 +1070,6 @@ Object.assign(CodemanApp.prototype, {
}
this._resizeTimeout = setTimeout(() => {
this._resizeTimeout = null;
// Fit xterm.js to final container dimensions
if (this.fitAddon) {
this.fitAddon.fit();
}
// Flush any stale flicker buffer before clearing viewport
if (this.flickerFilterBuffer) {
if (this.flickerFilterTimeout) {
@@ -1026,32 +1078,47 @@ Object.assign(CodemanApp.prototype, {
}
this.flushFlickerBuffer();
}
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
// causes Ink to re-render at the new row count, garbling terminal output.
// Local fit() still runs so xterm knows the viewport size for scrolling.
// Hold the PTY's shape while the virtual keyboard is up: a SIGWINCH per
// step of the OS animation makes Ink re-render at a row count that is
// about to change again, and shifts the accessory toolbar mid-typing.
// KeyboardHandler's settle timer sends ONE resize once the animation
// stops (`_sendTerminalResize`), so the PTY is not left stale.
const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
// Same yield as sendResize: never resize a PTY whose session is showing
// in its own window. Dragging the dashboard's border must not reshape it.
const detachedElsewhere = !this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId);
if (this.activeSessionId && !keyboardUp && !detachedElsewhere) {
const dims = this.fitAddon.proposeDimensions();
// Enforce minimum dimensions to prevent layout issues
const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS;
const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS;
// ⚠️ Whether to fit is the SAME question as whether to send (issue #464).
// This block used to fit unconditionally and skip only the SIGWINCH,
// which is the one combination that cannot be right: it moves xterm to
// a shape the PTY is never told about, and Claude Code computes its
// repaints from the shape it was told. Withhold both, or neither —
// a reflow nothing is rendering for buys nothing and costs correctness.
const dims =
this.activeSessionId && !keyboardUp && !detachedElsewhere ? this._geometryForResizeRequest() : null;
// ⚠️ A null measurement is NOT a reason to report the floor. It used to
// fall back to a bare 40x10, which tells the PTY a shape nothing measured
// and xterm does not hold — the write-only guess this whole change exists
// to remove. An unmeasurable terminal has nothing to say; the next
// resize event says it.
if (dims) {
const { cols, rows } = dims;
// Only send resize if dimensions actually changed
if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) {
// Clear viewport + scrollback ONLY when dimensions actually change.
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
// pushing overflow into scrollback. Ink's cursor-up count is based on the
// pre-reflow line count, so ghost renders accumulate in scrollback.
// syncTerminalGeometry() reflowed content: lines at old width may wrap to
// more rows, pushing overflow into scrollback. Ink's cursor-up count is
// based on the pre-reflow line count, so ghost renders accumulate there.
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
// IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed).
// Clearing without a subsequent Ink redraw leaves the terminal blank.
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
// Not while another device holds the width: the columns were not
// reflowed here, and a refused resize brings no redraw after it.
if (
activeResizeSession &&
activeResizeSession.mode !== 'shell' &&
!this._paneWidthRefused &&
this.terminal &&
this.isTerminalAtBottom()
) {
@@ -1076,11 +1143,24 @@ Object.assign(CodemanApp.prototype, {
}
}
if (!sentViaWs) {
// ⚠️ The reply carries the geometry that actually took, and this
// is the path where a declined resize is LEAST likely to be
// noticed: no socket means no `{"t":"zc"}` frame either, so
// discarding it here left the one transport that cannot hear the
// answer also not asking for it.
const resizedSessionId = this.activeSessionId;
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows, viewportType }),
}).catch(() => {});
})
.then(async (res) => {
const applied = (await res.json())?.data ?? {};
this._onPtyGeometryReport(resizedSessionId, applied.cols, applied.rows);
})
.catch(() => {
/* a resize that never landed tells us nothing about the PTY */
});
}
}
}
@@ -1092,6 +1172,12 @@ Object.assign(CodemanApp.prototype, {
if (this._localEchoOverlay?.hasPending) {
this._localEchoOverlay.rerender();
}
// Pane B (split view) has its own container and its own fit()/resize
// frame — this observer only ever measured Pane A's container, so
// without this call Pane B never learned about a window resize, an
// Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently
// stayed at whatever size it was last dragged to.
this._splitPane?.fit();
}, 300); // Trailing-edge: only fire after 300ms of no resize events
};
@@ -1928,24 +2014,22 @@ Object.assign(CodemanApp.prototype, {
this.terminal?.select?.(index % cols, Math.floor(index / cols), length);
},
/** Long-press fired: select the word under the finger and arm drag-to-extend. */
/** Long-press fired: swallow the platform gesture, then select a word if one exists. */
_beginTouchSelection(clientX, clientY) {
// Reaching the 350ms threshold makes this a long press even when the finger
// landed on blank space. Arm every guard before looking for a word so Chrome
// cannot focus xterm's hidden textarea, and leave _touchSelecting set so the
// touchend branch preventDefaults the compatibility mouse sequence.
this._blurMobileTerminalInput();
this._suppressTrustedTapMouseEvents();
this._armTouchSelectionFocusGuard();
this._touchSelecting = true;
const cell = this._touchSelectionCellAt(clientX, clientY);
if (!cell) return false;
const word = this._touchSelectionWordAt(cell);
if (!word) return false;
// The keyboard must not sit on top of the thing being selected, and the
// composer would eat the selection on its next keystroke anyway.
this._blurMobileTerminalInput();
this._touchSelectionAnchor = word;
this._touchSelecting = true;
this._touchSelectionActive = true;
// From here until the gesture ends, no trusted mouse event may reach xterm —
// see _endTouchSelectionGesture for why — and the terminal input may not take
// focus. Both are re-armed as the gesture continues, since their windows are
// short and a press can be held for much longer.
this._suppressTrustedTapMouseEvents();
this._armTouchSelectionFocusGuard();
this._applyTouchSelection(word.index, word.length);
// Android answers; iOS ignores it silently. Both are fine.
try {
@@ -3337,7 +3421,107 @@ Object.assign(CodemanApp.prototype, {
return performance.now() - this._lastUserScrollUpAt < window.CodemanTerminalInput.USER_SCROLL_STICKY_SUPPRESS_MS;
},
/**
* Watchdog for a frozen renderer.
*
* iOS DISCARDS scheduled requestAnimationFrame callbacks when a PWA goes to
* the background — not deferred, never delivered. xterm's RenderDebouncer
* only clears its `_animationFrame` handle from INSIDE that callback, so once
* one is dropped the handle stays permanently non-undefined and every later
* `refresh()` returns on its first line. Parsing is decoupled from rendering,
* so bytes keep filling the buffer correctly and nothing throws: the terminal
* is simply frozen until the page is reloaded.
*
* Codeman is more exposed than an app that mounts a terminal per session —
* there is exactly ONE xterm instance for the whole page load, so a single
* backgrounding can wedge it for the rest of the session.
*
* The heal is what `_innerRefresh` would have done: cancel the stale handle,
* clear the field, and request a full repaint (which schedules a fresh rAF).
* Cancelling a genuinely pending handle is harmless — the full repaint that
* follows covers whatever it was going to draw.
*
* Discipline for reaching into xterm privates, and it is not optional: every
* access is optional-chained and the whole body is wrapped, so a shape change
* upstream degrades to a no-op. A self-heal that can break the terminal it is
* healing is worse than no self-heal.
*
* ⚠️ The field path (`_core._renderService._renderDebouncer._animationFrame`)
* is validated against xterm 6.x and CANNOT be covered by the CI gate:
* `_renderService` is only constructed by `Terminal.open()`, which needs a
* real DOM, and the gate runs in node. `test/xterm-private-api.test.ts` pins
* the RESOLVED lockfile version instead, so ANY bump fails there — not only a
* major — and sends someone to re-check this by hand; the declared `^6.0.0`
* range was the wrong assertion in both directions, since 6.4.0 could rename a
* private field while resolving inside it. `test/terminal-resilience.test.ts`
* covers the decision half. If the path ever goes stale the watchdog silently
* stops healing — that is the failure mode to watch for, and why the version
* guard exists at all.
*/
_startRenderLivenessWatchdog() {
this._stopRenderLivenessWatchdog();
this._lastRenderAt = Date.now();
this._lastTerminalWriteAt = 0;
this._renderLivenessTimer = setInterval(() => {
try {
if (typeof CodemanRenderLiveness === 'undefined') return;
const kick = CodemanRenderLiveness.shouldKickRenderer({
wroteAt: this._lastTerminalWriteAt || 0,
renderedAt: this._lastRenderAt || 0,
now: Date.now(),
// A hidden terminal legitimately stops rendering (xterm pauses it),
// so only a VISIBLE one that owes us a frame counts as frozen.
visible: document.visibilityState === 'visible' && !!this.terminal?.element?.isConnected,
});
if (!kick) return;
const kicked = this._kickRenderer();
_crashDiag.log(`RENDER STALL: kick=${kicked}`);
// Treat the kick as the render for accounting purposes either way, so a
// terminal we cannot heal logs once per stall rather than every tick.
this._lastRenderAt = Date.now();
} catch {
/* a watchdog must never throw into the interval */
}
}, RENDER_LIVENESS_POLL_MS);
},
_stopRenderLivenessWatchdog() {
if (this._renderLivenessTimer) {
clearInterval(this._renderLivenessTimer);
this._renderLivenessTimer = null;
}
},
/**
* Do what xterm's dropped `_innerRefresh` would have done. Never throws.
* @returns {boolean} true if a stale handle was found and cleared.
*/
_kickRenderer() {
try {
const renderService = this.terminal?._core?._renderService;
const debouncer = renderService?._renderDebouncer;
if (!debouncer || typeof renderService.refreshRows !== 'function') return false;
const handle = debouncer._animationFrame;
if (handle === undefined) return false; // not wedged — nothing to clear
try {
cancelAnimationFrame(handle);
} catch {
/* a stale handle may no longer be cancellable; clearing it is the point */
}
debouncer._animationFrame = undefined;
renderService.refreshRows(0, Math.max(0, (this.terminal.rows || 1) - 1));
return true;
} catch {
return false;
}
},
batchTerminalWrite(data) {
// Feed the renderer watchdog. Recorded before the buffer-load early return
// below: a write that is queued rather than written still means the pipeline
// owes us a frame once it drains.
this._lastTerminalWriteAt = Date.now();
// If a buffer load (chunkedTerminalWrite) is in progress, queue live events
// to prevent interleaving historical buffer data with live SSE data.
// This is critical: interleaving causes cursor position chaos with Ink redraws.
@@ -4069,8 +4253,9 @@ Object.assign(CodemanApp.prototype, {
this._localEchoOverlay.suppressBufferDetection();
this._flushedOffsets?.delete(this.activeSessionId);
this._flushedTexts?.delete(this.activeSessionId);
if (flushed.count > 0) {
this.sendInput('\x7f'.repeat(flushed.count)).catch(() => {});
const flushedLength = Array.from(flushed.text || '').length;
if (flushedLength > 0) {
this.sendInput('\x7f'.repeat(flushedLength)).catch(() => {});
}
} else {
// In non-local-echo mode the TUI already owns the editable buffer. Ctrl+U
@@ -4163,11 +4348,16 @@ Object.assign(CodemanApp.prototype, {
*
* `text` is for the callers that already read the selection to decide whether
* to copy at all (the Ctrl+C gate and the right-click handler), so the read is
* not repeated. The transform is idempotent on xterm output, so an
* already-cleaned string is an acceptable argument: a CR is consumed by the
* parser as a cursor move and never stored in a cell, so the only \r the
* selection can carry is the Windows line join, and that is what makes the
* trailing scan a fixed point. Fuzzed over 300 000 realistic selections.
* not repeated.
*
* ⚠️ **Pass the RAW selection, never an already-cleaned one.** The trailing
* trim alone is a fixed point, because a CR is consumed by the parser as a
* cursor move and never stored in a cell, so the only \r the selection can
* carry is the Windows line join. The MARGIN strip is not: it takes the
* narrower of the declared width and the run every line shares, so a second
* pass over an already-stripped block takes up to `margin` columns more. A
* caller that cleans to decide whether to copy must still hand the raw text
* to copyTerminalSelection, which cleans once on its own.
*
* A COLUMN selection comes back untouched. Alt+drag makes one (xterm's
* shouldColumnSelect keys on altKey alone, and Codeman sets neither of the
@@ -4184,7 +4374,73 @@ Object.assign(CodemanApp.prototype, {
if (this.terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
const clean = window.CodemanCopySelection?.clean;
if (!clean) return raw;
return clean(raw);
const range = this._normalisedSelectionRange();
return clean(raw, {
margin: this._cliGutterColumns(),
firstLinePartial: !!range && range.start.x > 0,
});
},
/**
* xterm's selection range with its two ends in reading order.
*
* `getSelectionPosition()` reports `start` and `end` as the two ends of the
* drag, and on xterm 6.0 it already hands back the earlier one first: it
* reads `_selectionService.selectionStart`, whose getter returns the model's
* `finalSelectionStart`, and that swaps the pair for a reversed selection.
* A real upward mouse drag through chromium confirms it. The ordering here
* is a guard rather than a fix. One layer down the same model exposes the
* UNNORMALISED fields under the same two names, and a reversed pair would
* make the row window below run backwards and collapse, which would report
* no margin at all for every upward drag in a deep buffer.
*
* `terminal` names which xterm to read, defaulting to the primary pane's.
* Pane B of a split owns a second terminal and passes it, because this file's
* `this` is always the primary pane.
*/
_normalisedSelectionRange(terminal) {
const range = (terminal ?? this.terminal)?.getSelectionPosition?.();
if (!range?.start || !range?.end) return null;
const { start, end } = range;
const reversed = end.y < start.y || (end.y === start.y && end.x < start.x);
return reversed ? { start: end, end: start } : { start, end };
},
/**
* How many columns to take off a copy from one session's pane: the transcript
* gutter its CLI declares, or 0 when it declares none. `sessionId` defaults to
* the active session, and Pane B of a split passes its own, so both panes of a
* split strip the width their own CLI declares rather than Pane A's.
*
* ⚠️ Read from `window.__codemanTranscriptGutter`, the map the server derives
* from the `transcriptGutter` CAPABILITY at render time — never an id literal
* here, which is the registry's standing rule and is also what lets a CLI that
* declares a gutter later work with no change to this file.
*
* ⚠️ DECLARED rather than measured off the buffer, and two measured versions
* are why. Asking whether the pane painted spaces across the unused part of
* each row separates a TUI from a shell perfectly where it fires and never
* over-stripped, but it is a function of pane WIDTH, since that padding exists
* only while a rendered line stops short of the CLI's own layout width and
* Claude Code's prose wraps to fill it: the share of padded rows on one live
* transcript ran 44%, 6%, 6%, 7% and 87% at 123, 160, 198, 235 and 298
* columns, so the strip did nothing at any ordinary window size. Taking the
* narrowest indent on the rows around the selection instead fires at every
* width and over-strips on about 1% of them, because a file listing inside the
* transcript can be the narrowest thing on screen. A declared width does
* neither, and it reads no buffer rows at all on a path that runs on every
* Ctrl+C.
*
* A missing map means no session gets a strip, the same direction an
* unmeasured CLI takes by declaring nothing.
*/
_cliGutterColumns(sessionId) {
if (!this._copyStripMarginEnabled()) return 0;
const byMode = window.__codemanTranscriptGutter;
if (!byMode || typeof byMode !== 'object') return 0;
const mode = this.sessions?.get(sessionId ?? this.activeSessionId)?.mode;
const columns = mode ? byMode[mode] : 0;
return Number.isInteger(columns) && columns > 0 ? columns : 0;
},
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
@@ -4219,6 +4475,26 @@ Object.assign(CodemanApp.prototype, {
return ok;
},
/**
* Whether this device wants the pane's left margin off the clipboard
* (`copyStripMargin`, per-device, default ON).
*
* Read here rather than mirrored into a field, for the same reason
* `_autoCopySelectionEnabled` is: there is then no apply-path a future
* settings save can forget to call, and the toggle takes effect on the next
* selection instead of the next reload. ⚠️ The test is `!== false`, not
* `=== true`: this one defaults ON, and the desktop branch of
* getDefaultSettings returns {} and leans on the read sites for defaults, so
* a device that has never opened App Settings has no stored value at all.
*/
_copyStripMarginEnabled() {
try {
return this.loadAppSettingsFromStorage?.()?.copyStripMargin !== false;
} catch {
return true;
}
},
/**
* Auto Copy's ON/OFF, read at flush time from the CACHED settings object
* (loadAppSettingsFromStorage memoizes, so this is not a localStorage hit).
@@ -5119,11 +5395,15 @@ Object.assign(CodemanApp.prototype, {
setFontSize(size) {
this.terminal.options.fontSize = size;
document.getElementById('fontSizeDisplay').textContent = size;
this.fitAddon.fit();
this._refitAfterCellSizeChange();
localStorage.setItem('codeman-font-size', size);
// Update overlay font cache and re-render at new cell dimensions
this._localEchoOverlay?.refreshFont();
this._predictiveEcho?.refreshFont();
if (this._splitPane?.terminal) {
this._splitPane.terminal.options.fontSize = size;
this._splitPane.fitAddon?.fit();
}
},
/**
@@ -5144,11 +5424,15 @@ Object.assign(CodemanApp.prototype, {
// without needing a tab switch. The fit below still runs, so the terminal
// is never left unfitted if the wait is slow.
this._terminalFontReady = this._awaitTerminalFont().then(() => {
if (this.terminal?.options?.fontFamily === resolved) this.fitAddon?.fit();
if (this.terminal?.options?.fontFamily === resolved) this._refitAfterCellSizeChange();
});
this.fitAddon?.fit();
this._refitAfterCellSizeChange();
this._localEchoOverlay?.refreshFont();
this._predictiveEcho?.refreshFont();
if (this._splitPane?.terminal) {
this._splitPane.terminal.options.fontFamily = resolved;
this._splitPane.fitAddon?.fit();
}
},
/**
@@ -5185,9 +5469,9 @@ Object.assign(CodemanApp.prototype, {
// rasterized yet. Re-arm the wait and fit again once it settles; the fit
// below still runs, so the terminal is never left unfitted.
this._terminalFontReady = this._awaitTerminalFont().then(() => {
if (this.terminal?.options?.fontWeight === fontWeight) this.fitAddon?.fit();
if (this.terminal?.options?.fontWeight === fontWeight) this._refitAfterCellSizeChange();
});
this.fitAddon?.fit();
this._refitAfterCellSizeChange();
this._localEchoOverlay?.refreshFont();
this._predictiveEcho?.refreshFont();
for (const [, entry] of this.teammateTerminals || []) {
@@ -5200,6 +5484,11 @@ Object.assign(CodemanApp.prototype, {
/* pane not laid out yet — its own resize observer refits it */
}
}
if (this._splitPane?.terminal) {
this._splitPane.terminal.options.fontWeight = fontWeight;
this._splitPane.terminal.options.fontWeightBold = fontWeightBold;
this._splitPane.fitAddon?.fit();
}
},
loadFontSize() {
@@ -5273,19 +5562,126 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Get terminal dimensions with minimum enforcement.
* Prevents extremely narrow terminals that cause vertical text wrapping.
* The geometry this terminal would report right now, floors applied.
* Reads only — `syncTerminalGeometry()` is what makes it true of xterm.
* @returns {{cols: number, rows: number}|null}
*/
getTerminalDimensions() {
const MIN_COLS = 40;
const MIN_ROWS = 10;
const dims = this.fitAddon?.proposeDimensions();
// Never throws. `proposeDimensions()` reads a rendered element and throws
// on a terminal that has been disposed or detached mid-resize, which is an
// ordinary outcome on a tab switch — and this is called from the settle
// timer and the resize observer, where an exception takes the rest of the
// callback (the padding fit, the scroll restore, the SIGWINCH) with it.
try {
return window.CodemanTerminalGeometry.clampTerminalDimensions(this.fitAddon?.proposeDimensions());
} catch {
return null;
}
},
/**
* Fit xterm to its container and return the geometry that was APPLIED.
*
* ⚠️ THE ONLY function that may change the terminal's size, and the only
* source of the numbers sent to the server. `fitAddon.fit()` on its own is
* not enough and the gap is issue #464: fit() resizes xterm to
* `proposeDimensions()` RAW, while every server-facing path reported those
* dimensions floored at 40x10. Whenever the floor bit — a phone with the
* keyboard up routinely proposes under ten rows — the PTY was told one shape
* and xterm held another, and Claude Code then computed every repaint for a
* screen that did not exist. See the note in constants.js for what that
* renders as, and why the floor is not negotiable at either end.
*
* Three call sites each used to do their own fit-then-clamp
* (`throttledResize`, `sendResize`, KeyboardHandler's one-shot), which is
* three chances to disagree; two of them also re-read `proposeDimensions()`
* after the fit, so a container that moved in between — `_shrinkPaddingToFit`
* runs exactly there — changed the answer without touching xterm.
*
* The second resize only happens when the floor actually bites, so the
* ordinary path still reflows once, as before.
*
* @returns {{cols: number, rows: number}|null} null when the terminal cannot be measured
*/
syncTerminalGeometry() {
if (!this.fitAddon || !this.terminal) return null;
try {
this.fitAddon.fit();
} catch {
/* a disposed or unattached terminal cannot be fitted; fall through to the read */
}
const dims = this.getTerminalDimensions();
if (!dims) return null;
return {
cols: Math.max(dims.cols, MIN_COLS),
rows: Math.max(dims.rows, MIN_ROWS),
};
if (!this._resizeTerminalTo(dims)) return null;
// The floor can leave this terminal wider than the box that shows it, and
// that clips columns with no gesture to reach them (issue #464, item 4).
this._scheduleOverflowAffordanceSync();
return dims;
},
/**
* The geometry to ASK the server for, applied locally only as far as the PTY
* can follow it.
*
* Ordinarily that is all of it: `syncTerminalGeometry()`. While another
* device holds the width (`_paneWidthRefused`, set by `_onPtyGeometryReport`)
* it is not. Fitting then re-wraps xterm to the container's columns, the
* request is refused, the report puts the PTY's columns back, and the whole
* buffer re-wraps twice per ask, with the viewport pointing at a different
* part of the scrollback in between. The mobile retry asks every 30 seconds,
* so that happened on a timer for as long as the refusal lasted. So the
* columns stay at the PTY's (the #464 invariant: the browser never draws at
* a width the PTY does not have), the rows follow the container (they are
* never adopted, see reconcilePtyGeometry), and the container's columns go
* out as the request. An accepted request is adopted by the report.
*
* @returns {{cols: number, rows: number}|null} the geometry to request
*/
_geometryForResizeRequest() {
if (!this._paneWidthRefused) return this.syncTerminalGeometry();
const wanted = this.getTerminalDimensions();
if (!wanted || !this.terminal) return null;
if (!this._resizeTerminalTo({ cols: this.terminal.cols, rows: wanted.rows })) return null;
this._scheduleOverflowAffordanceSync();
return wanted;
},
/**
* Re-measure after something changed the CELL size, and tell the server.
*
* ⚠️ A font change is a geometry change. Bigger glyphs mean fewer columns in
* the same box, and the PTY is drawing for a column count nobody updated:
* `setFontSize`, `setFontFamily` and `setFontWeight` all refitted the terminal
* and sent NOTHING, so raising the font on a phone could drop the browser
* below the columns the CLI was still wrapping at until some unrelated resize
* event happened along. That is issue #464 reached through the font menu.
*
* With no session there is no PTY to tell, and a session detached into its own
* window is not this terminal's to resize — `sendResize` makes that call, and
* fits as its first synchronous step, so this never fits twice.
*/
_refitAfterCellSizeChange() {
if (this.activeSessionId) {
this.sendResize(this.activeSessionId)?.catch?.(() => {});
return;
}
this.syncTerminalGeometry();
},
/**
* Make xterm exactly `dims`. Idempotent, and never throws at a caller — a
* terminal disposed mid-resize is an ordinary outcome on a tab switch.
* @returns {{cols: number, rows: number}|null} the applied geometry
*/
_resizeTerminalTo(dims) {
if (!this.terminal || !dims) return null;
if (this.terminal.cols === dims.cols && this.terminal.rows === dims.rows) return dims;
try {
this.terminal.resize(dims.cols, dims.rows);
return dims;
} catch {
return null;
}
},
/**
@@ -5295,21 +5691,26 @@ Object.assign(CodemanApp.prototype, {
* @returns {Promise<boolean>} Whether dimensions changed from the last send
*/
async sendResize(sessionId, options = {}) {
// Fit terminal to container before reading dimensions — ensures local
// terminal size matches what we report to the server PTY.
if (this.fitAddon) this.fitAddon.fit();
// One PTY cannot hold two sizes. A detached session is owned by its own
// window, and the dashboard's terminal is narrower than that window because
// the session rail takes width the popup does not have — so both sizing it
// makes the CLI draw frames that fit neither, which garbles the popup. The
// dashboard yields; the solo window sizes what it alone displays.
// (_maybeRefetchFullHistory already stands aside for the same reason.)
// ⚠️ AFTER the fit, never before: the local reflow keeps the dashboard's own
// xterm right, and only the SERVER write is the dashboard's to withhold —
// the mobile-keyboard guard below draws exactly this line. tab-rail-resize
// performs its one settle-time refit through this call and has no fallback.
// ⚠️ BEFORE the fit, never after. This used to fit first and withhold only
// the server write, on the reasoning that the local reflow keeps the
// dashboard's own xterm right. It does not: it leaves this xterm at a shape
// the PTY was never told about, which is the #464 divergence exactly — and
// the popup that DOES own the PTY is drawing for its own width, so the
// dashboard's reflow is to a size nothing is rendering for. Withholding the
// resize means withholding all of it. tab-rail-resize performs its one
// settle-time refit through this call and has no fallback, which is correct:
// a pane it does not own is not its to refit either.
if (!this.isSoloWindow && this.detachedSessions?.has(sessionId)) return false;
const dims = this.getTerminalDimensions();
// Fit, floor, and apply in one step so the numbers below are the numbers
// xterm is actually holding (or, while another device holds the width,
// the numbers this container would hold if the PTY followed).
const dims = this._geometryForResizeRequest();
if (!dims) return false;
// Did the dimensions actually change since the last resize we sent? Callers
// use this to skip work (e.g. the post-resize TUI-redraw settle) when no
@@ -5342,14 +5743,150 @@ Object.assign(CodemanApp.prototype, {
}
const body = { ...dims, viewportType };
if (options.force) body.force = true;
await fetch(`/api/sessions/${sessionId}/resize`, {
const res = await fetch(`/api/sessions/${sessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
// Same report the WS path gets as a {"t":"zc"} frame. An older server
// answers `{}`, which reconciles to a no-op rather than throwing.
try {
const applied = (await res.json())?.data ?? {};
this._onPtyGeometryReport(sessionId, applied.cols, applied.rows);
} catch {
/* a body that is not JSON tells us nothing about the PTY; keep our own geometry */
}
return changed;
},
/**
* Adopt the geometry the server says the PTY actually has.
*
* ⚠️ The server is the authority and this client is not always obeyed.
* `Session.resize` declines a small-viewport request outright while a desktop
* connection holds an active sizing claim, and says nothing — resize was
* write-only until #464. A terminal that keeps its own shape after such a
* refusal does not render "too narrow", it renders GARBLED: Claude Code wraps
* its frame at the width it was told and walks the cursor up that many rows,
* so a mismatch makes its erase count come out short and each repaint paints
* over rows it never cleared. Measured against a real xterm — a PTY believing
* 120 columns against a 62-column terminal draws every wrapped line twice.
*
* Adopting can leave the pane wider than the viewport, and the container is
* `overflow: hidden`, so `.term-overflows-x` grants horizontal reach for exactly
* as long as the mismatch lasts. Correct-and-reachable beats correct-and-
* clipped beats garbled; nothing here is worth trapping content behind.
*
* Self-resolving: `_startMobileResizeRetry` re-sends this device's dimensions
* on a timer, so the pane comes back to this screen once the desktop goes
* idle, and the next report clears the class and the notice with it.
*/
_onPtyGeometryReport(sessionId, cols, rows) {
if (!this.terminal || sessionId !== this.activeSessionId) return;
const local = { cols: this.terminal.cols, rows: this.terminal.rows };
const { adopt } = window.CodemanTerminalGeometry.reconcilePtyGeometry(local, { cols, rows });
// Columns only, and the local row count is kept — see reconcilePtyGeometry
// for why adopting rows put the CLI's input line below the container with
// nothing able to scroll to it.
if (adopt && this._resizeTerminalTo({ cols, rows: local.rows })) {
// The numbers we would report next are now the PTY's, not the container's:
// without this the dedupe in throttledResize/sendResize compares against a
// request that was refused and suppresses the retry that recovers the pane.
this._lastResizeDims = { cols, rows: local.rows };
}
// Is the PTY at a width this container did not ask for? Compared against
// what we WOULD request, not against what the terminal currently holds:
// once adopted those two are equal, so the second question answers itself
// false and the condition would look resolved while it is still true.
// The floor widens this terminal too, and that is the reader's own font
// setting rather than another device — hence the comparison, not `>`.
const wanted = this.getTerminalDimensions();
this._paneWidthRefused = !!wanted && Number.isFinite(cols) && cols !== wanted.cols;
this._scheduleOverflowAffordanceSync();
},
/**
* Measure on the NEXT frame, coalesced.
*
* `terminal.resize()` updates the buffer synchronously but the screen element
* takes its new width with the render, so measuring in the same tick reads
* the size the terminal just left. Coalesced because a settling container
* fires several resizes and only the last one's measurement is the truth.
*/
_scheduleOverflowAffordanceSync() {
if (typeof requestAnimationFrame !== 'function') {
this._syncTerminalOverflowAffordance();
return;
}
if (this._overflowAffordanceFrame) return;
this._overflowAffordanceFrame = requestAnimationFrame(() => {
this._overflowAffordanceFrame = null;
this._syncTerminalOverflowAffordance();
});
},
/**
* Let the reader reach a pane wider than the box that shows it.
*
* ⚠️ Keyed on what actually does not FIT, not on a PTY mismatch. Two
* different causes put the terminal wider than its container and both leave
* columns unreachable behind `.terminal-container`'s clip:
*
* - another device holds the sizing claim, so this terminal adopts a width
* it did not ask for; and
* - the 40-column floor. On a 360px phone, font 18 applies 40 columns and
* paints 433px, and font 24 paints 578px — 218px, 38% of the pane, with no
* gesture that could reach it. `increaseFontSize` goes to 24 and applies
* immediately, so that is two taps away, and the PTY agrees with the
* terminal throughout: a mismatch test would never fire.
*
* Measured rather than derived from cell arithmetic, because the cell width
* is fractional and the container's padding is not ours to assume. One pixel
* of slack keeps sub-pixel rounding from flapping the class.
*/
_syncTerminalOverflowAffordance() {
// ⚠️ Nothing in here may throw. It runs off every geometry change, which is
// the resize path, and the affordance is cosmetic: a terminal that cannot
// be measured — disposed mid-resize, or a harness with no real DOM — must
// lose the scroll affordance, never the resize.
let container = null;
let overflows = false;
try {
container = document.getElementById('terminalContainer');
const screen = container?.querySelector('.xterm-screen');
if (container && screen) {
overflows = screen.getBoundingClientRect().width - container.clientWidth > 1;
}
} catch {
/* unmeasurable; fall through with the affordance off */
}
container?.classList.toggle('term-overflows-x', overflows);
// The notice tells the reader to scroll sideways, so it is only true advice
// once there is something to scroll. A wide PTY on a screen wide enough to
// show it needs no explanation and gets none.
if (!overflows || !this._paneWidthRefused) {
this._paneOwnedElsewhere = false;
return;
}
this._notePaneOwnedElsewhere();
},
/**
* Say, once, that this pane's width belongs to another device.
*
* Once per transition, not per report: reports arrive on every resize, and a
* toast that repeats is noise about a situation already on screen. Silent
* when it resolves — the pane simply reflows back to this screen.
*/
_notePaneOwnedElsewhere() {
if (this._paneOwnedElsewhere) return;
this._paneOwnedElsewhere = true;
// 53 characters: measured at one line on a 430px phone. The longer
// wording wrapped to two, which is a lot of the terminal to cover for a
// notice about a condition that resolves itself.
this.showToast('Another device is setting the width — scroll sideways', 'info');
},
/**
* Send input to the active session.
* @param {string} input - Text to send (include \r for Enter)
@@ -5420,6 +5957,13 @@ Object.assign(CodemanApp.prototype, {
}
}
}
if (this._splitPane?.terminal) {
this._splitPane.terminal.options.minimumContrastRatio = minimumContrastRatio;
this._splitPane.terminal.options.theme = { ...theme };
try {
this._splitPane.terminal.refresh(0, this._splitPane.terminal.rows - 1);
} catch {}
}
},
});
+20 -3
View File
@@ -128,6 +128,18 @@ const APP_VERSION = (() => {
const LOCAL_CLONE_ADMIN_ONLY =
'Cloning from a local path is admin-only in multi-user mode. Use a repository URL instead.';
/**
* Whether a clone or preflight must run with git's credential helpers cleared:
* a non-admin in multi-user mode. Every user's git runs as the one server
* account, so its helpers (the Docker image's opt-in `gh`/`az` ones, or any
* `gh auth setup-git`) would otherwise read a private repository with the
* signed-in admin's credentials, the same boundary the local-transport rule
* above guards. Admins and single-user mode keep the account's own helpers.
*/
export function cloneWithoutCredentialHelpers(req: FastifyRequest): boolean {
return isMultiUserMode() && !isAdmin(req);
}
/**
* The one line of git's stderr worth appending to an error message.
*
@@ -475,7 +487,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
if (!isGitAvailable()) {
return { success: true, data: { parse: parsed, gitAvailable: false } };
}
const remote = await probeGitRemote(parsed.repository);
const remote = await probeGitRemote(parsed.repository, undefined, {
withoutCredentialHelpers: cloneWithoutCredentialHelpers(req),
});
return { success: true, data: { parse: parsed, remote, gitAvailable: true } };
}
);
@@ -491,8 +505,10 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
* request died mid-clone still sees the case appear over SSE when git finishes.
*
* Deliberately NOT admin-gated in multi-user mode: unlike `/api/cases/link`,
* this writes only inside the caller's own `resolveCasesDir`. The one exception
* is a `local`-transport source, which would read through that boundary.
* this writes only inside the caller's own `resolveCasesDir`. Two things would
* otherwise read through that boundary: a `local`-transport source (refused for
* non-admins) and the server account's git credential helpers, which every user
* shares (cleared for non-admins, see `cloneWithoutCredentialHelpers`).
*
* Repository contents win over scaffolding: an existing CLAUDE.md is left
* alone, and hooks are MERGED into whatever `.claude/settings.local.json` the
@@ -562,6 +578,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const clone = await cloneRepository({
repository: parsed.repository,
destination: casePath,
withoutCredentialHelpers: cloneWithoutCredentialHelpers(req),
...(ref ? { ref } : {}),
...(shallow ? { shallow: true } : {}),
});
+23 -6
View File
@@ -176,6 +176,12 @@ export function registerHookEventRoutes(
// identity beyond the shared per-instance secret, so a prompt claimed for a
// session that can never show one must not create an answerable item).
let approvalId: string | undefined;
// Set when the item opened ALREADY acknowledged, which today means the session is
// watching work it started itself. It rides the broadcast so a live page declines to
// arm the alert (a reloading page learns the same thing from `acknowledgedAt` when it
// seeds from /api/approvals), and it suppresses the push: an alert nobody can answer
// is worth even less on a phone than in a tab.
let acknowledgedReason: string | undefined;
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
if (approvalKind) {
@@ -194,6 +200,10 @@ export function registerHookEventRoutes(
toolSummary: typeof toolSummary === 'string' ? toolSummary : undefined,
message: typeof safeData.message === 'string' ? safeData.message : undefined,
cwd: typeof safeData.cwd === 'string' ? safeData.cwd : undefined,
// What the pane says is still running in the background. An idle prompt from a
// session that is watching its own work opens acknowledged, so it never arms an
// alert nobody can answer; notePrompt() carries the whole reasoning.
watching: session.watching,
// Visible tmux frame first (it IS the dialog); raw byte-buffer tail as
// the fallback for direct-PTY sessions and the no-op test mux.
capture: () => {
@@ -203,6 +213,7 @@ export function registerHookEventRoutes(
},
});
approvalId = item.id;
acknowledgedReason = item.acknowledgedReason;
} else if (APPROVAL_RESOLVING_EVENTS.has(event)) {
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
}
@@ -213,6 +224,7 @@ export function registerHookEventRoutes(
timestamp: Date.now(),
...safeData,
...(approvalId && { approvalId }),
...(acknowledgedReason && { acknowledgedReason }),
});
// Full state ride-along, same shape as the working/idle handlers: the home
// screens rank the blocked group on lastActivityAt, and without this a
@@ -224,12 +236,17 @@ export function registerHookEventRoutes(
// on approvalId, and the answer route refuses keystrokes for dsh dialogs
// (third-party TUI, unmeasured contract) — so a dsh push stays a plain
// notification instead of offering buttons whose answer would be refused.
ctx.sendPushNotifications(`hook:${event}`, {
sessionId,
sessionName,
...safeData,
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
});
// Nothing to push for a prompt that opened acknowledged: the agent is waiting for its
// own monitor or backgrounded shell, and a phone buzzing about it is the same false
// alarm as the tab alert, delivered where it is hardest to ignore.
if (!acknowledgedReason) {
ctx.sendPushNotifications(`hook:${event}`, {
sessionId,
sessionName,
...safeData,
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
});
}
// Track in run summary. `prompt_submitted` fires on EVERY prompt of every
// Claude pane; only the ones where the conversation actually moved (a /clear
+16 -1
View File
@@ -1583,6 +1583,12 @@ export function registerSessionRoutes(
name: session.name,
mode: session.mode,
});
// Persist, not just broadcast. Starting a command in the pane changes
// `pid` and retracts any `paneExit` (Ark0N/Codeman#446), and the pane-exit
// watcher cannot write that retraction to disk for us: its next tick finds
// the in-memory field already cleared, reports no change and persists
// nothing, so `state.json` would keep saying the agent had exited.
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionInteractive, { id });
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
@@ -1612,6 +1618,9 @@ export function registerSessionRoutes(
name: session.name,
mode: 'shell',
});
// Persist for the same reason /interactive does: a started pane retracts
// `paneExit`, and the watcher's next tick cannot write that retraction.
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionInteractive, { id, mode: 'shell' });
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
return {};
@@ -2115,7 +2124,13 @@ export function registerSessionRoutes(
const session = findSessionOrFail(ctx, id, req);
session.resize(cols, rows, { viewportType, force });
return {};
// Answer with the geometry the PTY ACTUALLY holds, which is not always the
// one asked for: `Session.resize` declines small-viewport requests while a
// desktop connection holds an active sizing claim. A browser terminal left
// at a shape the PTY refused renders garbled output, not merely wrong-sized
// output, so the client adopts this (issue #464). A session with no pane
// reports nothing rather than the constructor defaults — see `ptyGeometry`.
return session.ptyGeometry ?? {};
});
// ========== Get Last Response (from transcript JSONL) ==========
+16
View File
@@ -22,6 +22,9 @@
* {"t":"c"} — clear terminal
* {"t":"r"} — needs refresh (reload buffer)
* {"t":"ia","seq":N} — input ACK (echoes the seq of an applied/deduped input frame)
* {"t":"zc","c":N,"r":N} — resize confirm: the geometry the PTY now holds, which
* is NOT always the one requested (see Session.resize
* arbitration). Clients adopt it — issue #464.
* Client -> Server:
* {"t":"i","d":"...","seq":N,"cid":"..."} — input (keystroke or paste). seq+cid are
* optional reliable-delivery tags: the server applies each
@@ -240,6 +243,19 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
}
const force = msg.f === true;
session.resize(msg.c, msg.r, { viewportType, force });
// Report the geometry that actually took. Resize used to be
// write-only, so a client whose request was declined by the
// arbitration above — or floored, or overridden by another device
// — had no way to find out, and went on rendering a CLI's repaints
// against a screen shape that did not exist (issue #464). Sent
// unconditionally: it is ~30 bytes on a debounced, rare message,
// and always-send means the client needs no "did it take?" state.
// A session with no pane sends nothing at all: its `_ptyCols`/
// `_ptyRows` are constructor defaults no process was ever told.
const applied = session.ptyGeometry;
if (applied && socket.readyState === 1) {
socket.send(`{"t":"zc","c":${applied.cols},"r":${applied.rows}}`);
}
}
} catch {
// Ignore malformed messages
+90 -5
View File
@@ -463,6 +463,11 @@ export class WebServer extends EventEmitter {
this.mux.on('statsUpdated', (sessions) => {
this.broadcast(SseEvent.MuxStatsUpdated, sessions);
});
// Ark0N/Codeman#446 — a pane read finished. Internal only: the field reaches
// the browser on `session:updated`, and no SSE event was added for it.
this.mux.on('paneExitsUpdated', () => {
this.applyPaneExits();
});
// COD-108 — remote-session auto-reconnect. The TmuxManager watcher detects a
// dead remote pane and emits `remoteSessionDropped`; the session owner (here)
@@ -1581,9 +1586,17 @@ export class WebServer extends EventEmitter {
// the /session/:id URL path; this global is a belt-and-suspenders fallback.
// The id is gated to JSON + <-escaped so it can't break out of the inline
// <script> (ids are UUIDs in practice, but defense-in-depth is cheap).
//
// Every `</head>` injection below passes a replacer FUNCTION, never a
// replacement STRING: `String.replace` interprets `$&`, `$'`, `` $` `` and
// `$<n>` inside a string replacement, so a payload carrying `$'` would splice
// the rest of the document (the whole <body>) into the inline script, past
// any escaping applied to the payload itself. The custom-model list below
// carries a user-settable `label` (clis.json), which is the site that made
// this real; the others follow the same rule so the class of bug stays out.
if (soloSessionId) {
const safeId = JSON.stringify(soloSessionId).replace(/</g, '\\u003c');
html = html.replace('</head>', `<script>window.__CODEMAN_SOLO__=${safeId};</script>\n</head>`);
html = html.replace('</head>', () => `<script>window.__CODEMAN_SOLO__=${safeId};</script>\n</head>`);
}
// Gesture-control overlay (Phase 5): dashboard only (not solo popups, which
// have no tab strip). `CODEMAN_GESTURE=1` makes the feature *available* on
@@ -1655,7 +1668,7 @@ export class WebServer extends EventEmitter {
};
html = html.replace(
'</head>',
`<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>`
() => `<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>`
);
// Which run modes the Run-menu picker (docs/custom-model-endpoints-plan.md) may
// generate an entry for: read generically off the registry's `capabilities`
@@ -1672,16 +1685,40 @@ export class WebServer extends EventEmitter {
const customModelClisJson = escapeScriptJson(JSON.stringify(customModelClis));
html = html.replace(
'</head>',
`<script>window.__codemanCustomModelClis=${customModelClisJson};</script>\n</head>`
() => `<script>window.__codemanCustomModelClis=${customModelClisJson};</script>\n</head>`
);
}
// How many columns each run mode indents its transcript by, so a copy can drop
// that much. Read off `capabilities` like the payload above and never as an id
// list here, so a CLI that declares a gutter later needs no frontend change.
// Ids and small integers only, no user-settable strings, so JSON.stringify
// alone is enough (same reasoning as __codemanCliAvailable's booleans).
//
// ⚠️ Outside the `if (!soloSessionId)` block above, unlike every other payload
// here: a detached session window (`/session/:id`) runs a terminal, so Ctrl+C
// copies there, and an absent map reads as "no session gets a strip". The
// toggle used to work in the main window and do nothing in the popup on the
// same device. This needs no availability probe, so it costs a solo window
// nothing that the run menu's own payloads would have cost it.
const gutterClis: Record<string, number> = {};
for (const entry of enabledClis()) {
const columns = entry.capabilities.transcriptGutter;
if (typeof columns === 'number') gutterClis[entry.id] = columns;
}
html = html.replace(
'</head>',
() => `<script>window.__codemanTranscriptGutter=${JSON.stringify(gutterClis)};</script>\n</head>`
);
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
if (settings.gestureControlEnabled === true) {
const v = this.gestureBundleVersion();
// Relative src so the injected `<base href>` resolves it under the mount
// prefix (a root-absolute `/gesture/...` would escape a sub-path mount).
html = html.replace('</head>', `<script type="module" src="gesture/gesture-codeman.js${v}"></script>\n</head>`);
html = html.replace(
'</head>',
() => `<script type="module" src="gesture/gesture-codeman.js${v}"></script>\n</head>`
);
}
}
return html;
@@ -2444,6 +2481,36 @@ export class WebServer extends EventEmitter {
this.sse.broadcastSessionStateDebounced(sessionId);
}
/**
* Fold the latest pane readings into the sessions they belong to
* (Ark0N/Codeman#446). A reading that changes a session's answer persists the
* record and pushes a `session:updated`, which is how the tab learns; a read
* that repeats what the last one said costs nothing.
*
* The answer is pulled per session from the mux rather than taken off a
* broadcast payload. The mux reports the RAW pane reading, which for a remote
* or docker session is the death of an ssh client or a `docker exec` rather
* than of the agent, so it must not travel to a browser at all;
* `Session.setPaneExit()` is where that scoping is applied.
*
* Nothing here touches `status` or `pid`. `status: 'error'` belongs to the
* PTY-exit breaker and makes the browser offer a restart, and a null `pid` is
* what makes the browser re-attach and launch a fresh CLI.
*/
private applyPaneExits(): void {
const getPaneExit = this.mux.getPaneExit?.bind(this.mux);
if (!getPaneExit) return;
for (const session of this.sessions.values()) {
const muxName = session.muxName;
// No pane, so nothing to report — and `setPaneExit()` would force UNKNOWN
// for such a session anyway.
if (!muxName) continue;
if (!session.setPaneExit(getPaneExit(muxName))) continue;
this.persistSessionState(session);
this.broadcastSessionStateDebounced(session.id);
}
}
// ========== Web Push ==========
/** Map SSE event names to push notification payloads */
@@ -3325,6 +3392,15 @@ export class WebServer extends EventEmitter {
// the conversation the CLI was on when the server stopped, which is
// what a re-attach must point the viewer at instead of the launch id.
claudeSessionChain: savedState?.claudeSessionChain,
// What the previous run last observed of this pane's agent. Carried
// over so the first persist after boot does not blank a record that
// says the agent exited; the attach below drops it, and the
// pane-exit watcher's own tick replaces it with a first-hand
// reading (not the stats collector — see `startPaneExitWatcher`).
paneExit: savedState?.paneExit,
// A record rebuilt from the socket has no provenance, so its
// apparent locality is a guess (see `MuxSession.discovered`).
discoveredMuxSession: muxSession.discovered,
// The pane's last output, previous run's value. Without it every
// restart restamped all sessions "now" (constructor + the attach
// repaint within the same second), flattening the home screens'
@@ -3534,6 +3610,15 @@ export class WebServer extends EventEmitter {
(this.mux as { startMouseModeSync: (ms?: number) => void }).startMouseModeSync();
}
// Ark0N/Codeman#446 — poll every pane for an exited agent. Always start,
// even with no sessions, for the same reason as the two watchers around
// it: sessions arrive later. Deliberately NOT folded into the stats
// collector above, which the browser arms and disarms with the Monitor
// panel and which boot skips entirely when nothing was recovered.
if ('startPaneExitWatcher' in this.mux) {
(this.mux as { startPaneExitWatcher: (ms?: number) => void }).startPaneExitWatcher();
}
// COD-108 — start the remote-session auto-reconnect watcher (tmux only).
// Always-on (D3) with a `remoteAutoReconnect` kill-switch the watcher reads
// each tick. Start even with no sessions — remote sessions may arrive later.
+14
View File
@@ -43,6 +43,7 @@ export interface SessionListenerRefs {
exit: (code: number | null) => void;
working: () => void;
idle: () => void;
watchingChanged: () => void;
taskCreated: (task: BackgroundTask) => void;
taskUpdated: (task: BackgroundTask) => void;
taskCompleted: (task: BackgroundTask) => void;
@@ -263,6 +264,17 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
}
},
/**
* Pushes the session state when `Session.watching` changes without the status
* changing with it. That is the badge appearing or, more often, going away: a CLI can
* finish its background work without taking a turn, so the row is idle before and
* after and no other broadcast fires. There is no SSE event of its own, because the
* badge reads off the session payload every surface already has.
*/
watchingChanged: () => {
deps.broadcastSessionStateDebounced(session.id);
},
// ─── Background Task Events ──────────────────────────────
/** Broadcasts `task:created` — new background task discovered */
@@ -495,6 +507,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
session.on('exit', refs.exit);
session.on('working', refs.working);
session.on('idle', refs.idle);
session.on('watchingChanged', refs.watchingChanged);
session.on('taskCreated', refs.taskCreated);
session.on('taskUpdated', refs.taskUpdated);
session.on('taskCompleted', refs.taskCompleted);
@@ -531,6 +544,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
session.off('exit', refs.exit);
session.off('working', refs.working);
session.off('idle', refs.idle);
session.off('watchingChanged', refs.watchingChanged);
session.off('taskCreated', refs.taskCreated);
session.off('taskUpdated', refs.taskUpdated);
session.off('taskCompleted', refs.taskCompleted);
@@ -20,11 +20,15 @@ import { fileURLToPath } from 'node:url';
import {
agentImageBuildArgPairs as mjsPairs,
agentImageNpmPackages as mjsPackages,
GIT_HOST_CLI_BUILD_ARGS as mjsGitHostArgs,
gitHostCliBuildArgPairs as mjsGitHostPairs,
} from '../scripts/lib/cli-catalog.mjs';
import {
agentImageBuildArgPairs as tsPairs,
agentImageBuildArgs,
agentImageNpmPackages as tsPackages,
GIT_HOST_CLI_BUILD_ARGS as tsGitHostArgs,
gitHostCliBuildArgPairs as tsGitHostPairs,
} from '../src/docker-hosts.js';
const CATALOG = JSON.parse(readFileSync(fileURLToPath(new URL('../config/clis.stock.json', import.meta.url)), 'utf-8'));
@@ -96,3 +100,48 @@ describe('agent-image build args: the .mjs and the TS mirror agree', () => {
expect(extract(tsSource, 'docker-hosts.ts')).toBe(extract(mjsSource, 'cli-catalog.mjs'));
});
});
describe('optional gh / az in the agent image: both producers pass the same switches', () => {
const ENV_GH = 'CODEMAN_AGENT_IMAGE_INSTALL_GH';
const ENV_AZ = 'CODEMAN_AGENT_IMAGE_INSTALL_AZ';
it('map the same environment variables to the same Dockerfile ARGs', () => {
expect(tsGitHostArgs).toEqual(mjsGitHostArgs);
expect(tsGitHostArgs.map(([, arg]) => arg)).toEqual(['CODEMAN_INSTALL_GH', 'CODEMAN_INSTALL_AZ']);
});
it('agree for every combination, and an unset or empty variable adds nothing', () => {
for (const gh of [undefined, '', '0', '1']) {
for (const az of [undefined, '', '0', '1']) {
const env: NodeJS.ProcessEnv = {};
if (gh !== undefined) env[ENV_GH] = gh;
if (az !== undefined) env[ENV_AZ] = az;
const expected: Array<[string, string]> = [];
if (gh) expected.push(['CODEMAN_INSTALL_GH', gh]);
if (az) expected.push(['CODEMAN_INSTALL_AZ', az]);
expect(tsGitHostPairs(env)).toEqual(expected);
expect(mjsGitHostPairs(env)).toEqual(expected);
expect(tsPairs(env)).toEqual(mjsPairs(CATALOG, env));
}
}
});
it('keeps the default argv unchanged when neither variable is set', () => {
expect(tsPairs({})).toEqual([['CLI_NPM_PACKAGES', tsPackages().join(' ')]]);
});
it('refuses anything but 0 or 1 on both sides, naming the variable', () => {
for (const bad of ['yes', 'true', '2', ' 1', '0 && echo']) {
expect(() => tsGitHostPairs({ [ENV_AZ]: bad })).toThrow(new RegExp(ENV_AZ));
expect(() => mjsGitHostPairs({ [ENV_AZ]: bad })).toThrow(new RegExp(ENV_AZ));
}
});
it('both Dockerfiles declare the switches, defaulting to OFF (opt-in)', () => {
for (const file of ['../docker/agent.Dockerfile', '../docker/server.Dockerfile']) {
const dockerfile = readFileSync(fileURLToPath(new URL(file, import.meta.url)), 'utf-8');
expect(dockerfile, file).toMatch(/^ARG CODEMAN_INSTALL_GH=0$/m);
expect(dockerfile, file).toMatch(/^ARG CODEMAN_INSTALL_AZ=0$/m);
}
});
});
+4 -1
View File
@@ -99,7 +99,10 @@ describe('App Settings modal structure', () => {
for (const [, attrs, body] of previewed) {
const kind = attrs.match(/data-preview="([a-z]+)"/)?.[1];
expect(['header', 'panel', 'toolbar', 'float']).toContain(kind);
expect(attrs, `chip ${body} needs a preview order`).toMatch(/data-preview-order="\d+"/);
// A decimal (e.g. "11.5") is allowed — Split sits between Multi-monitor
// (11) and Ultracode Agents (12) in the real header, and Number()
// parses it fine for the preview's own sort.
expect(attrs, `chip ${body} needs a preview order`).toMatch(/data-preview-order="\d+(\.\d+)?"/);
// A text token replaces the icon for readouts (plan usage, CPU, font size).
const hasIcon = body.includes('class="set-chip-ico') || attrs.includes('data-preview-text=');
expect(hasIcon, `chip ${body} has nothing to render in the preview`).toBe(true);
+52
View File
@@ -330,6 +330,58 @@ describe('ApprovalInbox', () => {
expect(inbox.getById(second.id)).toBeDefined();
});
describe('a session watching its own background work', () => {
it('opens its idle prompt already acknowledged, and says why', () => {
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching: '1 monitor' });
expect(item.acknowledgedAt).toBe(item.createdAt);
expect(item.acknowledgedReason).toBe('watching 1 monitor');
});
it('keeps the prompt pending and answerable: only its alert is spent', () => {
// Acknowledging rather than skipping creation is what makes a wrong label cheap.
// The prompt is real either way, and this way it is still in the drawer, still
// answerable and still Read My Mind context.
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching: '2 shells' });
expect(inbox.listPending().map((i) => i.id)).toEqual([item.id]);
expect(inbox.getForSession('s1')?.id).toBe(item.id);
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
});
it('never pre-acknowledges a dialog that blocks the agent', () => {
// A permission or question dialog blocks the turn whatever else the agent started,
// so watching says nothing about whether a human is needed.
for (const kind of ['permission', 'question'] as const) {
const item = inbox.notePrompt({ sessionId: `s-${kind}`, sessionName: 'w1', kind, watching: '1 monitor' });
expect(item.acknowledgedAt).toBeUndefined();
expect(item.acknowledgedReason).toBeUndefined();
}
});
it('leaves an ordinary idle prompt alone', () => {
for (const watching of [undefined, null, '']) {
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching });
expect(item.acknowledgedAt).toBeUndefined();
}
});
it('re-arms by itself once the background work is over', () => {
// The next prompt supersedes this one and is built fresh, so nothing has to
// remember to clear the flag when the monitor ends.
const watched = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching: '1 monitor' });
expect(watched.acknowledgedAt).toBeDefined();
const after = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle' });
expect(after.id).not.toBe(watched.id);
expect(after.acknowledgedAt).toBeUndefined();
expect(after.acknowledgedReason).toBeUndefined();
});
it('cannot be acknowledged a second time by a human opening the session', () => {
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching: '1 monitor' });
expect(inbox.acknowledge('s1')).toBeUndefined();
expect(inbox.getById(item.id)?.acknowledgedReason).toBe('watching 1 monitor');
});
});
describe('verifyStillAnswerable', () => {
it('resolves the item and refuses when a parsed dialog left the screen', () => {
const { resolved } = collect(inbox);
+37 -13
View File
@@ -136,9 +136,10 @@ async function stubTerminalDynamic(
/**
* Answer every fetch with the geometry the client itself is asking for, read
* live from the page. That is the clamp signature: `getTerminalDimensions()`
* floors at 40x10 while `fitAddon.fit()` does not, so a small enough viewport
* makes the pane permanently bigger than the terminal at a size the client
* requested itself.
* floors at 40x10, and since #464 `syncTerminalGeometry()` applies that floor
* to xterm too — so at a small enough viewport the pane, the report and the
* terminal all agree on the floored size, which is the case the equality guard
* is left covering.
*/
async function stubTerminalAtRequestedSize(page: Page, counter: { n: number; urls: string[] }) {
await page.route('**/api/sessions/*/terminal*', async (route) => {
@@ -498,13 +499,22 @@ describe('a capture bigger than the terminal', () => {
}, 60_000);
it('does not replay a pane already at the size the client asked for', async () => {
// `getTerminalDimensions()` floors at 40x10 while `fitAddon.fit()` does
// not, so a viewport this small leaves the terminal shorter than the size
// the client itself requests, and the pane obligingly draws at the floored
// size. The captured height then exceeds the terminal's forever. A replay
// cannot converge, because it re-requests the same floored size and
// captures the same frame, so without the equality guard this retries on
// every tab switch for the life of the page.
// ⚠️ The premise of this case CHANGED with issue #464, and the old one can
// never hold again. It used to be the clamp: `getTerminalDimensions()`
// floors at 40x10 while `fitAddon.fit()` did not, so a viewport this small
// left the terminal shorter than the size the client itself requested, the
// pane drew at the floored size, and the captured height exceeded the
// terminal's forever — a replay that re-requested the same floored size and
// captured the same frame, on every tab switch, for the life of the page.
//
// `syncTerminalGeometry()` now applies the floor to xterm as well, so the
// browser terminal IS the size it reports and that divergence is gone at
// the source. The case survives on its own terms — a pane already drawing
// at the requested size must not be replayed, because the retry would
// capture the identical frame — and its premise is now the #464 invariant
// itself, asserted below: the floored report and the terminal agree. That
// is a stronger guard than the old one, since the clamp coming back would
// fail it here rather than silently restoring the replay loop.
context = await browser.newContext({ viewport: { width: 320, height: 200 } });
page = await context.newPage();
const sessionId = await openSession(page);
@@ -514,8 +524,9 @@ describe('a capture bigger than the terminal', () => {
await consumeFullHistory(page, sessionId, fetches);
await select(page, sessionId, { forceReload: true });
// The premise: the floor really does bind here. Without this the case
// would pass on any viewport, proving nothing.
// The premise: the floor really does bind at this viewport — otherwise the
// case would pass on any viewport, proving nothing — AND the terminal holds
// exactly what it reports, which is what stops the old replay loop.
const requested = await page.evaluate(
() =>
(
@@ -523,7 +534,20 @@ describe('a capture bigger than the terminal', () => {
).app.getTerminalDimensions?.() ?? null
);
expect(requested).not.toBeNull();
expect(requested!.rows).toBeGreaterThan(await terminalRows(page));
const proposed = await page.evaluate(
() =>
(
window as unknown as { app: { fitAddon?: { proposeDimensions?: () => { cols: number; rows: number } } } }
).app.fitAddon?.proposeDimensions?.() ?? null
);
expect(proposed, 'the terminal could not be measured').not.toBeNull();
expect(
proposed!.rows < requested!.rows || proposed!.cols < requested!.cols,
`the floor must bind at this viewport, or the case proves nothing (proposed ${proposed!.cols}x${proposed!.rows}, reported ${requested!.cols}x${requested!.rows})`
).toBe(true);
// The #464 invariant: what the client reports is what the terminal holds.
expect(requested!.rows).toBe(await terminalRows(page));
expect(requested!.cols).toBe(await terminalCols(page));
expect(fetches.n).toBe(1);
+43
View File
@@ -122,6 +122,49 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
expect(compileVersionRegex(src), `${entry.id} declares a workingLine the guard refuses`).not.toBeNull();
}
});
it('holds the optional watchingLine to the same guard', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
watchingLine: '(a+)+b',
};
}, 'this one runs over a pane capture every time a session settles, so it can freeze the event loop the same way');
});
it('bounds how far up the screen a config file may search', () => {
// The window is the injection guard: every row it adds is another row the agent
// itself may be able to write, and the label is what silences an idle alert.
for (const lines of [0, 9, 2.5]) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
watchingLine: 'chip (\\d+)',
watchingLines: lines,
};
}, 'a config file must not be able to widen the search to the whole pane');
}
});
it('refuses a window with no pattern to bound', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
watchingLines: 3,
};
}, 'a window with nothing to search is a typo whose failure is otherwise silent');
});
it('accepts every shipped watchingLine', () => {
for (const entry of STOCK_CLIS) {
const src = entry.capabilities.workDetect?.watchingLine;
if (!src) continue;
expect(compileVersionRegex(src), `${entry.id} declares a watchingLine the guard refuses`).not.toBeNull();
}
});
});
describe('no shell text can reach the command line', () => {
+13 -3
View File
@@ -142,7 +142,7 @@ describe('_quickStartWithCustomModelConfirm', () => {
})) as unknown as typeof fetch;
}
it('returns the response directly when no confirmation is needed', async () => {
it('returns the response directly when no confirmation is needed, and records "last used"', async () => {
const { win, app } = bootApp();
withFetch(win, (body) => ({ success: true, data: { sessionId: 's1', modelSwapInProgress: false, body } }));
const data = await app._quickStartWithCustomModelConfirm({
@@ -152,9 +152,17 @@ describe('_quickStartWithCustomModelConfirm', () => {
expect(data.success).toBe(true);
expect(data.data.sessionId).toBe('s1');
expect(app._lastCustomModelLaunchResult).toEqual(data.data);
expect(win.localStorage.getItem('codeman:customModelLastUsed:codex:e')).toBe('m');
});
it('confirming re-sends with confirmedSwap and returns the second response', async () => {
it('a plain launch with no customModel at all never touches the "last used" key (undefined endpointId/modelId would otherwise silently no-op it)', async () => {
const { win, app } = bootApp();
withFetch(win, () => ({ success: true, data: { sessionId: 's1' } }));
await app._quickStartWithCustomModelConfirm({ mode: 'codex' });
expect(win.localStorage.getItem('codeman:customModelLastUsed:codex:undefined')).toBeNull();
});
it('confirming re-sends with confirmedSwap, returns the second response, and only THEN records "last used"', async () => {
const { win, app } = bootApp();
app._confirmModelSwap = async () => true;
let calls = 0;
@@ -183,9 +191,10 @@ describe('_quickStartWithCustomModelConfirm', () => {
expect(calls).toBe(2);
expect(data.data.sessionId).toBe('s1');
expect(app._lastCustomModelLaunchResult.modelSwapInProgress).toBe(true);
expect(win.localStorage.getItem('codeman:customModelLastUsed:codex:e')).toBe('m');
});
it('cancelling never re-sends, and reports a cancellation error', async () => {
it('cancelling never re-sends, reports a cancellation error, and must NEVER record "last used" for a launch that never happened', async () => {
const { win, app } = bootApp();
app._confirmModelSwap = async () => false;
let calls = 0;
@@ -208,6 +217,7 @@ describe('_quickStartWithCustomModelConfirm', () => {
expect(data.success).toBe(false);
expect(data.error).toMatch(/cancelled/i);
expect(app._lastCustomModelLaunchResult).toBeUndefined();
expect(win.localStorage.getItem('codeman:customModelLastUsed:codex:e')).toBeNull();
});
});
+249 -4
View File
@@ -272,6 +272,207 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
expect(win.document.getElementById('customModelPickList')!.textContent).toContain('Default');
});
it('promotes the model llama-swap currently has loaded and ready to the top of the list, tagged', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3', 'phi4'] }],
});
const origApiJson = app._apiJson;
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/llama-box/running-status') {
return { isLlamaSwap: true, running: [{ model: 'phi4', state: 'ready' }] };
}
return origApiJson(path);
};
await app.selectCustomModelEntry('claude', 'llama-box');
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons.map((b) => b.textContent)).toHaveLength(3);
expect(buttons[0].textContent).toContain('phi4');
expect(buttons[0].textContent).toContain('Currently loaded');
// Nothing else got relabelled or reordered past the promoted row.
expect(buttons[1].textContent).toContain('qwen3');
expect(buttons[2].textContent).toContain('llama3');
});
it('falls back to the last model launched on this (harness, endpoint) pair when nothing is currently loaded', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3', 'phi4'] }],
});
const origApiJson = app._apiJson;
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/llama-box/running-status') return { isLlamaSwap: false, running: [] };
return origApiJson(path);
};
// Simulate a prior launch on this exact (harness, endpoint) pair having picked llama3.
win.localStorage.setItem('codeman:customModelLastUsed:claude:llama-box', 'llama3');
await app.selectCustomModelEntry('claude', 'llama-box');
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons[0].textContent).toContain('llama3');
expect(buttons[0].textContent).toContain('Last used');
expect(buttons[0].textContent).not.toContain('Currently loaded');
});
it('prefers the currently-loaded model over a stale "last used" entry when both are present', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3', 'phi4'] }],
});
const origApiJson = app._apiJson;
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/llama-box/running-status') {
return { isLlamaSwap: true, running: [{ model: 'phi4', state: 'ready' }] };
}
return origApiJson(path);
};
win.localStorage.setItem('codeman:customModelLastUsed:claude:llama-box', 'llama3');
await app.selectCustomModelEntry('claude', 'llama-box');
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons[0].textContent).toContain('phi4');
expect(buttons[0].textContent).toContain('Currently loaded');
});
it('a currently-loaded row that is also the endpoint default shows BOTH tags, as two separate spans', async () => {
// The common case on a single-purpose GPU box: the one model that is loaded is the
// saved default too. An exclusive tag slot (promotion, else Default) silently dropped
// the Default marking for exactly that row.
const { win, app } = bootApp({
hosts: [
{
id: 'llama-box',
label: 'llama.cpp',
baseUrl: 'http://x',
models: ['qwen3', 'llama3', 'phi4'],
defaultModelId: 'phi4',
},
],
});
const origApiJson = app._apiJson;
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/llama-box/running-status') {
return { isLlamaSwap: true, running: [{ model: 'phi4', state: 'ready' }] };
}
return origApiJson(path);
};
await app.selectCustomModelEntry('claude', 'llama-box');
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons[0].textContent).toContain('phi4');
const tags = [...buttons[0].querySelectorAll('.set-scope')].map((el) => el.textContent);
expect(tags).toEqual(['Currently loaded', 'Default']);
// Rows with neither a promotion nor the default carry no tag at all.
expect(buttons[1].querySelectorAll('.set-scope')).toHaveLength(0);
expect(buttons[2].querySelectorAll('.set-scope')).toHaveLength(0);
});
it('a "last used" row that is also the endpoint default shows both tags too', async () => {
const { win, app } = bootApp({
hosts: [
{
id: 'llama-box',
label: 'llama.cpp',
baseUrl: 'http://x',
models: ['qwen3', 'llama3', 'phi4'],
defaultModelId: 'llama3',
},
],
});
const origApiJson = app._apiJson;
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/llama-box/running-status') return { isLlamaSwap: false, running: [] };
return origApiJson(path);
};
win.localStorage.setItem('codeman:customModelLastUsed:claude:llama-box', 'llama3');
await app.selectCustomModelEntry('claude', 'llama-box');
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons[0].textContent).toContain('llama3');
const tags = [...buttons[0].querySelectorAll('.set-scope')].map((el) => el.textContent);
expect(tags).toEqual(['Last used', 'Default']);
});
it('is not fooled by a model llama-swap reports loaded but not yet ready, or one this host no longer lists', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
});
const origApiJson = app._apiJson;
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/llama-box/running-status') {
// "loading", not "ready" — and a model id this host's own /v1/models no longer serves.
return { isLlamaSwap: true, running: [{ model: 'ghost-model', state: 'loading' }] };
}
return origApiJson(path);
};
await app.selectCustomModelEntry('claude', 'llama-box');
const list = win.document.getElementById('customModelPickList')!;
expect(list.textContent).not.toContain('Currently loaded');
expect(list.textContent).not.toContain('ghost-model');
const buttons = [...list.querySelectorAll('button')];
expect(buttons[0].textContent).toContain('qwen3');
});
it('remembers the launched model as "last used" only once the apply actually succeeds, not on the mere attempt', async () => {
const { win, app } = bootApp({});
app.activeSessionId = 'old-session';
// A real new session, and a real successful apply with no questions asked — the
// restart path only reaches its _setCustomModelLastUsed call past both.
app.run = async () => {
app.activeSessionId = 'new-session';
};
app._api = async () => ({
ok: true,
status: 200,
json: async () => ({ success: true, data: { customModel: { endpointId: 'llama-box' }, restarted: true } }),
});
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(win.localStorage.getItem('codeman:customModelLastUsed:claude:llama-box')).toBe('qwen3');
});
it('a slower currently-loaded probe for an earlier pick must never clobber a faster, later pick for a different endpoint', async () => {
const { win, app } = bootApp({});
const hostA = { id: 'host-a', label: 'Host A', baseUrl: 'http://a', models: ['a1', 'a2'] };
const hostB = { id: 'host-b', label: 'Host B', baseUrl: 'http://b', models: ['b1', 'b2'] };
let resolveA!: (v: unknown) => void;
const pendingA = new Promise((resolve) => {
resolveA = resolve;
});
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/host-a/running-status') return pendingA;
if (path === '/api/model-endpoints/host-b/running-status') return { isLlamaSwap: false, running: [] };
return null;
};
// Host A's picker opens first but its probe never resolves until we say so below —
// Host B's opens second and resolves immediately, so it renders first.
const openA = app._openCustomModelPickModal('claude', hostA);
await app._openCustomModelPickModal('claude', hostB);
expect(win.document.getElementById('customModelPickHint')!.textContent).toContain('Host B');
expect(app._pendingCustomModelPick).toEqual({ mode: 'claude', endpointId: 'host-b' });
// Host A's probe finally answers, after Host B has already rendered.
resolveA({ isLlamaSwap: false, running: [] });
await openA;
// The late-arriving Host A response must be a no-op: still Host B on screen.
expect(win.document.getElementById('customModelPickHint')!.textContent).toContain('Host B');
expect(app._pendingCustomModelPick).toEqual({ mode: 'claude', endpointId: 'host-b' });
const listText = win.document.getElementById('customModelPickList')!.textContent;
expect(listText).toContain('b1');
expect(listText).toContain('b2');
expect(listText).not.toContain('a1');
expect(listText).not.toContain('a2');
});
it('picking a row in the modal closes it and launches with that exact model', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://localhost:8080', models: ['qwen3', 'llama3'] }],
@@ -333,6 +534,48 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
});
});
describe('Custom Model Endpoint Profiles: _getCustomModelCurrentlyLoaded is client-side bounded', () => {
// `timeoutMs` driven in milliseconds rather than the real 800 — same reasoning as
// `_watchLlamaSwapLoading`'s own `pollIntervalMs` a few describe blocks down: this
// code runs inside the JSDOM window's own realm, whose setTimeout vi.useFakeTimers()
// does not patch, so this is the only way to test the bound without actually waiting
// on it (or, worse, hanging on a promise that deliberately never resolves).
it('never lets an endpoint that never answers keep the picker waiting past the client-side bound', async () => {
const { app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3'] }],
});
// A `running-status` probe that simply never resolves — the exact shape of an
// endpoint that is asleep or firewalled, distinct from one that answers an error.
app._apiJson = () => new Promise(() => {});
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5);
expect(result).toBeNull();
});
it('an endpoint that answers well within the bound is unaffected by it', async () => {
const { app } = bootApp({});
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] });
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5);
expect(result).toBe('qwen3');
});
it('a rejected probe settles quietly to null rather than leaving an unhandled rejection once the timeout has already won the race', async () => {
const { app } = bootApp({});
app._apiJson = () => new Promise((_resolve, reject) => setTimeout(() => reject(new Error('boom')), 10));
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 2);
expect(result).toBeNull();
// Give the loser of the race a turn to actually reject and hit its own .catch —
// an unswallowed rejection here would surface as an "Unhandled Errors" failure
// for the whole test file, not a failed assertion in this test.
await new Promise((resolve) => setTimeout(resolve, 20));
});
});
describe('Custom Model Endpoint Profiles: applying a picked entry', () => {
it('does not apply the endpoint to a session that was already open when the launch fails', async () => {
const { app } = bootApp({});
@@ -1025,8 +1268,8 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own
return { win, app, applyBodies };
}
it('confirming the in-app context-warning modal re-sends the apply with confirmed:true', async () => {
const { app, applyBodies } = launchHarness([
it('confirming the in-app context-warning modal re-sends the apply with confirmed:true, and only THEN records "last used"', async () => {
const { win, app, applyBodies } = launchHarness([
{ requiresContextWarning: true, modelId: 'qwen3', contextLength: 16384, minSafeContextTokens: 40000 },
{ customModel: { endpointId: 'llama-box' }, restarted: true, modelSwapInProgress: false },
]);
@@ -1043,10 +1286,11 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own
{ endpointId: 'llama-box', modelId: 'qwen3' },
{ endpointId: 'llama-box', modelId: 'qwen3', confirmedContext: true },
]);
expect(win.localStorage.getItem('codeman:customModelLastUsed:claude:llama-box')).toBe('qwen3');
});
it('declining the in-app context-warning modal keeps the native backend and never re-sends the apply', async () => {
const { app, applyBodies } = launchHarness([
it('declining the in-app context-warning modal keeps the native backend, never re-sends the apply, and must NEVER record this model as "last used" — it cannot work with this CLI at all', async () => {
const { win, app, applyBodies } = launchHarness([
{ requiresContextWarning: true, modelId: 'qwen3', contextLength: 16384, minSafeContextTokens: 40000 },
]);
app._confirmContextWarning = async () => false;
@@ -1059,6 +1303,7 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own
expect(applyBodies).toHaveLength(1); // no second (confirmed) call
expect(toastMessage).toMatch(/context window too small/i);
expect(win.localStorage.getItem('codeman:customModelLastUsed:claude:llama-box')).toBeNull();
});
});
+28 -4
View File
@@ -14,6 +14,12 @@
* already stood aside on the same condition, so this follows a rule the code
* had already established.
*
* ⚠️ It returns early BEFORE the local fit, not after (issue #464). The earlier
* rule was "withhold the send, never the reflow", which leaves this window's
* xterm at a shape the PTY was never told about — and a CLI computes its
* repaints from the shape it was told, so that reflow bought a garbled frame
* rather than a correct one. Withhold both, or neither.
*
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
* box; see connection-indicator.test.ts), the same way terminal-buffer-flush
* extracts the real mixin methods from terminal-ui.js.
@@ -56,7 +62,19 @@ function makeApp(overrides: Record<string, unknown> = {}) {
currentFetch = fetchMock;
const app = {
sendResize: mixin.sendResize,
// The real chain: sendResize fits, floors and applies through one function
// now, so the harness must let it (#464).
syncTerminalGeometry: mixin.syncTerminalGeometry,
_geometryForResizeRequest: mixin._geometryForResizeRequest,
_resizeTerminalTo: mixin._resizeTerminalTo,
// Real, so a geometry change really does re-check whether the terminal now
// overflows its container (#464 item 4) — the fake DOM has no container, so
// it measures nothing and settles on "no overflow", which is the truth here.
_scheduleOverflowAffordanceSync: mixin._scheduleOverflowAffordanceSync,
_syncTerminalOverflowAffordance: mixin._syncTerminalOverflowAffordance,
_onPtyGeometryReport: vi.fn(),
getTerminalDimensions: () => ({ cols: 120, rows: 40 }),
terminal: { cols: 120, rows: 40, resize: vi.fn() },
fitAddon: { fit: vi.fn() },
detachedSessions: new Set<string>(),
isSoloWindow: false,
@@ -77,10 +95,16 @@ describe('detached sessions own their pane size', () => {
expect(changed).toBe(false);
// No request: the popup's size stands on the server.
expect(fetchMock).not.toHaveBeenCalled();
// The LOCAL fit still runs, so the dashboard's own xterm stays correct and
// tab-rail-resize's single settle-time refit is not swallowed. Same line the
// mobile-keyboard guard draws: withhold the send, never the reflow.
expect((app.fitAddon as { fit: ReturnType<typeof vi.fn> }).fit).toHaveBeenCalled();
// ⚠️ REVERSED by issue #464, deliberately. This used to assert that the
// LOCAL fit still ran — "withhold the send, never the reflow" — on the
// reasoning that it keeps the dashboard's own xterm correct. It does not:
// it leaves this xterm at a shape the PTY was never told about, and Claude
// Code computes every repaint from the shape it WAS told, so the frames
// land on rows nothing erased. The popup that owns the PTY is drawing for
// its own width either way, so the dashboard's reflow was a reflow nothing
// was rendering for. Withholding the resize means withholding all of it.
expect((app.fitAddon as { fit: ReturnType<typeof vi.fn> }).fit).not.toHaveBeenCalled();
expect((app.terminal as { resize: ReturnType<typeof vi.fn> }).resize).not.toHaveBeenCalled();
});
it('the solo window still sizes the session it displays', async () => {
+315 -1
View File
@@ -18,10 +18,15 @@
* `CODEMAN_CASES_PATH`, so the directory it creates has the owner the
* container will accept, and its `git_head_commit` helper (a pure function
* over `.git`) resolves the three ref layouts a checkout can have.
* 4. `Update-Codeman.sh` (the scripted major update) runs its collision guard
* before its own `--no-cache` build and `down`, removes exactly the two
* build-artefact volumes rather than every volume in the project, and hands
* off to `Start-Codeman.sh`; checked statically and by an end-to-end run
* against a stub `docker`.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { readFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -33,6 +38,7 @@ const compose = read('docker/docker-compose.yaml');
const entrypoint = read('docker/entrypoint.sh');
const dockerfile = read('docker/server.Dockerfile');
const startScript = read('docker/Start-Codeman.sh');
const updateScript = read('docker/Update-Codeman.sh');
/** The `- NAME` entries under `cap_add:` (the block ends at the next key at the same indent). */
function composeCapAdd(text: string): string[] {
@@ -174,6 +180,314 @@ describe('Start-Codeman.sh', () => {
});
});
describe('Update-Codeman.sh (the scripted major-update path — docker/README.md "Major updates")', () => {
it('parses under bash -n', () => {
execFileSync('bash', ['-n', join(ROOT, 'docker/Update-Codeman.sh')]);
});
it('force-rebuilds with --no-cache BEFORE stopping the stack, THEN hands off to Start-Codeman.sh via `bash`', () => {
const build = updateScript.indexOf('"${compose_command[@]}" build --no-cache');
const down = updateScript.indexOf('"${compose_command[@]}" down');
const handoff = updateScript.indexOf('exec bash "$script_dir/Start-Codeman.sh"');
expect(build).toBeGreaterThan(-1);
expect(down).toBeGreaterThan(build);
expect(handoff).toBeGreaterThan(down);
// A bare `exec "$script_dir/Start-Codeman.sh"` fails EACCES — Start-Codeman.sh
// is committed non-executable (100644), same as this script.
expect(updateScript).not.toMatch(/exec "\$script_dir\/Start-Codeman\.sh"/);
});
it('resolves the collision guard BEFORE the --no-cache build and the down, not after', () => {
// Start-Codeman.sh has no such guard, so this is the only one, and it has
// to run before this script's own build, down and volume removal.
const projectName = updateScript.indexOf('project_name=$(');
const guard = updateScript.indexOf('other_working_dir=$(');
const build = updateScript.indexOf('"${compose_command[@]}" build --no-cache');
const down = updateScript.indexOf('"${compose_command[@]}" down');
expect(projectName).toBeGreaterThan(-1);
expect(guard).toBeGreaterThan(projectName);
expect(guard).toBeLessThan(build);
expect(guard).toBeLessThan(down);
expect(updateScript).toMatch(/label=com\.docker\.compose\.project=\$project_name/);
expect(updateScript).toMatch(/\{\{\.Label "com\.docker\.compose\.project\.working_dir"\}\}/);
expect(updateScript).toMatch(/grep -v -F -x -- "\$script_dir"/);
});
it('clears exactly the two build-artefact volumes by DEFAULT, by label, scoped to the project', () => {
expect(updateScript).toMatch(/--keep-volumes\)\s*\n\s*keep_volumes=1/);
expect(updateScript).toMatch(/for key in codeman-node-modules codeman-dist; do/);
expect(updateScript).toMatch(/--filter "label=com\.docker\.compose\.volume=\$key"/);
expect(updateScript).toMatch(/docker volume rm -- "\$volume_name"/);
// `down --volumes` survives only as the fallback for an unresolvable
// project name, where the label filter could not match anything.
const fallback = updateScript.indexOf('"${compose_command[@]}" down --volumes');
const warning = updateScript.indexOf('could not resolve the Compose project name');
expect(warning).toBeGreaterThan(-1);
expect(fallback).toBeGreaterThan(warning);
});
it('--help/-h prints usage and exits 0, rather than falling into the unrecognised-argument branch', () => {
expect(updateScript).toMatch(/--help \| -h\)\s*\n\s*printf 'Usage:/);
const helpBlock = updateScript.slice(updateScript.indexOf('--help | -h)'), updateScript.indexOf('*)'));
expect(helpBlock).toMatch(/exit 0/);
});
it('rejects an unrecognised argument rather than silently ignoring it', () => {
expect(updateScript).toMatch(/Error: unrecognised argument/);
expect(updateScript).toMatch(/exit 1/);
});
it('resolves the override file exactly like Start-Codeman.sh, so `down` and `up` never target different Compose files', () => {
// \r stripped before comparing: git's autocrlf normalises the COMMITTED blob to LF
// either way, but a Windows checkout can have already converted one file's line
// endings on disk and not the other's (e.g. Start-Codeman.sh checked out before this
// script existed), which would fail a raw byte comparison for a reason that has
// nothing to do with the two scripts actually agreeing.
const normalise = (s: string) => s.replace(/\r\n/g, '\n');
const overrideBlock = (script: string) =>
normalise(script.slice(script.indexOf('override_yml='), script.indexOf('compose_command=(docker compose')));
expect(overrideBlock(updateScript)).toBe(overrideBlock(startScript));
});
it('derives PUID/PGID from the SAME owner_of() helper Start-Codeman.sh uses, so the --no-cache build gets the right build args', () => {
const normalise = (s: string) => s.replace(/\r\n/g, '\n');
const ownerOfBlock = (script: string) => {
const start = script.indexOf('owner_of() {');
const end = script.indexOf('\n}', start) + '\n}'.length;
return normalise(script.slice(start, end));
};
expect(ownerOfBlock(updateScript)).toBe(ownerOfBlock(startScript));
expect(updateScript).toMatch(/export PUID=\$\{owner_ids%%:\*\}/);
expect(updateScript).toMatch(/export PGID=\$\{owner_ids##\*:\}/);
// The build must come AFTER PUID/PGID are resolved and exported, or Compose
// falls back to its own default of 1000:1000 for the build args.
const puidExport = updateScript.indexOf('export PUID=');
const build = updateScript.indexOf('"${compose_command[@]}" build --no-cache');
expect(puidExport).toBeGreaterThan(-1);
expect(build).toBeGreaterThan(puidExport);
});
describe('end-to-end smoke test (a stub `docker` on PATH, logging every invocation)', () => {
/**
* Reproduces the exact scenario the review on PR #465 caught by hand: a bare
* `exec` of a non-executable script exits 126 with no further `docker` calls
* at all. Runs the REAL Update-Codeman.sh against a synthetic deployment,
* asserting the actual command sequence a shell would issue — string-matching
* the source (the tests above) cannot tell a working `exec bash "…"` apart
* from a silently-broken bare `exec "…"` the way actually running it can.
*
* The harness intentionally does NOT create a real Unix socket for
* DOCKER_SOCKET (net.createServer().listen(path) is unreliable off Linux —
* measured EACCES on this Windows sandbox even outside any container). So the
* handoff to Start-Codeman.sh is real and fully exercises this script's own
* build/down/handoff sequence, but Start-Codeman.sh's OWN socket check is
* expected to then fail — which is itself the proof the handoff worked: a
* process that failed to exec would never reach a Start-Codeman.sh-only error
* message, and would exit 126, not 1.
*/
// Windows join()/mkdtempSync() paths carry backslashes, which the stub
// `docker`'s naive `source "$envfile"` (a shortcut for `docker compose
// config --environment`'s own real parsing, which handles this fine) reads
// as bash ESCAPE characters and silently drops — `C:\Users\x` becomes
// `C:Usersx`. Forward slashes are accepted by git-bash/MSYS on Windows and
// by every POSIX shell, so normalising once here sidesteps a harness
// artifact that has nothing to do with the scripts under test.
const posix = (p: string) => p.replace(/\\/g, '/');
function runSmokeTest(
args: string[],
extraEnv: Record<string, string> = {}
): { status: number; stderr: string; log: string[] } {
const dir = mkdtempSync(join(tmpdir(), 'codeman-update-smoke-'));
try {
const dockerDir = join(dir, 'docker');
mkdirSync(dockerDir);
writeFileSync(join(dockerDir, 'Update-Codeman.sh'), updateScript);
writeFileSync(join(dockerDir, 'Start-Codeman.sh'), startScript);
writeFileSync(join(dockerDir, 'docker-compose.yaml'), compose);
const appdataPath = join(dir, 'appdata');
const casesPath = join(dir, 'cases');
const socketPath = join(dir, 'docker.sock'); // deliberately NOT a real socket — see above
mkdirSync(appdataPath);
mkdirSync(casesPath);
writeFileSync(socketPath, '');
writeFileSync(
join(dockerDir, '.env'),
[
`CODEMAN_APPDATA_PATH=${posix(appdataPath)}`,
`CODEMAN_CASES_PATH=${posix(casesPath)}`,
`DOCKER_SOCKET=${posix(socketPath)}`,
'CODEMAN_RUNTIME_USER=codeman',
'CODEMAN_PORT=3000',
'CODEMAN_HOST=127.0.0.1',
'CODEMAN_PASSWORD=x',
'CODEMAN_USERNAME=admin',
'GEMINI_API_KEY=',
'CODEMAN_DOCKER_BRIDGE_HOOKS=',
'CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=',
'TZ=UTC',
'CODEMAN_IMAGE=codeman:test',
'',
].join('\n')
);
// A stub `docker` that only understands the two `compose config` shapes
// both scripts actually issue, and logs every invocation verbatim —
// written and chmod+x'd from WITHIN one bash invocation (not
// fs.chmodSync, whose Win32 backing does not reliably set the bit this
// MSYS bash's own PATH lookup honours — measured, differs from a plain
// `chmod +x` issued by bash itself).
const binDir = join(dir, 'bin');
mkdirSync(binDir);
const stub = [
'#!/usr/bin/env bash',
'echo "docker $*" >> "$CMDLOG"',
'if [[ "$1" == "compose" ]]; then',
' shift',
' prev=""',
' envfile=""',
' for a in "$@"; do',
' if [[ "$prev" == "--env-file" ]]; then envfile="$a"; fi',
' prev="$a"',
' done',
' if [[ " $* " == *" config "* && " $* " == *" --environment "* ]]; then',
' source "$envfile"',
' echo "CODEMAN_APPDATA_PATH=$CODEMAN_APPDATA_PATH"',
' echo "CODEMAN_CASES_PATH=$CODEMAN_CASES_PATH"',
' echo "DOCKER_SOCKET=$DOCKER_SOCKET"',
' exit 0',
' fi',
' if [[ " $* " == *" config "* && " $* " == *" --format json "* ]]; then',
' if [[ -n "${STUB_CONFIG_JSON_FAIL:-}" ]]; then echo "unknown flag: --format" >&2; exit 1; fi',
// Real `docker compose config --format json` pretty-prints, so
// `"name"` starts its OWN line rather than sharing one with `{` -
// the sed extraction both scripts use anchors on that, and a
// compact one-liner here would silently resolve project_name to
// empty, exercising neither script's guard the way real Compose
// output does.
' printf \'{\\n "name": "codeman"\\n}\\n\'',
' exit 0',
' fi',
' exit 0',
'fi',
// Mirrors the guard's own `docker ps -a --filter ... --format
// '{{.Label "com.docker.compose.project.working_dir"}}'` call.
// Empty by default (no collision) so the existing smoke tests above
// see no output here and proceed exactly as before; a test that
// wants to exercise the guard itself sets STUB_PS_WORKING_DIR.
'if [[ "$1" == "ps" && -n "${STUB_PS_WORKING_DIR:-}" ]]; then',
' printf "%s\\n" "$STUB_PS_WORKING_DIR"',
' exit 0',
'fi',
// `docker volume ls -q --filter label=com.docker.compose.volume=<key> ...`:
// answer with the Compose-style `<project>_<key>` name for that key.
'if [[ "$1" == "volume" && "$2" == "ls" ]]; then',
' for a in "$@"; do',
' case "$a" in label=com.docker.compose.volume=*) echo "codeman_${a#label=com.docker.compose.volume=}" ;; esac',
' done',
' exit 0',
'fi',
'exit 0',
].join('\n');
const stubPath = join(binDir, 'docker');
writeFileSync(stubPath, stub);
execFileSync('bash', ['-c', `chmod +x '${stubPath}'`]);
const logPath = join(dir, 'cmdlog.txt');
writeFileSync(logPath, '');
let status = 0;
let stderr = '';
try {
execFileSync('bash', [join(dockerDir, 'Update-Codeman.sh'), ...args], {
env: { ...process.env, PATH: `${binDir}:${process.env.PATH}`, CMDLOG: logPath, ...extraEnv },
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (err) {
const e = err as { status?: number; stderr?: string };
status = e.status ?? 1;
stderr = e.stderr ?? '';
}
const log = readFileSync(logPath, 'utf-8')
.split('\n')
.filter((l) => l.trim());
return { status, stderr, log };
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
it('default: build --no-cache, THEN a plain down, THEN removes exactly the two volumes, THEN the handoff runs Start-Codeman.sh', () => {
const { status, stderr, log } = runSmokeTest([]);
const buildIdx = log.findIndex((l) => l.includes('build --no-cache'));
const downIdx = log.findIndex((l) => / down(\s|$)/.test(l));
expect(buildIdx).toBeGreaterThan(-1);
expect(downIdx).toBeGreaterThan(buildIdx);
expect(log[downIdx]).not.toContain('--volumes');
expect(log.some((l) => l.includes('down --volumes'))).toBe(false);
const removed = log.filter((l) => l.startsWith('docker volume rm'));
expect(removed).toEqual([
'docker volume rm -- codeman_codeman-node-modules',
'docker volume rm -- codeman_codeman-dist',
]);
expect(log.findIndex((l) => l.startsWith('docker volume rm'))).toBeGreaterThan(downIdx);
// Proof the handoff really executed Start-Codeman.sh rather than dying
// with EACCES right after printing "Handing off...": more `docker`
// invocations appear AFTER the down, which only Start-Codeman.sh's own
// config-resolution lines would produce.
const configCallsAfterDown = log.slice(downIdx + 1).filter((l) => l.includes('config'));
expect(configCallsAfterDown.length).toBeGreaterThan(0);
// A working handoff fails HONESTLY at Start-Codeman.sh's own socket
// check (this harness deliberately supplies no real Unix socket) — never
// with an EACCES/126 from a broken `exec`.
expect(status).toBe(1);
expect(stderr).toMatch(/DOCKER_SOCKET is not a Unix socket/);
expect(stderr).not.toMatch(/permission denied/i);
});
it('--keep-volumes: a plain `down`, with no --volumes flag', () => {
const { log } = runSmokeTest(['--keep-volumes']);
const downLine = log.find((l) => / down(\s|$)/.test(l));
expect(downLine).toBeDefined();
expect(downLine).not.toContain('--volumes');
expect(log.some((l) => l.startsWith('docker volume rm'))).toBe(false);
});
it('reports a failing first `docker compose config` call instead of exiting silently', () => {
const { status, stderr, log } = runSmokeTest([], { STUB_CONFIG_JSON_FAIL: '1' });
expect(status).not.toBe(0);
expect(stderr).toMatch(/docker compose config --format json` failed/);
expect(stderr).toMatch(/unknown flag: --format/);
expect(log.some((l) => l.includes('build --no-cache'))).toBe(false);
});
it('still refuses when an unlabelled container prints an empty line ahead of the other checkout', () => {
const { status, stderr, log } = runSmokeTest([], { STUB_PS_WORKING_DIR: '\n/some/other/checkout/docker' });
expect(status).toBe(1);
expect(stderr).toMatch(/already in use by a DIFFERENT checkout/);
expect(log.some((l) => l.includes('build --no-cache'))).toBe(false);
});
it('refuses BEFORE the --no-cache build when the resolved project belongs to a different checkout', () => {
// Start-Codeman.sh has no such guard, so nothing downstream of this
// script would catch the collision.
const { status, stderr, log } = runSmokeTest([], { STUB_PS_WORKING_DIR: '/some/other/checkout/docker' });
expect(status).toBe(1);
expect(stderr).toMatch(/already in use by a DIFFERENT checkout/);
expect(stderr).toContain('/some/other/checkout/docker');
expect(log.some((l) => l.includes('build --no-cache'))).toBe(false);
expect(log.some((l) => / down(\s|$)/.test(l))).toBe(false);
});
});
});
describe('git_head_commit resolves every ref layout a checkout can have', () => {
let base: string;
const git = (cwd: string, ...args: string[]) =>
+64
View File
@@ -351,6 +351,70 @@ describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencod
expect(mounts.filter((m) => m.readonly && m.dst.includes('cred-seeds')).length).toBeGreaterThanOrEqual(3);
});
/** Host files for both opt-in stores, present whether or not the switches are on. */
function writeGhAzHostFiles(): void {
mkdirSync(join(home, '.config', 'gh'), { recursive: true });
writeFileSync(join(home, '.config', 'gh', 'hosts.yml'), '');
writeFileSync(join(home, '.config', 'gh', 'config.yml'), '');
mkdirSync(join(home, '.azure'), { recursive: true });
writeFileSync(join(home, '.azure', 'azureProfile.json'), '{}');
writeFileSync(join(home, '.azure', 'msal_token_cache.json'), '{}');
}
const isGhOrAz = (p: string) => /\.azure|\.config[\\/]gh/.test(p);
it('gh + az: the DEFAULT environment seeds neither, even when the host files exist', () => {
writeGhAzHostFiles();
for (const env of [{}, { CODEMAN_AGENT_IMAGE_INSTALL_GH: '0', CODEMAN_AGENT_IMAGE_INSTALL_AZ: '' }]) {
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home, env);
expect(mounts.filter((m) => isGhOrAz(m.src))).toEqual([]);
expect(seedCopies.filter((s) => isGhOrAz(s.to))).toEqual([]);
}
});
it('gh + az: each store follows ONLY its own switch, and only the exact value 1', () => {
writeGhAzHostFiles();
const dests = (env: NodeJS.ProcessEnv) => resolveDockerCredentialArtifacts(home, env).seedCopies.map((s) => s.to);
const ghOnly = dests({ CODEMAN_AGENT_IMAGE_INSTALL_GH: '1' });
expect(ghOnly).toContain('/home/agent/.config/gh/hosts.yml');
expect(ghOnly.some((d) => d.includes('.azure'))).toBe(false);
const azOnly = dests({ CODEMAN_AGENT_IMAGE_INSTALL_AZ: '1' });
expect(azOnly).toContain('/home/agent/.azure/msal_token_cache.json');
expect(azOnly.some((d) => d.includes('.config/gh'))).toBe(false);
expect(
dests({ CODEMAN_AGENT_IMAGE_INSTALL_GH: 'true', CODEMAN_AGENT_IMAGE_INSTALL_AZ: 'yes' }).some(isGhOrAz)
).toBe(false);
});
it('gh + az: seed only the sign-in files, never logs/extensions/caches', () => {
mkdirSync(join(home, '.config', 'gh'), { recursive: true });
writeFileSync(join(home, '.config', 'gh', 'hosts.yml'), '');
writeFileSync(join(home, '.config', 'gh', 'config.yml'), '');
mkdirSync(join(home, '.azure', 'logs'), { recursive: true });
mkdirSync(join(home, '.azure', 'cliextensions'), { recursive: true });
writeFileSync(join(home, '.azure', 'azureProfile.json'), '{}');
writeFileSync(join(home, '.azure', 'msal_token_cache.json'), '{}');
writeFileSync(join(home, '.azure', 'config'), '');
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home, {
CODEMAN_AGENT_IMAGE_INSTALL_GH: '1',
CODEMAN_AGENT_IMAGE_INSTALL_AZ: '1',
});
const dests = seedCopies.map((s) => s.to);
expect(dests).toContain('/home/agent/.config/gh/hosts.yml');
expect(dests).toContain('/home/agent/.config/gh/config.yml');
expect(dests).toContain('/home/agent/.azure/azureProfile.json');
expect(dests).toContain('/home/agent/.azure/msal_token_cache.json');
expect(dests).toContain('/home/agent/.azure/config');
// Absent files are skipped, and nothing outside the sign-in set is seeded.
expect(dests).not.toContain('/home/agent/.azure/service_principal_entries.json');
expect(dests.some((d) => d.includes('logs') || d.includes('cliextensions'))).toBe(false);
expect(seedCopies.filter((s) => /\.azure|\.config\/gh/.test(s.to)).every((s) => !s.recursive)).toBe(true);
// Every host credential file rides a READ-ONLY mount, so the container never writes back.
const credMounts = mounts.filter((m) => /\.azure|\.config[\\/]gh/.test(m.src));
expect(credMounts.length).toBe(5);
expect(credMounts.every((m) => m.readonly)).toBe(true);
});
it('gates every artifact on existsSync (absent stores contribute nothing)', () => {
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
expect(mounts).toEqual([]);
+272
View File
@@ -0,0 +1,272 @@
// Port: none (pure policy + the real scheduler from app.js under a fake clock).
//
// `_onSessionTerminal` drops an incoming frame once the app-owned render queues
// hold 128KB. That is the right call — the alternative is an unbounded backlog —
// but a hole in a TUI byte stream is a desynced cursor, and a desynced cursor is
// muffled text (issue #464). The drop is only half of it; the recovery has to
// actually happen.
//
// ⚠️ It used to be a fire-and-forget timer: it nulled its own handle and then
// called `_onSessionNeedsRefresh()`, which opens with four early returns. Two of
// them — a buffer load in flight, a refresh already owning this session — are
// MOST likely to be true during exactly the output burst that caused the drop,
// so the recovery was silently lost precisely when it was needed, and those
// bytes were never replayed.
//
// The test that matters here is `retries when the refresh was skipped`, paired
// with `does not retry once a repaint happened`. Either one alone would pass
// against the old fire-and-forget code; only the contrast pins the fix.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const read = (rel: string) => readFileSync(resolve(import.meta.dirname, '..', rel), 'utf8');
type RetryState = { repainted: boolean; timedOut?: boolean; attempt: number; stillActive: boolean };
function loadConstants() {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(readFileSync(resolve(publicDir, 'constants.js'), 'utf8'), context, { filename: 'constants.js' });
return (
context.window as {
CodemanDroppedOutput: {
shouldRetryDroppedOutputRecovery: (s: RetryState) => boolean;
DROP_RECOVERY_DELAY_MS: number;
DROP_RECOVERY_MAX_ATTEMPTS: number;
};
}
).CodemanDroppedOutput;
}
const { shouldRetryDroppedOutputRecovery, DROP_RECOVERY_MAX_ATTEMPTS, DROP_RECOVERY_DELAY_MS } = loadConstants();
describe('shouldRetryDroppedOutputRecovery', () => {
it('retries a recovery that did not repaint', () => {
expect(shouldRetryDroppedOutputRecovery({ repainted: false, attempt: 0, stillActive: true })).toBe(true);
});
it('stops as soon as something repainted', () => {
expect(shouldRetryDroppedOutputRecovery({ repainted: true, attempt: 0, stillActive: true })).toBe(false);
});
it('stops when the reader has moved to another session', () => {
// selectSession repaints from the server on its own, so a retry here would
// be a second replay of a buffer that is about to be written anyway.
expect(shouldRetryDroppedOutputRecovery({ repainted: false, attempt: 0, stillActive: false })).toBe(false);
});
it('does not retry a refresh that died at the fetch deadline', () => {
// A stalled link, not contention: each retry would be another full capture
// waiting out a deadline of up to two minutes.
expect(shouldRetryDroppedOutputRecovery({ repainted: false, timedOut: true, attempt: 0, stillActive: true })).toBe(
false
);
});
it('gives up at the cap rather than looping against the API forever', () => {
const last = DROP_RECOVERY_MAX_ATTEMPTS - 1;
expect(shouldRetryDroppedOutputRecovery({ repainted: false, attempt: last - 1, stillActive: true })).toBe(true);
expect(shouldRetryDroppedOutputRecovery({ repainted: false, attempt: last, stillActive: true })).toBe(false);
});
});
// ───────────────────────────────────────────────────────────────────────────
// The real scheduler, from app.js, under a fake clock.
// ───────────────────────────────────────────────────────────────────────────
function loadAppPrototype(): Record<string, unknown> {
const context = vm.createContext({
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
performance: { now: () => 0 },
setInterval: vi.fn(),
clearInterval: vi.fn(),
// ⚠️ Delegated, not captured. Baking the real `setTimeout` into the context
// puts the scheduler on a clock `vi.useFakeTimers()` cannot reach, and the
// retry behaviour under test is entirely a matter of timers firing. These
// arrows resolve the identifier from the global at CALL time, so the fake
// clock installed later still owns them.
setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms),
clearTimeout: (id: ReturnType<typeof setTimeout>) => clearTimeout(id),
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: vi.fn(),
document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: { isTouchDevice: () => false },
});
vm.runInContext(
`${readFileSync(resolve(publicDir, 'constants.js'), 'utf8')}\n` +
`${readFileSync(resolve(publicDir, 'app.js'), 'utf8')}\n` +
`globalThis.__CodemanApp = CodemanApp;\nglobalThis.__crashDiag = _crashDiag;`,
context
);
crashTrail = (context as { __crashDiag: { _entries: string[] } }).__crashDiag._entries;
return (context as { __CodemanApp: { prototype: Record<string, unknown> } }).__CodemanApp.prototype;
}
let crashTrail: string[] = [];
const proto = loadAppPrototype();
const SESSION = 'session-A';
/** A minimal app carrying only what the scheduler touches. */
function makeApp(refresh: () => unknown) {
const calls: string[] = [];
return {
calls,
app: {
_scheduleDroppedOutputRecovery: proto._scheduleDroppedOutputRecovery,
activeSessionId: SESSION,
_clientDropRecoveryTimer: null as ReturnType<typeof setTimeout> | null,
_onSessionNeedsRefresh: (arg: { id: string }) => {
calls.push(arg.id);
return refresh();
},
} as unknown as {
_scheduleDroppedOutputRecovery: (id: string, attempt?: number) => void;
activeSessionId: string | null;
_clientDropRecoveryTimer: unknown;
},
};
}
/** Run every pending timer the scheduler laid down, up to `rounds` deep. */
async function drain(rounds = DROP_RECOVERY_MAX_ATTEMPTS + 2) {
for (let i = 0; i < rounds; i++) {
await vi.advanceTimersByTimeAsync(DROP_RECOVERY_DELAY_MS + 1);
}
}
describe('_scheduleDroppedOutputRecovery', () => {
it('retries when the refresh was SKIPPED, which is what a burst makes likely', async () => {
vi.useFakeTimers();
try {
// `_onSessionNeedsRefresh` returns false from all four of its early
// returns — a buffer load in flight, a refresh already owning the session.
const { app, calls } = makeApp(() => Promise.resolve(false));
app._scheduleDroppedOutputRecovery(SESSION);
await drain();
expect(calls.length, 'a skipped refresh must be tried again — the old fire-and-forget timer stopped at one').toBe(
DROP_RECOVERY_MAX_ATTEMPTS
);
expect(new Set(calls)).toEqual(new Set([SESSION]));
} finally {
vi.useRealTimers();
}
});
// The contrast. Without this the case above is satisfied by retrying forever.
it('does not retry once a repaint actually happened', async () => {
vi.useFakeTimers();
try {
const { app, calls } = makeApp(() => Promise.resolve(true));
app._scheduleDroppedOutputRecovery(SESSION);
await drain();
expect(calls.length).toBe(1);
} finally {
vi.useRealTimers();
}
});
it('stops when the reader switches away mid-recovery', async () => {
vi.useFakeTimers();
try {
const { app, calls } = makeApp(() => {
app.activeSessionId = 'session-B';
return Promise.resolve(false);
});
app._scheduleDroppedOutputRecovery(SESSION);
await drain();
expect(calls.length, 'selectSession repaints session-B on its own').toBe(1);
} finally {
vi.useRealTimers();
}
});
it('treats a refresh that THREW as not repainted, and tries again', async () => {
vi.useFakeTimers();
try {
const { app, calls } = makeApp(() => Promise.reject(new Error('network')));
app._scheduleDroppedOutputRecovery(SESSION);
await drain();
expect(calls.length).toBe(DROP_RECOVERY_MAX_ATTEMPTS);
} finally {
vi.useRealTimers();
}
});
it('does not retry a refresh that hit the fetch deadline', async () => {
vi.useFakeTimers();
try {
const { app, calls } = makeApp(() => Promise.resolve('deadline'));
app._scheduleDroppedOutputRecovery(SESSION);
await drain();
expect(calls.length, 'a stalled link is not contention; one full capture is the old cost').toBe(1);
} finally {
vi.useRealTimers();
}
});
it('writes one crash-trail line per debounce window, not one per dropped frame', async () => {
vi.useFakeTimers();
try {
const { app } = makeApp(() => Promise.resolve(true));
const before = crashTrail.filter((e) => e.includes('TERMINAL DROP')).length;
const schedule = app._scheduleDroppedOutputRecovery as (id: string, attempt?: number, queued?: number) => void;
for (let i = 0; i < 125; i++) schedule.call(app, SESSION, 0, 200 * 1024);
const drops = crashTrail.filter((e) => e.includes('TERMINAL DROP')).length - before;
expect(drops, 'one second of 8ms frames used to evict the whole 50-entry trail').toBe(1);
await drain();
} finally {
vi.useRealTimers();
}
});
it('coalesces a burst of drops into one attempt, as the debounce always did', async () => {
vi.useFakeTimers();
try {
const { app, calls } = makeApp(() => Promise.resolve(true));
for (let i = 0; i < 20; i++) app._scheduleDroppedOutputRecovery(SESSION);
await drain();
expect(calls.length, 'twenty dropped frames must not become twenty fetches').toBe(1);
} finally {
vi.useRealTimers();
}
});
});
describe('the drop path and the refresh agree on what counts as recovered', () => {
const app = read('src/web/public/app.js');
it('the 128KB drop goes through the scheduler, not a bare timer', () => {
const at = app.indexOf('131072');
expect(at, 'the cap is gone — renamed?').toBeGreaterThan(-1);
const branch = app.slice(at, at + 600);
expect(branch).toContain('this._scheduleDroppedOutputRecovery(data.id, 0, queued)');
// The crash-trail line belongs behind the scheduler's debounce guard.
expect(branch).not.toContain('_crashDiag.log');
expect(branch, 'a bare setTimeout here is the bug this fixes').not.toContain('setTimeout');
});
it('_onSessionNeedsRefresh reports false from every early return', () => {
const start = app.indexOf('async _onSessionNeedsRefresh(event = {})');
expect(start).toBeGreaterThan(-1);
const head = app.slice(start, app.indexOf('const refreshOwner', start));
const bareReturns = head.match(/\breturn;/g) ?? [];
expect(bareReturns, 'a bare `return` reads as undefined, which the caller cannot tell from false').toHaveLength(0);
expect((head.match(/return false;/g) ?? []).length).toBeGreaterThanOrEqual(4);
});
it('and reports true only where it settles the reconcile marker', () => {
const start = app.indexOf('async _onSessionNeedsRefresh(event = {})');
const body = app.slice(start, app.indexOf('\n }\n', app.indexOf('needsRefresh reload failed', start)));
const markerAt = body.indexOf('this._markTerminalBufferReconciled(sessionId);');
const trueAt = body.indexOf('return true;');
expect(markerAt).toBeGreaterThan(-1);
expect(trueAt, 'the success return must sit with the marker it settles').toBeGreaterThan(markerAt);
expect(trueAt - markerAt).toBeLessThan(80);
});
});
+4
View File
@@ -292,6 +292,10 @@ function loadRealSelectSessionHarness(options: { terminalFailure?: boolean } = {
app._beginBufferLoad = vi.fn(() => 1);
app._isLoadingBuffer = false;
app.fitAddon = { fit: vi.fn() };
// selectSession fits through the one owner now, which also applies the floor
// it reports to the PTY (#464). Without it on the fake, the unconditional
// call throws into selectSession's catch and nothing after it runs.
app.syncTerminalGeometry = vi.fn(() => ({ cols: 120, rows: 40 }));
app.sendResize = vi.fn(() => {
resizeCalls++;
return resizeCalls === 1 ? terminalBoundary.promise : Promise.resolve(false);
+8 -2
View File
@@ -313,11 +313,17 @@ describe('fold reserved region: every centred overlay is covered', () => {
/**
* The elements whose padding cascade is simulated: every derived overlay as
* a bare element, plus the open command palette, which is a `.modal` wearing
* two more classes and the one overlay mobile.css pads with a shorthand.
* two more classes and the one overlay mobile.css pads with a shorthand, plus
* the mobile prompt composer, a `.paste-overlay` wearing a second class that
* carries its own `padding` shorthand. The derived list cannot see the
* composer (it inherits the centring declarations rather than declaring
* them), and simulating `['paste-overlay']` alone stayed green while the
* generic `.paste-overlay` fold rule erased the composer's bottom gutter.
*/
const ELEMENTS: { name: string; classes: string[] }[] = [
...CENTRED_OVERLAYS.map((o) => ({ name: o.selector, classes: classCompound(o.selector)! })),
{ name: '.modal.command-palette-modal.active', classes: ['modal', 'command-palette-modal', 'active'] },
{ name: '.paste-overlay.prompt-composer-overlay', classes: ['paste-overlay', 'prompt-composer-overlay'] },
];
it('simulates the cascade the browser measured', () => {
@@ -330,7 +336,7 @@ describe('fold reserved region: every centred overlay is covered', () => {
const picker = ['path-picker-overlay'];
expect(cascadedPadding(picker, 'right', 393, false)).toBe('0');
expect(cascadedPadding(picker, 'right', 626, false)).toBe('16px');
const palette = ELEMENTS.at(-1)!.classes;
const palette = ELEMENTS.find((e) => e.name === '.modal.command-palette-modal.active')!.classes;
expect(cascadedPadding(palette, 'right', 393, false)).toBeNull();
expect(cascadedPadding(palette, 'right', 626, false)).toBe('0.75rem');
expect(cascadedPadding(palette, 'bottom', 626, false)).toBe('0');
+260
View File
@@ -0,0 +1,260 @@
/**
* @fileoverview Static guard: no NEW CLI-id branch in the two files PR B2 touched
* (`session-ui.js`, `mobile-overview.js`), mirroring
* `test/cli-registry-no-id-branching.test.ts` for the backend registry.
*
* Deliberately scoped to ONLY these two files, not all of `src/web/public/`.
* `docs/cli-registry.md` and CLAUDE.md are explicit that the rest of the
* frontend (`app.js`, `terminal-ui.js`, `styles.css`, `settings-ui.js`, …)
* keeps its own hand-authored per-CLI rules deliberately — "moving them is
* its own piece of work verified by a browser/mobile suite the CI gate cannot
* see." Widening this guard to the whole directory would force either fixing
* or allowlisting dozens of branches in files nobody has touched or reviewed
* for this change, which is scope B2 never took on.
*
* Port: none (pure static analysis).
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const PUBLIC = fileURLToPath(new URL('../src/web/public/', import.meta.url));
const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js'];
/**
* Every currently-surviving branch, each with the COUNT of physical call
* sites carrying it and the reason none of them is a `CliCapabilities`
* field, keyed `<file>::<the matched expression>` — deliberately NO line
* number. An earlier version keyed on `<file>::<line>::<expression>`, and
* inserting one comment line at the top of `session-ui.js` shifted every
* subsequent line number, so all 21 entries went stale and the same 21
* branches were then reported as "new". `session-ui.js` is one of the most
* contended files in the repo, so a guard that goes red on any unrelated
* edit to it sends the next person after the wrong problem.
*
* The `count` is what closes the gap dropping the line number opened: a key
* alone says "this expression is approved somewhere in this file", so a
* BRAND NEW `mode === 'codex'` site anywhere in `session-ui.js` would reuse
* the same key as the two approved ones and pass silently. The count makes
* that a mismatch — one more occurrence than declared — and the "counts
* match" test below catches it, while a genuinely new expression (a CLI id
* with no ALLOWED_BRANCHES entry at all) is still caught by the separate
* "no unapproved id branches" test either way.
*/
const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = {
"session-ui.js::mode === 'shell'": {
count: 2,
reason:
'run() dispatch (shell needs no CLI probe at all) and the button-label ternary (pinned exact ' +
"text — test/run-mode-ui.test.ts asserts e.g. 'Run OMP', which diverges from CliEntry.shortBadge " +
"for at least omp ('OM' vs the displayed 'OMP'), so a catalogue-driven rewrite would silently " +
'change user-visible text and break that pinned test; the maintainer confirmed leaving this ' +
'hardcoded, see the PR #458 review thread)',
},
"session-ui.js::mode === 'claude'": {
count: 4,
reason:
'four claude-specific call sites, not one branch: run() dispatch (claude has its own ' +
'remote/docker branching and parallel-create path, unlike every RUN_MODE_LAUNCH entry), ' +
'runCustomModelEntry() (restart-vs-one-shot launch mechanism, not a preference — see ' +
"CLAUDE.md's Custom Model Endpoint Profiles section), the Respawn/Ralph section (claude-only " +
"by design, mirroring the backend capabilities.ralph gate), and the runMode setter's " +
'validity check',
},
// The 8 external CLIs share the same two call sites and the same reason at
// each: the button-label ternary (see the shell entry above for why it
// stays hardcoded) and the runMode property setter's validity allowlist
// (not a behaviour branch; left hardcoded in Phase 2 since its chain has
// no shell arm at all and no evidence of what callers rely on it).
"session-ui.js::mode === 'opencode'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
"session-ui.js::mode === 'codex'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
"session-ui.js::mode === 'gemini'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
"session-ui.js::mode === 'antigravity'": {
count: 2,
reason: 'button-label ternary + runMode setter validity check',
},
"session-ui.js::mode === 'pi'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
"session-ui.js::mode === 'grok'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
"session-ui.js::mode === 'deepseek'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
"session-ui.js::mode === 'omp'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
// The docker adopt-preflight status line and the docker link/adopt toast
// both list the agent CLIs probed INSIDE the container and leave `shell`
// out of that human-readable "found ..." summary (it is always present and
// is not an agent CLI). Written as `(m) => m !== 'shell'`, the naming the
// original named-variable pattern could not see; the widened pattern
// normalizes the `m` to `mode` (see BRANCH_PATTERN below).
"session-ui.js::mode !== 'shell'": {
count: 2,
reason: 'display filter: the "CLIs found inside the container" summaries omit shell, which is not an agent CLI',
},
// mobile-overview.js: shell is exempt from the isCliAvailable() gate the
// same way the toolbar's #runModeMenu exempts it (shell needs no CLI).
"mobile-overview.js::mode !== 'shell'": {
count: 1,
reason: 'shell needs no CLI, so it is exempt from the availability gate',
},
};
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
const IDS = STOCK_CLIS.map((e) => e.id as string);
const ID_ALT = IDS.join('|');
/**
* The backend guard's four shapes (see its own comment for why all four
* matter), with ONE deliberate widening on the first.
*
* The backend pattern accepts a comparison only when its left-hand side is
* literally named `mode`, `id` or `agentType`, so both
* `const m = this._runMode; if (m === 'codex')` and
* `if (this._runMode !== 'gemini')` slip past it, and `session-ui.js` already
* uses exactly that naming (`(m) => m !== 'shell'`, twice). The review of
* PR #458 surfaced that blind spot, so here the left-hand side is ANY
* identifier (`[\w$]+`, the leaf of a member chain), normalized to `mode` in
* the allowlist key by `scan()` so a local rename never churns the entries.
* Measured over both scanned files before widening: every extra hit was a
* genuine mode comparison (the two `m !== 'shell'` filters, allowlisted
* above), so the widening added no false positive; a future one gets an
* allowlist entry with its reason like any other. The backend guard keeps
* its narrower form and is deliberately not changed here.
*
* Still unseen, and worth knowing: a Yoda comparison (`'codex' === mode`),
* and an id list held in a variable (`EXTERNAL.includes(mode)`), since the
* third shape needs the literal list inline.
*/
const BRANCH_PATTERN = new RegExp(
[
// <identifier> === 'codex' / <identifier> !== 'codex' (any left-hand identifier, see above)
`\\b[\\w$]+\\s*[!=]==\\s*'(?:${ID_ALT})'`,
// case 'codex':
`\\bcase\\s+'(?:${ID_ALT})'\\s*:`,
// ['codex', 'gemini'].includes(mode) — the id list IS the branch, wherever `mode` sits
`'(?:${ID_ALT})'\\s*(?:,\\s*'(?:${ID_ALT})'\\s*)*\\]\\s*\\.includes\\(`,
].join('|'),
'g'
);
/** Blanks comment lines before scanning — see the backend guard's own comment on why. */
function uncommented(source: string): string {
return source
.split('\n')
.map((line) => (/^\s*(\/\/|\*|\/\*)/.test(line) ? '' : line))
.join('\n');
}
interface Finding {
file: string;
expression: string;
line: number;
key: string;
}
function scan(): Finding[] {
const findings: Finding[] = [];
for (const file of SCANNED_FILES) {
const lines = uncommented(readFileSync(PUBLIC + file, 'utf-8')).split('\n');
lines.forEach((line, i) => {
BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts
for (const match of line.matchAll(BRANCH_PATTERN)) {
// Normalize the comparison shape's left-hand identifier (whatever the
// local is called: `id`, `agentType`, `m`, `_runMode`) to `mode`; the
// lookahead leaves the `case`/`.includes(` shapes untouched.
const expression = match[0].replace(/\s+/g, ' ').replace(/^[\w$]+(?=\s*[!=]==)/, 'mode');
findings.push({ file, expression, line: i + 1, key: `${file}::${expression}` });
}
});
}
return findings;
}
const findings = scan();
function actualCounts(): Map<string, number> {
const counts = new Map<string, number>();
for (const f of findings) counts.set(f.key, (counts.get(f.key) ?? 0) + 1);
return counts;
}
describe('no NEW CLI-id branching in session-ui.js / mobile-overview.js (PR B2)', () => {
it('scans both files (sanity)', () => {
// If this drops to zero the scanner or the file list drifted and every
// assertion below would pass vacuously.
const scannedBytes = SCANNED_FILES.reduce((n, f) => n + readFileSync(PUBLIC + f, 'utf-8').length, 0);
expect(scannedBytes).toBeGreaterThan(10_000);
});
it('builds its id list from the live catalog (sanity)', () => {
expect(IDS).toContain('claude');
expect(IDS).toContain('deepseek');
expect(IDS.length).toBeGreaterThanOrEqual(9);
});
it('still detects a branch when one exists (anti-vacuity)', () => {
const samples = [
"if (session.mode === 'codex') { doSomething(); }",
"if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }",
"switch (mode) { case 'gemini': return 1; }",
"if (['codex', 'gemini'].includes(mode)) { doSomething(); }",
// The two forms the named-variable pattern was blind to (see BRANCH_PATTERN).
"const m = this._runMode; if (m === 'codex') { doSomething(); }",
"if (this._runMode !== 'gemini') { doSomething(); }",
];
for (const sample of samples) {
BRANCH_PATTERN.lastIndex = 0;
expect(sample.match(BRANCH_PATTERN), `pattern missed: ${sample}`).not.toBeNull();
}
BRANCH_PATTERN.lastIndex = 0;
expect(uncommented(" // mode === 'codex'\ncode();").match(BRANCH_PATTERN)).toBeNull();
});
it('has no unapproved id branches', () => {
const offenders = findings.filter((f) => !(f.key in ALLOWED_BRANCHES));
const detail = offenders.map((f) => ` ${f.file}:${f.line} ${f.expression}`).join('\n');
expect(
offenders,
offenders.length === 0
? ''
: `Found ${offenders.length} new CLI-id branch(es) in session-ui.js/mobile-overview.js:\n${detail}\n\n` +
'Two ways out, in order of preference:\n' +
' 1. Derive the difference from a shared module-level constant, the way\n' +
' _runCliMode()/RUN_MODE_LAUNCH/EXTERNAL_CLI_MODES do.\n' +
' 2. If it is a genuine mechanism difference (not a CLI-behaviour branch), add it to\n' +
' ALLOWED_BRANCHES in this file WITH the reason.'
).toEqual([]);
});
it('every allowlisted branch occurs exactly its declared number of times', () => {
// This is what closes the gap the line-number removal opened (see the
// ALLOWED_BRANCHES header comment): a key alone cannot tell "the two
// approved sites" from "the two approved sites plus a brand new third
// one reusing the same expression" — the count can. A mismatch in
// either direction is real: higher means an unreviewed NEW branch
// landed reusing an approved expression, lower means one of the
// reviewed call sites was removed and the entry is now a stale lie
// about the codebase (the count going to 0 is the old "stale entry"
// case, now folded into this same check rather than a separate one).
const actual = actualCounts();
const mismatches: string[] = [];
for (const [key, { count: expected }] of Object.entries(ALLOWED_BRANCHES)) {
const got = actual.get(key) ?? 0;
if (got !== expected) {
mismatches.push(` ${key} expected ${expected}, found ${got}`);
}
}
expect(
mismatches,
mismatches.length === 0
? ''
: `ALLOWED_BRANCHES count mismatch(es):\n${mismatches.join('\n')}\n\n` +
'A count LOWER than declared means a reviewed call site was removed — update or delete ' +
'the entry. A count HIGHER than declared means a NEW branch landed reusing an already-' +
'approved expression — review it and bump the count (or fix the branch) explicitly, ' +
'rather than let it ride in on an existing approval.'
).toEqual([]);
});
});
+19
View File
@@ -201,6 +201,25 @@ describe('buildCloneArgs / buildLsRemoteArgs', () => {
'd',
]);
});
it('clears every credential helper, BEFORE the subcommand, only when asked', () => {
// Multi-user non-admin clones must not borrow the server account's git sign-in.
// `-c` is a global option: after `clone` git would read it as an unknown flag.
expect(
buildCloneArgs({ repository: 'https://example.com/r.git', destination: 'd', withoutCredentialHelpers: true })
).toEqual(['-c', 'credential.helper=', 'clone', '--', 'https://example.com/r.git', 'd']);
expect(buildLsRemoteArgs('https://example.com/r.git', { withoutCredentialHelpers: true })).toEqual([
'-c',
'credential.helper=',
'ls-remote',
'--symref',
'--',
'https://example.com/r.git',
]);
// Absent or false leaves the argv exactly as it was before the option existed.
expect(buildCloneArgs({ repository: 'r', destination: 'd', withoutCredentialHelpers: false })[0]).toBe('clone');
expect(buildLsRemoteArgs('r', {})[0]).toBe('ls-remote');
});
});
describe('gitNonInteractiveEnv', () => {
+5 -1
View File
@@ -127,7 +127,11 @@ describe('the in-terminal truncation line is gone (static guard)', () => {
expect(app).toContain("session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId)");
expect(app).toContain("!restoredSnapshot && session?.mode !== 'shell'");
expect(app).toContain('`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`');
expect(app).toContain('fetch(`/api/sessions/${sessionId}/terminal?full=1`)');
// Every terminal capture now goes through _fetchTerminalCapture, which adds
// an abort deadline (a `?full=1` body can be megabytes and used to hang
// indefinitely on a stalled mobile link). The URL and the full-vs-tail
// decision this guard exists to pin are unchanged.
expect(app).toContain('this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true })');
expect(app).toContain("if (this.sessions.get(sessionId)?.mode !== 'shell')");
expect(app).toContain("if (session?.mode === 'shell')");
expect(app).toContain("if (!force && session?.mode === 'shell') return;");
+47
View File
@@ -356,3 +356,50 @@ describe('home screens: one order, one numbering', () => {
expect(branch).not.toContain('this.sessionOrder[idx]');
});
});
describe('home sessions column: watching badge', () => {
it('carries the label off the session payload', () => {
const app = loadHomeSessionsApp({
sessions: sessionMap([{ id: 'watcher', watching: '1 monitor' }, { id: 'plain' }]),
sessionOrder: ['watcher', 'plain'],
cases: CASES,
});
const rows = Object.fromEntries(app.buildHomeSessionRows().map((r: any) => [r.id, r.watching]));
expect(rows).toEqual({ watcher: '1 monitor', plain: '' });
});
it("builds the badge with the phone overview, in the rail's own pill class", () => {
// Both home screens must word this badge identically, so the rail borrows the
// builder rather than writing a second one — the same arrangement it already has
// for state classification and for the stamp wording.
const app = loadHomeSessionsApp({
sessions: sessionMap([{ id: 'watcher', watching: '2 shells' }]),
sessionOrder: ['watcher'],
cases: CASES,
});
const row = app._buildHomeSessionRow(app.buildHomeSessionRows()[0]);
const badges = collect(row).filter((el: any) => String(el.className).includes('--watching'));
expect(badges).toHaveLength(1);
expect(badges[0].className).toBe('home-sessions-pill home-sessions-pill--watching');
expect(badges[0].textContent).toBe('watching');
expect(badges[0].title).toBe('Still running in the background: 2 shells');
});
it('draws no badge for a session running nothing in the background', () => {
const app = loadHomeSessionsApp({
sessions: sessionMap([{ id: 'plain' }]),
sessionOrder: ['plain'],
cases: CASES,
});
const row = app._buildHomeSessionRow(app.buildHomeSessionRows()[0]);
expect(collect(row).filter((el: any) => String(el.className).includes('--watching'))).toHaveLength(0);
});
});
/** Every node under one the builders made, the row itself included. */
function collect(node: any): any[] {
return [node, ...(node.children || []).flatMap((child: any) => collect(child))];
}
+39 -3
View File
@@ -17,7 +17,9 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
const imageInputSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/image-input.js'), 'utf8');
interface TrapListener {
(e: Record<string, unknown>): void;
@@ -91,8 +93,7 @@ function loadPasteHarness(): Harness {
});
vm.runInContext('class CodemanApp {}', context);
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/image-input.js'), 'utf8');
vm.runInContext(src, context, { filename: 'image-input.js' });
vm.runInContext(imageInputSource, context, { filename: 'image-input.js' });
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
const pastedText: string[] = [];
@@ -133,6 +134,20 @@ function loadPasteHarness(): Harness {
};
}
function loadImageInputApp() {
const context = vm.createContext({ console, window: {}, document: {} });
vm.runInContext('class CodemanApp {}', context);
vm.runInContext(imageInputSource, context, { filename: 'image-input.js' });
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
const app = new CodemanApp();
app.activeSessionId = 'session-1';
app.showToast = vi.fn();
app.sendInput = vi.fn(async () => {});
app._normalizeImageForUpload = vi.fn(async (file) => file);
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path);
return app as Record<string, any>;
}
describe('Ctrl+V paste trap', () => {
it('sends clipboard text to the terminal once for a single paste event', () => {
const h = loadPasteHarness();
@@ -174,3 +189,24 @@ describe('Ctrl+V paste trap', () => {
expect(h.attachedTraps()).toBe(0);
});
});
describe('image upload insertion policy', () => {
it('returns ordered paths without terminal insertion when requested by the composer', async () => {
const app = loadImageInputApp();
const files = [{ path: '/tmp/first.png' }, { path: '/tmp/second.png' }];
const paths = await app._uploadAndInsertImages(files, { insert: false });
expect(Array.from(paths)).toEqual(['/tmp/first.png', '/tmp/second.png']);
expect(app.sendInput).not.toHaveBeenCalled();
});
it('preserves terminal insertion by default', async () => {
const app = loadImageInputApp();
const paths = await app._uploadAndInsertImages([{ path: '/tmp/legacy.png' }]);
expect(Array.from(paths)).toEqual(['/tmp/legacy.png']);
expect(app.sendInput).toHaveBeenCalledWith('/tmp/legacy.png');
});
});
+228 -1
View File
@@ -179,8 +179,13 @@ describe('install.sh runtime safety', () => {
/if \[\[ -n "\$\{CODEMAN_INSTALL_SH_LIB:-\}" \]\]; then return 0 2>\/dev\/null \|\| exit 0; fi/
);
const guardAt = SOURCE.indexOf('CODEMAN_INSTALL_SH_LIB');
const dispatchAt = SOURCE.indexOf('case "${1:-}" in');
const dispatchAt = SOURCE.indexOf('case "$SUBCOMMAND" in');
expect(dispatchAt, 'the dispatch case must exist').toBeGreaterThan(-1);
expect(guardAt, 'the sourcing guard must precede the dispatch case').toBeLessThan(dispatchAt);
// parse_flags runs only in the dispatch tail, after the guard: a sourced copy must
// never consume the harness's own arguments.
const parseAt = SOURCE.indexOf('\nparse_flags "$@"');
expect(parseAt, 'parse_flags must be invoked after the sourcing guard').toBeGreaterThan(guardAt);
});
it('still sets the strict flags it has always run under', () => {
@@ -205,6 +210,104 @@ describe('install.sh DeepSeek identity probe', () => {
});
});
describe('install.sh owns the build and the start', () => {
it('runs npm install with CODEMAN_NO_AUTOSTART=1', () => {
// scripts/postinstall.js builds dist/ and starts a detached `codeman web` on its
// own unless told not to. Under the installer that orphan made the service
// crash-loop on EADDRINUSE while the done screen reported "running" off the
// orphan (fresh Ubuntu 24 sandbox, 2026-09-20). Every npm install here must
// carry the opt-out.
// Executed installs only: the catalogue's `npm install -g` literals and the
// failure message that quotes the command are prose here.
const installs = CODE_LINES.filter(
(line) => /\bnpm install\b/.test(line) && !/npm install -g/.test(line) && !/\b(error|warn|info|echo) "/.test(line)
);
expect(installs.length, 'expected the one npm install call').toBeGreaterThan(0);
for (const line of installs) {
expect(line, `npm install without CODEMAN_NO_AUTOSTART=1:\n ${line}`).toContain('CODEMAN_NO_AUTOSTART=1');
}
});
});
describe('install.sh Tailscale safety rules', () => {
// Every rule here protects config that is not ours. `serve reset` destroys a user's
// unrelated serve mappings (the maintainer's own node carries two); funnel is the
// public internet, a different risk class than tailnet-only serve; advertising a
// Tailscale Service requires a tagged node and admin approval and is documented
// as a hint only. All three are pinned as absences.
it('never runs `tailscale serve reset`', () => {
const offenders = CODE_LINES.filter((line) => /serve\s+reset\b/.test(line));
expect(offenders).toEqual([]);
});
it('never runs `tailscale funnel` and never advertises a Tailscale Service', () => {
// The installer's own `--service` flag (run as a service) is not Tailscale's
// `--service=svc:<name>`; the pin keys on the svc: prefix and the serve form.
const offenders = CODE_LINES.filter(
(line) => /\bfunnel\b/.test(line) || /\bsvc:/.test(line) || /\bserve\b.*--service/.test(line)
);
expect(offenders).toEqual([]);
});
it('routes every serve mutation through ts_cmd_serve (the sudo-aware wrapper)', () => {
// A bare `tailscale serve --bg` or `set --hostname` would fail for a non-operator
// user on Linux, exactly the state the wrapper exists to handle.
const mutations = CODE_LINES.filter((line) => /\bserve --(bg|https)/.test(line) || /\bset --hostname\b/.test(line));
expect(mutations.length).toBeGreaterThan(0);
for (const line of mutations) {
// Prose in warn/info strings and manual-command hints are fine; executed lines
// must start with the wrapper.
const executed = /^\s*(if\s+)?(!\s*)?(out=\$\()?ts_cmd_serve\b/.test(line);
const quoted = /(info|warn|echo -e|success) /.test(line) || /Run: /.test(line) || /Configuring: /.test(line);
expect(executed || quoted, `serve mutation outside ts_cmd_serve:\n ${line}`).toBe(true);
}
});
it('decides the rename before the serve shape, and applies serve only after the build', () => {
// Serve config is keyed by the DNS name it was written under: renaming after
// configuring would orphan the mapping (and only `serve reset` could remove the
// stale key). tailscale_prepare therefore asks the name first, chooses the shape
// second, and main() applies the shape only after the build and the service.
const prepare = SOURCE.slice(SOURCE.indexOf('tailscale_prepare() {'), SOURCE.indexOf('tailscale_apply() {'));
expect(prepare.indexOf('tailscale_choose_name')).toBeGreaterThan(-1);
expect(prepare.indexOf('tailscale_choose_name')).toBeLessThan(prepare.indexOf('tailscale_choose_mapping'));
const main = SOURCE.slice(SOURCE.indexOf('\nmain() {'), SOURCE.indexOf('\npreflight_detect() {'));
const order = [
'choose_network_binding',
'choose_launch_mode',
'install_or_update_repo',
'npm_install_deps',
'run_step "Building Codeman"',
'tailscale_apply',
'print_done_screen',
];
const positions = order.map((needle) => main.indexOf(needle));
for (let i = 0; i < positions.length; i++) {
expect(positions[i], `${order[i]} missing from main()`).toBeGreaterThan(-1);
if (i > 0) expect(positions[i], `${order[i]} must come after ${order[i - 1]}`).toBeGreaterThan(positions[i - 1]);
}
});
it('documents every flag it parses', () => {
// The header comment is the only manual most people read (it is what `curl` shows
// them if they look). A flag parse_flags accepts and the header does not mention
// is a flag nobody finds.
const header = SOURCE.slice(0, SOURCE.indexOf('set -euo pipefail'));
const parse = SOURCE.slice(SOURCE.indexOf('parse_flags() {'), SOURCE.indexOf('# Sourcing guard'));
const flags = Array.from(parse.matchAll(/^\s+(--[a-z-]+)(?:[|)=\s])/gm), (m) => m[1]);
expect(flags.length).toBeGreaterThan(5);
for (const flag of new Set(flags)) {
expect(header.includes(flag), `${flag} is parsed but not documented in the header`).toBe(true);
}
});
it('renames only as an opt-in: the question defaults to no and --yes never renames', () => {
const fn = SOURCE.slice(SOURCE.indexOf('tailscale_choose_name() {'), SOURCE.indexOf('tailscale_rename_node() {'));
expect(fn).toMatch(/prompt_yes_no "Rename this machine to \$suggested\?" "n"/);
expect(fn).toMatch(/\[\[ "\$ASSUME_YES" == "1" \]\]/);
});
});
describe('install.sh AI CLI install menu', () => {
// The menu is the one interactive path in the script, which is why it used to be the
// only part nothing exercised: choosing "s" (Skip) once fell straight into the shared
@@ -304,3 +407,127 @@ describe('install.sh detect_all_clis and a disabled entry', () => {
expect(run.stdout).toContain('found=0');
});
});
describe('install.sh review fixes for #460', () => {
// Each pin here is a finding from the two reviews of PR #460 (the DeepSeek Harness
// pass, then the Claude pass), kept as a static guard so the fix cannot quietly rot.
const fn = (name: string, until: string) => {
const start = SOURCE.indexOf(`${name}() {`);
expect(start, `${name}() missing`).toBeGreaterThan(-1);
const end = SOURCE.indexOf(until, start);
expect(end, `${until} missing after ${name}()`).toBeGreaterThan(start);
return SOURCE.slice(start, end);
};
it('keeps an existing password on the flag and env preset paths', () => {
// `--lan --service` on a unit that carried a password used to rewrite it without the
// password and with the unauthenticated ack; `--tailscale` dropped it the same way.
const body = fn('choose_network_binding', 'get_tailscale_path() {');
expect(body.match(/BIND_PASSWORD="\$\{CODEMAN_PASSWORD:-\$EXISTING_PASSWORD\}"/g)?.length).toBe(2);
expect(body).not.toMatch(/BIND_PASSWORD="\$\{CODEMAN_PASSWORD:-\}"/);
// The presets can only keep what was read, so the read comes first.
expect(body.indexOf('read_existing_binding')).toBeLessThan(body.indexOf('CODEMAN_HOST:-'));
});
it('composes the hand-start environment in one place', () => {
// "Do not start" under a sub-path or a custom port used to print a bare `codeman web`
// under URLs that carried both.
const hint = fn('start_command_hint', 'export_bind_env() {');
const exported = fn('export_bind_env', '# A QR code of the URL');
for (const key of [
'CODEMAN_HOST',
'CODEMAN_PASSWORD',
'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK',
'CODEMAN_BASE_URL',
'CODEMAN_PORT',
]) {
expect(hint, `${key} missing from start_command_hint`).toContain(key);
expect(exported, `${key} missing from export_bind_env`).toContain(key);
}
const done = fn('print_done_screen', '\nupdate() {');
expect(done).toContain('$(start_command_hint)');
expect(done).not.toMatch(/CODEMAN_HOST=0\.0\.0\.0 codeman web/);
});
it('flips RECONFIGURE for --password and --port', () => {
// Neither used to, so on a completed install both took the quiet update path, which
// never rewrites the unit: the password never landed and the port stayed at 3000.
const parse = fn('parse_flags', '# Sourcing guard');
for (const label of ['--password)', '--password=*)', '--port)', '--port=*)']) {
const at = parse.indexOf(label);
expect(at, `${label} missing`).toBeGreaterThan(-1);
expect(parse.slice(at, parse.indexOf(';;', at)), `${label} does not reconfigure`).toContain('RECONFIGURE="1"');
}
});
it('ends the sudo keepalive and exports the binding before the exec', () => {
// exec skips the EXIT trap, and the keepalive keys on $$, which becomes the server's
// pid: it refreshed the sudo timestamp for the server's whole life.
const execAt = SOURCE.indexOf('exec node "$INSTALL_DIR/dist/index.js" web');
expect(execAt).toBeGreaterThan(-1);
const before = SOURCE.slice(SOURCE.lastIndexOf('source "$profile"', execAt), execAt);
expect(before).toContain('export_bind_env');
expect(before).toContain('stop_background_helpers');
});
it('lets Ctrl+C skip the HTTPS-toggle poll instead of ending the run', () => {
const body = fn('ensure_tailnet_https', 'tailnet_https_poll() {');
expect(body).toMatch(/trap '[^']*' INT/);
expect(body).toContain('trap - INT');
expect(fn('tailnet_https_poll', '# Everything Tailscale that needs a human')).toContain('sleep 5 || true');
});
it('asks before removing a LaunchDaemon it never wrote', () => {
const body = fn('uninstall', '\nusage() {');
const ask = body.indexOf('prompt_yes_no "Remove that LaunchDaemon too');
expect(ask).toBeGreaterThan(-1);
expect(body.indexOf('sudo rm -f "$daemon_plist"')).toBeGreaterThan(ask);
});
it('re-syncs the unit after `install.sh name` re-adds the mapping, and ends an update on the done screen', () => {
const name = fn('setup_name_subcommand', '\nstatus_subcommand() {');
expect(name.indexOf('sync_service_base_url')).toBeGreaterThan(name.indexOf('tailscale_choose_mapping'));
expect(name.indexOf('sync_service_base_url')).toBeLessThan(name.indexOf('tailscale_apply'));
expect(fn('update', '\nuninstall() {')).toContain('print_done_screen "" ""');
});
it('reads the Tailscale state in the preflight without node, and no longer records TS_JOINED_HERE', () => {
const preflight = fn('preflight_detect', '\nprint_preflight_summary() {');
expect(preflight).toContain('ts_backend_state');
expect(preflight).not.toContain('command -v node');
expect(fn('ts_backend_state', '\nts_dns_name() {')).toContain('sed -n');
expect(SOURCE).not.toContain('TS_JOINED_HERE');
expect(CODE).not.toContain('at port 3000');
});
it('drives the kept password and the start line in a real bash', () => {
const DRIVER = `
set -euo pipefail
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
read_existing_binding() { EXISTING_FOUND=1; EXISTING_HOST=0.0.0.0; EXISTING_PASSWORD=s3cret; EXISTING_ACK=0; EXISTING_BASE_URL=""; }
tailscale_prepare() { return 0; }
parse_flags $DRIVE_FLAGS
choose_network_binding >/dev/null 2>&1
echo "host=$BIND_HOST pw=$BIND_PASSWORD ack=$BIND_ACK"
BIND_HOST=0.0.0.0; BIND_PASSWORD=x; BIND_ACK=0; BIND_BASE_URL=/codeman; CODEMAN_PORT=4000
echo "hint=$(start_command_hint)"
BIND_HOST=127.0.0.1; BIND_PASSWORD=""; BIND_BASE_URL=""; CODEMAN_PORT=""
echo "bare=$(start_command_hint)"
`;
const drive = (flags: string) => {
const env = { ...process.env, DRIVE_FLAGS: flags };
delete env.CODEMAN_PASSWORD;
const result = spawnSync('bash', ['-c', DRIVER, 'bash', INSTALL_SH], { encoding: 'utf-8', timeout: 30_000, env });
expect(result.status, result.stderr).toBe(0);
return result.stdout;
};
const lan = drive('--lan');
expect(lan).toContain('host=0.0.0.0 pw=s3cret ack=0');
expect(lan).toContain(
"hint=CODEMAN_HOST=0.0.0.0 CODEMAN_PASSWORD='<your-password>' CODEMAN_BASE_URL=/codeman CODEMAN_PORT=4000 codeman web"
);
expect(lan).toContain('bare=codeman web');
expect(drive('--tailscale')).toContain('host=127.0.0.1 pw=s3cret ack=0');
});
});

Some files were not shown because too many files have changed in this diff Show More