Introduce src/config/terminal-history.ts: one place for terminal scrollback,
tmux history-limit, and PTY buffer byte caps, each overridable via env var or
the settings object and bounds-clamped via resolveTerminalHistoryConfig().
Defaults match the prior hardcoded values, so this is behavior-neutral. Wires
the resolver through buffer-limits, tmux-manager (incl. a setHistoryLimit so a
settings change applies live), session, server, system-routes, session-routes,
schemas, and the config port. Adds 4 optional settings keys (terminalScrollback
Lines, tmuxHistoryLimit, terminalBufferMaxBytes, terminalBufferTrimBytes) with
bounds + a trim<=max cross-check.
Gemini (PR #134) blockers:
- runGemini() now unwraps the {success,data} envelope: status check reads
.data.available, quick-start reads data.data.sessionId (was reading the raw
shape, so the Run-Gemini button could never start a session).
- setGeminiEnvVars() now uses the socket-scoped ${this.tmux()} setenv instead of
bare tmux — Gemini/Google auth env vars were targeting the wrong tmux server
and silently failing on every install.
Gemini parity polish:
- gemini tab-mode badge ('gm') + .tab-mode.gemini CSS; kill-dialog label
'Kill Tmux & Gemini'; codeman doctor dependency-registry entry; export
isGeminiAvailable from utils barrel; COLORTERM=truecolor + unset NO_COLOR;
add gemini to isAltScreenStripMode (Ink TUI, repaints inline like Codex/Claude).
- Revert 4 system-routes.test.ts envelope assertions weakened to
(body.message ?? body.error) back to (body.success === false).
- Add a runGemini() vm-sandbox test that drives the envelope path end-to-end.
Ralph todo-config (PR #135): maxTodos/todoExpirationMinutes are now persisted
and read back — surfaced via the loopState getter (RalphTrackerState) into
toState()/SSE broadcast and restored in restoreState(), mirroring maxIterations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switching away from a session and back replayed only the server's byte
history. For TUI modes (codex especially) that shows just the latest
repaint — the idle banner — because the TUI drops earlier conversation
from its current frame. This restores the actual on-screen view.
Two complementary mechanisms:
- Client: load xterm's SerializeAddon and snapshot the rendered state
(viewport + scrollback + colors) per session on switch-away, restoring
it for an instant first paint on switch-back. The snapshot is only the
first paint — the canonical /terminal frame is still fetched and
reconciled (restoredSnapshot/clearedForBusy force the replay). Snapshots
are LRU-bounded in memory (<=20) and persisted to localStorage
(<=256KB each, <=10 sessions, stale-pruned) so they survive tab discard.
- Server: GET /api/sessions/:id/terminal prepends the live tmux pane
buffer (via the existing captureActivePaneBuffer) ahead of the byte
history, cleared between, so replay reflects the current frame.
Also fix formatPaneSnapshot dropping the rightmost column of every
captured row: it painted to cols - 1 out of caution about last-column
autowrap, but every row is followed by an absolute cursor-position CSI
that cancels xterm's pending-wrap, so painting the full width is safe.
The SerializeAddon is built from @xterm/addon-serialize (new dependency)
into the vendor bundle by postinstall.js (dev) and build.mjs (prod),
matching how the other xterm addons are vendored.
Review fixes for COD-54:
- Generated hook curl commands now present X-Codeman-Hook-Secret, read from
the secret file AT EXECUTION TIME via $CODEMAN_HOOK_SECRET_FILE (exported
into every managed session's env by tmux buildEnvExports / the direct-PTY
env builders). Without this, every local hook 401'd the moment a managed
tunnel came up — the enforcement existed but nothing presented the secret.
Path-not-value keeps the secret off command lines and out of config files,
and running sessions pick up a newly generated secret with no respawn;
server.start() ensures the file exists up front.
- Hook-secret failures now count into a DEDICATED per-IP bucket
(hookSecretFailures) instead of the shared authFailures map. Legacy
(pre-secret) hook configs fire constantly from 127.0.0.1; counting their
401s against the shared bucket would 429 every cookie-less loopback
request — locking out the Basic-Auth login path (and, through a tunnel,
every client, since tunneled traffic also arrives as 127.0.0.1).
- docs/security-architecture.md: secret-gated hook exemption, dedicated
bucket, COD-55 refusal, and the residual caveat for EXTERNAL loopback
proxies (user-run cloudflared / tailscale serve), which the
managed-tunnel probe cannot see.
- test/cod54-hook-event-auth.test.ts: +3 tests — login path unaffected
after hook-bucket exhaustion; generated hooks reference the header +
$CODEMAN_HOOK_SECRET_FILE without embedding the value; env builders
export the path only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflict in src/web/public/app.js selectSession: combined #112's
_clearTerminalLoadState cleanup on stale select with #113's
{success,data} envelope unwrap of the terminal fetch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the PR #112 re-review (all six prior blockers were already
resolved; these are new issues the rework introduced):
- app.js: define the missing `_scheduleTerminalRepaint()` helper. It was
called from both WebGL-fallback paths (onContextLoss + long-task trip)
but defined nowhere, so each fallback threw `TypeError` and lost the
post-fallback repaint, leaving a stale/blank terminal. Implemented as an
rAF-debounced full refresh (matches the old inline `terminal.refresh`).
- app.js: clear terminal load-state on the two post-write stale-select
early-returns (cached-buffer + rewrite branches), matching the other
four checks. Switching away from a mid-loading tab no longer leaks a
permanent `.tab-loading` spinner / `aria-busy=true`.
- terminal-ui.js + app.js: gate the post-resize TUI-redraw settle on an
actual dimension change. `sendResize` now returns whether dims changed;
a same-size tab switch sends no SIGWINCH, so the wait is skipped instead
of charging a flat tax on every non-shell switch. Literal hoisted to
`TUI_REDRAW_SETTLE_MS`.
- tmux-manager.ts: `resizeWindow()` uses a non-blocking `exec` instead of
`execSync` so the interactive WS/HTTP resize path can't stall the
Fastify event loop on a slow/hung tmux. Sole caller already fire-and-
forgets the result; test updated to assert the async dispatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mode-agnostic terminal foundation extracted from the downstream branch:
- formatPaneSnapshot: SGR/grapheme-aware tmux pane capture + active-pane
resolution, with OSC/CSI redraw suppression and the buffer-load owner-token
race fix on the terminal fetch path
- socket-correct tmux lifecycle: dedicated -L socket and /tmp launch cwd in
createSession (restores the FUSE/getcwd hardening from #110), and a
socket-aware re-attach window-size query (avoids the 120x40 flicker)
- inline-rename: commit/cancel state handling clears _activeRename and skips
the API call on cancel
- selectSession: restored detached-window raise short-circuit
The codex-specific xterm snapshot/replay, the vendored serialize addon, and
the synchronous live pane-capture on the request path are intentionally
excluded: they depend on a 'codex' SessionMode that doesn't exist on master
and are deferred to COD-34 (which introduces that mode). The capture
primitives remain exported for COD-34 to build on.
Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
- M7 (SSRF): add isSafePushEndpoint (https-only; reject internal/loopback/link-local/metadata IPs incl. IPv4-mapped); enforce in PushSubscribeSchema and re-check before webpush.sendNotification. + unit test.
- M1 (command injection): validate tmux session names with isValidMuxName in sessionExists, killSession, and reconcileSessions before they reach a shell call site.
- M5: keep the intentional /var/log + ~/logs log-tail roots (a tested feature) and document the wider read scope in docs/security-architecture.md section 5 instead of dropping it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CLAUDE_CODE_EFFORT_LEVEL hard-locks effort for the whole session and makes
Claude reject in-session /effort switching (incl. ultracode). Carry effort
as a dedicated payload field instead, injected at spawn as a soft default:
- regular levels (incl. max) -> claude --effort <level>
(the settings effortLevel key is enum([low,medium,high,xhigh]) with
.catch(undefined), so max would be silently dropped there)
- ultracode -> claude --settings '{"ultracode":true}'
(dedicated boolean settings key, rejected by the --effort flag)
Changes:
- add effort enum field to create/quick-start/ralph-loop schemas and thread
it through Session -> CreateSessionOptions/RespawnPaneOptions -> spawn
- buildEffortCliArgs() in session-cli-builder, shared by tmux spawn command
and direct-PTY fallback args
- frontend: buildEnvOverrides() no longer emits CLAUDE_CODE_EFFORT_LEVEL;
validated effort goes into payloads via getEffortSetting()
- settings UI: add Ultracode option to the Thinking Effort dropdown
- legacy migration: Session constructor extracts CLAUDE_CODE_EFFORT_LEVEL
from persisted envOverrides; applyEnvOverrides() unsets the stale tmux
session var so respawned panes are no longer locked
- tests: test/effort-injection.test.ts (13 cases)
Co-authored-by: Teigen <teigenzhang@gmail.com>
Detach a session tab into its own browser window and back.
Detach/undock:
- GET /session/:id serves the SPA in "solo mode", reusing the existing
client (terminal, local-echo overlay, reconnect) so no terminal code is
duplicated. One PTY already fans out to N SSE/WS clients, so a detached
window is just another live client — no server fan-out work was needed.
- A pop-out icon per tab; detached tabs show a badge and focus the popup on
click; closing the popup re-docks. Cross-window state via BroadcastChannel
plus a WindowProxy poll, and survives a dashboard reload (roll-call).
app.detachSession(id) is a single idempotent entry point (future gesture
hook). <base href="/"> so relative assets resolve under /session/:id.
Beta-branch isolation (so it can run alongside a prod Codeman):
- Default port 3000 -> 5000.
- New src/config/instance.ts derives the data dir and tmux socket from
CODEMAN_INSTANCE (default "beta"): ~/.codeman-beta + tmux -L codeman-beta.
Every ~/.codeman path now goes through dataPath()/getDataDir() (state,
mux-sessions, settings, push keys, lifecycle log, screenshots, certs,
linked-cases, subagent window state). Overridable via CODEMAN_INSTANCE /
CODEMAN_DATA_DIR / CODEMAN_TMUX_SOCKET. Prevents a second instance from
discovering and attaching PTYs to the first instance's live tmux sessions.
Verified: tsc / eslint / prettier / lockfile clean; Playwright E2E (27 checks)
for detach/solo/redock; default isolation confirmed to see zero real sessions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The stats collector (~2s) and mouse-mode sync (5s) ran execSync (pgrep/ps/
list-panes, 5s timeout each) per session on the server's single thread,
blocking the event loop. With several sessions or a momentarily slow tmux this
froze port 3000 for seconds-to-tens-of-seconds while the process stayed alive
and other ports were unaffected — self-healing, so it never restarted and the
60s loopback healthcheck missed it. Convert these hot-path calls to execAsync.
Also add an always-on event-loop lag monitor (utils/event-loop-monitor.ts) that
logs stalls >=1s to the web log, so this otherwise-invisible class of incident
leaves a quantified, timestamped trace.
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
* fix: isolate codeman tmux sessions
* fix(tmux): unify all sessions onto a single dedicated socket
Remove the per-session `tmuxSocket` field that recorded which tmux server
each session lived on (default vs the `codeman` socket). That field was a
persisted cache of physical reality and could drift — causing live sessions
to be wrongly marked dead ("tab shows no session found") and spawning
duplicate "Restored:" tabs.
All Codeman sessions now live on one process-wide socket (`tmux -L codeman`,
overridable via CODEMAN_TMUX_SOCKET), exposed via TmuxManager.muxSocket on
the TerminalMultiplexer interface. reconcileSessions() collapses from a
multi-socket scan (locate / re-pin / cross-socket dedup) to a single
`list-panes` query. loadSessions() strips the obsolete field from on-disk
records so it stops being written back.
Also fix two sibling bare-`tmux` call sites the unification would otherwise
leave broken (same #80 regression class — bare tmux hits the user's default
server and never finds a session on the codeman socket):
- session.ts queryTmuxWindowSize(): add `-L <socket>` (was silently falling
back to 120x40 on re-attach, losing scrollback)
- session-routes.ts send-key (Shift+Enter / Ctrl+Enter newline): route
through ctx.mux.muxSocket
SSH chooser scripts (tmux-manager.sh, tmux-chooser.sh) route every tmux call
through `tmux -L $CODEMAN_TMUX_SOCKET`, matching the TS default.
---------
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
When a PTY client re-attaches to an existing tmux session, it currently
hardcodes the PTY size to 120x40 and tmux resizes the window to match.
The xterm.js client then resizes back to its actual viewport on the
next render tick, so every restart causes a visible flicker and loses
one repaint of buffer content.
Also remove the hardcoded `-x 120 -y 40` from `tmux new-session` so
initial size adapts to the first client.
Changes:
- session.ts: query existing window size via `tmux display -p
#{window_width} #{window_height}` before pty.spawn, fall back to
120x40 only if tmux is unreachable.
- tmux-manager.ts: drop -x/-y from new-session args.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resolved conflict in src/web/public/session-ui.js by keeping this
PR's buildEnvOverrides() helper — it already covers both
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS (this PR) and
CLAUDE_CODE_EFFORT_LEVEL (added in #73), so the master-side inline
block is fully replaced.
Also fixed test/session-manager.test.ts MockSession to add a
getEnvOverridesForPersist() stub — without it,
SessionManager.updateSessionState's new call breaks 19 tests with
"TypeError: session.getEnvOverridesForPersist is not a function".
Verified: typecheck, lint, format:check, build, and
test/{session-manager,session-state,tmux-manager,tmux-restart-recovery}.test.ts
all pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(tmux-manager): use '|' separator in reconcileSessions
Under non-tty execution contexts (launchd on macOS, systemd without TTY),
tmux emits '\t' in FORMAT strings as the literal two characters `\` + `t`
rather than as a tab. The parser's `line.indexOf('\t')` (a real tab char)
therefore never matches, `activeSessions` stays empty, `reconcileSessions`
returns `alive: []` / `discovered: []`, and `cleanupStaleSessions()` wipes
every entry in `state.json` — even though the underlying tmux sessions are
still alive. On the next startup the user sees an empty session list.
The bug reproduces reliably when codeman is launched via a user LaunchAgent
or a systemd unit without `TTYPath`. Interactive `npm run dev` hides it
because tmux's format parser does interpret `\t` when stdout is a TTY.
Fix: use `|` as the separator. tmux passes it through verbatim in every
environment, and `|` is not a valid tmux session-name character so it
cannot collide with the codeman-<uuid> / claudeman-<uuid> naming scheme.
* test(tmux-manager): cover parsePaneList separator contract
Extract the inline pane-list parser from `reconcileSessions` into an
exported `parsePaneList()` helper plus `PANE_LIST_SEP` / `PANE_LIST_FORMAT`
constants, so the '|' separator contract can be unit-tested directly.
The new tests lock in:
- Well-formed parsing into name -> pid Map
- Empty / blank-line / missing-separator handling
- Non-numeric pid and empty-name rejection
- A literal `\t` (backslash + t) in the input is NOT treated as a
delimiter — guards against the launchd/systemd regression that
motivated PR #71.
- Splitting on the first separator only.
No behavior change in `reconcileSessions`; the body now delegates to the
helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Co-authored-by: arkon <arkon.85@hotmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CLAUDE_CODE_EFFORT_LEVEL (and any CLAUDE_CODE_* / OPENCODE_* key) now flows:
UI dropdown → POST /api/sessions { envOverrides }
→ new Session({ envOverrides })
→ this._envOverrides
→ tmux-manager.buildEnvExports appends `export KEY=<shellescape(VALUE)>`
Previously the API wrote envOverrides to <case>/.claude/settings.local.json, which
created stale state (UI dropdown disagreeing with disk) and polluted user project
directories. Now envOverrides are ephemeral spawn-time state, preserved across
respawnPane cycles via this._envOverrides and across server restart via
SessionState.envOverrides in state.json.
Also removes the now-unused updateCaseEnvVars import from session-routes.ts.
The model validation regex rejected brackets, silently dropping models
like opus[1m]. Also quote the model flag to prevent bash glob expansion.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Enable NODE_COMPILE_CACHE in systemd service and npm start for 10-20% faster cold starts
- Lazy-load xterm-addon-webgl.min.js (244KB) only on desktop — mobile never downloads it
- Add <link rel="preload"> hints for critical scripts (xterm, constants, app) in <head>
- Replace per-session tmux subprocess calls with single batch `list-panes -a` call
(N*2+1+M execSync calls → 1 for reconcileSessions)
- Fix CLAUDE.md frontend module count (10 → 11)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Filter empty sessions from history API (check for conversation content)
- Add --resume fallback to new session if resume fails (prevents dead panes)
- Pass resumeSessionId through respawnPane for dead pane recovery
- Persist respawn presets and runMode to server settings (cross-device sync)
- Fix mobile touch handling for Recent Sessions dropdown (DOM API + touch CSS)
Add resumeSessionId field throughout the session creation pipeline,
allowing sessions to resume previous Claude conversations via --resume
flag instead of --session-id.
* add project setup
* add tools to eslint
* remove contributing.md
* update claude md
* chore: simplify CI to single Node.js version (22)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* use node 20
* adjust formatting
* fix linting
* format
* fix layout
* fix: align CI node version to .nvmrc (22), remove redundant gotcha
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: arkon <arkon.85@hotmail.com>
- Add _isStopped guard to OpenCode 3s readiness timeout (session.ts)
- Block respawn for opencode sessions on interactive-respawn and
respawn/enable routes (server.ts)
- Fail fast in direct PTY fallback for OpenCode mode (session.ts)
- Validate configContent as JSON at schema level (schemas.ts)
- Update JSDoc example for createSession options API (tmux-manager.ts)
- Un-hide Context tab for OpenCode sessions (index.html)
- Add OpenCode UI tests (opencode-resize.test.ts)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When running the dev server inside a tmux session, $TMUX is inherited
and tmux refuses to create new sessions ("sessions should be nested
with care, unset $TMUX to force"). Production (systemd) has a clean
env so this only affects dev/test. Strip $TMUX from the execSync env
when calling tmux new-session.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Set UTF-8 locale in all PTY spawn environments, add xterm-addon-unicode11
for proper double-width character measurement, and update LocalEchoOverlay
helpers to respect CJK character visual width in positioning, line wrapping,
and cursor placement.
Based on PR #13 by @TeigenZhang, adapted to current codebase structure.
Co-Authored-By: Tenggan Zhang <TeigenZhang@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The "Startup Mode" setting (normal/bypass/allowedTools) was saved to
settings but never read when spawning sessions. All code paths had
--dangerously-skip-permissions hardcoded. Now reads claudeMode from
~/.claudeman/settings.json and passes it through Session → TmuxManager.
Closes#12
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Previously, when Claude exited inside tmux (exitCode=0 or crash), tmux
destroyed the entire session — losing buffer, history, and causing crash
loops on restart attempts. Now:
- Set remain-on-exit on for all tmux sessions
- Detect dead panes via #{pane_dead} instead of assuming session is gone
- Respawn dead panes with tmux respawn-pane -k (preserves session)
- Applied to both startInteractive and startShell paths
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When Claude exits inside a tmux session, tmux destroys the session
(remain-on-exit is off), but the _muxSession reference persists.
On restart, startInteractive() treats it as a "restored session" and
tries to attach to the dead tmux — causing instant exit code 1.
Now checks tmux has-session before attaching. If the session is gone,
clears the stale reference and creates a fresh tmux session.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Clear teams/teamTasks maps in handleInit() on SSE reconnect
- Fix saveNowAsync() race condition with in-flight promise guard
- Add setMaxListeners() on Session, WebServer, SubagentWatcher,
ImageWatcher, TmuxManager to prevent MaxListenersExceeded warnings
- Debounce persistSessionState() per-session (100ms) to reduce
redundant toState() serialization across 25+ call sites
- Cache getLightSessionsState() with 1s TTL to avoid re-serializing
all sessions on every SSE connect and /api/sessions request
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>