Compare commits

...
Author SHA1 Message Date
Codeman maintainer d7bb726213 chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 09:16:48 +02:00
Codeman maintainer 715aef2076 feat(install): ask for network binding, default to LAN access with password prompt
The loopback-only default was safe but left most installs unreachable from
the devices people actually use. The installer now asks at the end of setup:

1) Any device on your network (0.0.0.0), the default. Prompts for a
   dashboard password (confirmed twice); skipping it requires an explicit
   confirmation and prints a big red warning as the final output.
2) This machine only (127.0.0.1), the safer option, for tunnel/Tailscale
   setups.

The choice flows into the systemd unit, the launchd plist (values escaped
for both formats), the run-now exec path, and the printed URLs (LAN IP
detection included). Non-interactive installs keep the safe loopback
default unless CODEMAN_HOST is preset; the server binary's own default
binding is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 03:09:43 +02:00
Codeman maintainer 608ec8a10e chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:46:03 +02:00
Codeman maintainer 303afd7fe1 docs: add blog article images (dashboard tour, mobile shots)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:44:00 +02:00
Codeman maintainer 0ee268ba82 fix(ui): stop the centered voice button overlapping the case picker
.toolbar-center is absolutely centered (left: 50%), so on viewports below
~1500px, or with long case names widening the left toolbar group, the voice
button rendered on top of the case picker's chevron and the + button. Below
1500px it now falls back into normal flex flow where overlap is impossible;
wide viewports keep the centered layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 23:36:38 +02:00
Codeman maintainer 1be98ff8a3 fix(mobile): collapse header brand to a C home button on phones
On <430px screens the full Codeman wordmark wasted header space; the brand
now renders a single C (same tap target, still app.goHome()). Desktop and
tablet keep the full wordmark. The compact letter lives in a separate
aria-hidden span so i18n custom branding keeps rewriting only the wordmark.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:47:09 +02:00
Codeman maintainer b710013add docs: README hero pitch, badges, star CTA
Add a short what-is-Codeman pitch block with deep links after the hero GIF,
npm version + GitHub stars badges, and a closing star/issues CTA.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:25:11 +02:00
Codeman maintainer 4343805672 docs: sync CLAUDE.md core-files table (Infra docker modules, app.js ~5K lines)
Adds src/docker-hosts.ts + src/docker-export.ts to the Infra row and
corrects the app.js size note (4906 lines), merged with the 1.7.0
i18n.js additions to the same rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 10:14:27 +02:00
Codeman maintainer 4f8471189e chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:43:22 +02:00
Ark0N fad7cdc1ab Merge pull request #165 from shenlvkang-collab/feat/custom-name-i18n
feat(ui): add custom branding and Chinese localization
2026-07-23 09:41:48 +02:00
Codeman maintainer 56db02412b Merge master into feat/custom-name-i18n; keep windowTitle stable on solo renders
Resolves the CLAUDE.md paragraph conflict with #162, skips the
windowTitle recompute for solo-session renders so a detached window
cannot reset the push-notification hostTitle prefix to the default,
and prettier-formats test/mobile/devices.ts (came in unformatted via
the #162 merge; CI format:check only covers src/**).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:17:04 +02:00
Ark0N 689d9fc5e5 Merge pull request #164 from shenlvkang-collab/fix/run-session-tab-dedup
fix(ui): show new run tabs immediately
2026-07-23 09:14:22 +02:00
Ark0N 50547a4e89 Merge pull request #163 from shenlvkang-collab/fix/unicode-working-directories
fix(paths): accept Unicode working directories
2026-07-23 09:13:24 +02:00
Ark0N 3c2a5bfef3 Merge pull request #162 from shenlvkang-collab/fix/foldable-mobile-settings
fix(mobile): preserve settings across foldable postures
2026-07-22 19:05:02 +02:00
Codeman maintainer bc66add7ed docs: sync READMEs with the 1.6.2 installer behavior
Quick Start now documents the consent-first flow (every system change is
prompted; the closing menu chooses terminal / background service / skip),
safe re-runs (finished installs update in place with local changes stashed
and the service restart verified; interrupted installs resume full setup;
install.sh update/uninstall), and the headless contract (system-changing
steps abort without CODEMAN_NONINTERACTIVE=1). The AI CLI note now says all
four CLIs are auto-detected with an install-or-skip choice when none exist,
and the background-service section points at installer menu option 2 before
the manual instructions. Same changes mirrored in README.zh-CN.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 12:52:47 +02:00
Codeman maintainer 24b5d8fa63 chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 12:36:57 +02:00
codeman-local 8d9fc4195b feat(ui): add custom branding and Chinese localization 2026-07-21 02:49:25 +08:00
codeman-local 5abcae16b4 fix(ui): show new run tabs immediately 2026-07-21 00:20:34 +08:00
codeman-local 66ad681666 fix(paths): accept Unicode working directories 2026-07-21 00:04:40 +08:00
Codeman maintainer 6c8d4ca72f chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:35:40 +02:00
Codeman maintainer 2fdf7dabac docs: sync READMEs with 1.6.0 (remote SSH, session manager, permissions); fix installer prompts under curl|bash
README.md + README.zh-CN.md:
- New "Remote SSH Sessions" section (durable remote tmux, auto-reconnect,
  discover/attach with detach-not-kill, shared sessions, injection-safe ssh)
- New "Session Manager & Command Palette" subsection (pinning survives kill,
  name retention on resume, cross-device tab order sync)
- Multi-user quick start right after installation (users add + --multiuser),
  and the zh-CN README gains the full Multi-User Mode section it was missing
- Security: document the configurable startup permission mode (skip/auto/
  normal/allowedTools) and the multi-user auto downgrade
- Cron header button noted as opt-in (Header Displays); API section counts
  refreshed (~190 handlers / 20 route modules) with pin, session-order and
  unified endpoints; Development now recommends npm run test:ci

CLAUDE.md (/init audit): session-order.ts in the Session row, PR #157
session-manager polish appended to the unified-list pattern, opt-in Cron
button documented, route/SSE counts refreshed (20 modules, ~188 handlers,
~146 events)

install.sh: the post-install "How would you like to run Codeman?" menu (and
the CLI picker + yes/no prompts) read from stdin, which under curl | bash is
the pipe, so choices were impossible and the script silently fell through to
the default. New has_tty()/read_reply() helpers prompt via /dev/tty whenever
a real terminal exists (same approach the sudo path already used) and only
fall back to defaults when there is genuinely none, now with an info line
saying so. Verified both paths with a pty harness (script(1)) and setsid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:17:32 +02:00
codeman-local 51cb3a7205 fix(mobile): preserve settings across foldable postures 2026-07-20 21:22:28 +08:00
Codeman maintainer b10e354936 chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:53:48 +02:00
Codeman maintainer 64559b60d1 feat(ui): hide the Cron toolbar button by default (opt-in via Header Displays)
The Cron button now follows the same opt-in pattern as the Session Manager /
Away Digest / File Viewer buttons: the template ships the btn-cron--hidden
marker class and applyHeaderVisibilitySettings() removes it only when the
per-device showCronButton setting (App Settings -> Display -> Header Displays)
is enabled. Defaults flipped to false in the mobile defaults block and both
?? fallbacks. Cron jobs remain fully functional; only the launcher is opt-in.

Verified in a live browser: fresh profile hides the button + unchecked toggle,
enabling shows it immediately and persists across reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:43:26 +02:00
Ark0N 6351b4143f Merge pull request #157 from aakhter/cod-162-session-manager-polish
Session Manager: pinning, cross-device ordering, name/prompt retention
2026-07-20 14:43:11 +02:00
Codeman maintainer 3d6e3f3d6e Merge remote-tracking branch 'origin/master' into pr-157 2026-07-20 14:33:10 +02:00
Ark0N 5c20fcf464 Merge pull request #156 from aakhter/cod-114-remote-tmux-durability
Remote tmux durability: survive SSH drop, discover/attach, collaborative sessions
2026-07-20 14:32:50 +02:00
Codeman maintainer 683544a22e Merge master into PR #157 (session manager polish)
Resolutions (sse-events.ts / constants.js / app.js): unions of the docker/
multi-user event registrations from master with the session-order/pin events
from this branch.

Additions on top of the merge:
- POST /api/sessions/:id/pin now falls back to the persisted store record when
  no live session exists: COD-142 deliberately preserves pinned records after
  kill (and cleanupStaleSessions skips them), so without this a pinned-then-
  killed session could never be unpinned. Owner-scoped in multi-user mode.
- SessionOrderUpdateSchema bounds (id <= 100 chars, <= 500 entries) so a buggy
  client can't persist megabytes into state.json; empty strings still flow to
  normalizeSessionOrder which drops them.
- Route tests for the persisted-record pin fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:31:44 +02:00
Codeman maintainer 5181c9abb0 docs: sync remote-sessions.md launch section with the shipped socket/naming
The durable-launch section predated the #145 consolidation: owned launches use
the dedicated -L codeman-remote socket with codeman-ssh-<id8> names and
per-session set -t options (never -g). Discovery/attach (COD-105) genuinely
target the canonical -L codeman socket; the asymmetry is now called out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:27:06 +02:00
Codeman maintainer 25c67f9415 Merge master into PR #156 (remote tmux durability)
Resolutions:
- session.ts: keep the extracted _buildRespawnPaneOptions() helper (COD-108)
  and add master's docker/owner fields to it
- tmux-manager.ts: docker branch first, then remote via buildRemoteSessionCommand
  (now an options object threading claudeMode/allowedTools into
  buildRemoteLaunchCommand, preserving the 6.3 multi-user permission downgrade)
- case-routes.ts: keep master's adminOnly helper; gate the new COD-105 discovery
  endpoint admin-only in multi-user mode (hosts are machine-level infra)
- settings-ui.js: union of remoteAutoReconnect + master's header-button defaults
- session-routes.ts: union of imports; session gets remote + owner

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:27:02 +02:00
Codeman maintainer d6917e3b21 chore: version packages
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 13:46:38 +02:00
Codeman maintainer 524a096e14 Merge feat/docker-session-mode into master (docker deep-review fixes)
Brings the docker session-mode deep-review work (intended for the skipped
1.4.2) onto the 1.5.x line: deterministic-conversation-id resume across
container stop/recreate, config-drift detection + POST /api/docker-cases/:name/recreate,
docker model-picker support, import-manifest hardening, remote-daemon (context/
daemonHost) correctness, comma-in-path rejection, and the zh-CN README re-translation.

Conflicts resolved to preserve BOTH the multi-user security scoping already on
master (ownership checks, workingDir confinement, permission downgrade) AND the
docker features. Version kept at master's 1.5.0 (the 1.4.2 bump is superseded;
a fresh changeset bumps to 1.5.1). tsc, eslint, and test:ci all green (3548 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 13:45:37 +02:00
Codeman maintainer a21400614a chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:36:02 +02:00
Codeman maintainer 9046b95b7e docs: multi-user mode design plan (reviewed against code)
Design for opt-in --multiuser: per-user case spaces, scrypt-hashed
users.json, ownership threading across sessions/cases/SSE/push, admin
panel, and a per-user Claude permission-mode policy. Reviewed against
the actual auth/SSE/case/session code; the plan encodes verified
call-site inventories, the non-admin workingDir confinement rule,
WS-upgrade identity plumbing, per-user QR minting, and the linked-cases
v2 format migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:29:50 +02:00
Codeman maintainer 8b3fa5f37c docs(zh): full re-translation sync of README.zh-CN.md
Bring the Chinese README to 1:1 section parity with the English one.
Adds the three missing sections (Using Codeman: A Human's Guide,
Driving Codeman from an Agent: Programmatic Guide, and Versioning),
updates the keyboard-shortcut table to the current registry (session
palette chord, Option bindings, prev/next tab), refreshes the API
section (18 route modules / ~160 handlers, ApiResponse envelope note,
Sessions rows with clientId+seq, new Cron table), and adds the
SECURITY.md disclosure pointer to the Security intro.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 01:51:01 +02:00
Codeman maintainer 4c6f96a2ef docs: sync CLAUDE.md and READMEs with the 1.4.1 feature set
CLAUDE.md (the 1.4.1 release commit only reformatted it): document the
seeded credential-isolation model (resolveDockerClaudeArtifacts /
resolveDockerCredentialArtifacts, buildSeamlessClaudeConfig), the
auto-built agent base image (ensureAgentBaseImage + docker:imageBuild*
SSE events), the C.UTF-8 image locale, w<n>-<case> tab naming, and the
opt-in File Viewer header button; bump the SSE registry count to ~138.

README.md: add Gemini to every CLI enumeration (tagline, install, WSL,
Multi-CLI, security, architecture diagram), split the Docker section's
hardening bullet into hardening + seamless-auth/credential-isolation,
note the base image now auto-builds on first use, add a File Viewer
bullet to More Features.

README.zh-CN.md: mirror all of the above, add the previously missing
"Isolated Docker Sessions" section and a Docker bullet in More
Features, fix the Node badge to 22+, and run prettier over the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 01:46:59 +02:00
Aamer Akhter 5ec71ace5a COD-145 show last (most recent) prompt alongside first in session manager
Building on COD-140's firstPrompt backfill, surface each session's most
recent user prompt too, so a long-running session is identifiable by both
where it started and where it is now.

- session-routes: add extractLastUserPrompt() (mirrors extractFirstUserPrompt
  with last-match semantics + same noise/secret/slash-command filters + 120
  cap); scanProjectDir computes lastPrompt from the file tail (reads a tail for
  large files; small files scan head); thread lastPrompt through HistorySession
  and the /api/sessions/unified history rows.
- unified-session-service: add lastPrompt to UnifiedSessionItem + HistoryInput,
  set it from history in the merge, and extend the backfill with parallel
  by-uuid / newest-by-workingDir indexes (never overwrites); add lastPrompt to
  the filterAndPaginate search haystack.
- terminal-ui: render a 'Last prompt' detail row, omitted when absent or equal
  to the first prompt (single-prompt sessions show one line).

Tests: unified-session-service.test.ts +5 (uuid-join, workingDir fallback,
newest-wins, no-overwrite, search). Beta-verified: /api/sessions/unified
populated firstPrompt+lastPrompt on all 200 rows (12 distinct); Playwright on
the session-manager modal rendered 12 'Last prompt' rows, 0 console errors.

(cherry picked from commit 115f4d397e91decc1a6381b47a99d74922e9055b)
2026-07-17 16:31:09 -04:00
Aamer Akhter b27a0e9188 COD-140 backfill firstPrompt for sessions whose id != transcript UUID
The unified session list only set firstPrompt from the transcript-history
view, keyed by the Claude transcript file's UUID. A live/persisted row
keyed by its Codeman id only inherited a prompt when that id happened to
equal an on-disk transcript filename; when it didn't (stale/wrong
claudeSessionId, post-/clear new uuid, resumed/attached/worktree session),
the session manager showed "(no prompt captured)" even though a real
transcript for that working dir existed under a different UUID.

Add a pure firstPrompt backfill pass in mergeUnifiedSessions (after the
merge loops, using the already-passed history source): for any row with no
firstPrompt, join by claudeSessionId first, then fall back to the newest
transcript in the same workingDir. Never overwrites a non-empty prompt, so
rows keyed to their own transcript are untouched; rows with genuinely no
transcript still show the placeholder. Pure, unit-tested (+5).

(cherry picked from commit 1f9f53ec64a61c9fa7f77d29efcbdd1d2794ec38)
2026-07-17 16:30:39 -04:00
Aamer Akhter 35a0217ccd COD-142 retain pin when a pinned session is killed
A killed session was full-deleted from state.json (removeSession),
dropping the COD-139 pinned/pinnedAt fields, so the session vanished
from the session-manager pinned group. cleanupStaleSessions also reaped
any persisted record with no live session on boot, which would have
wiped a preserved pin on the next restart.

Fix (state-store):
- demoteOrRemoveSession(id): on kill, demote a *pinned* record to a
  lightweight stopped record (status=stopped, pid=null, pin retained)
  instead of deleting; unpinned records are removed as before.
- cleanupStaleSessions skips pinned records so the pin survives restart.
- server _doCleanupSession calls demoteOrRemoveSession on the killMux
  path (shutdown path unchanged).

Restoration iterates live mux sessions, not state.json, so a stopped+
pinned record is never auto-revived. Unit-tested on the real StateStore
path (state-store.test.ts +4); session-cleanup/session-pin regress green.

(cherry picked from commit 86f183eacfc3f2f6ac28499fb1ae2d21eef2bbed)
2026-07-17 16:26:36 -04:00
Aamer Akhter 7a86cf87f7 COD-143 retain session name when resuming from the session manager
resumeHistorySession ignored the row's name and always synthesized a fresh
w<N>-<dir> name from the working dir, so resuming a custom-named session lost its
name. Thread the name through resumeHistorySession(sessionId, workingDir, name) and
extract the choice into a pure _resolveResumeName helper: prefer a non-empty existing
name, else generate the next free w<N>-<dir>. Forward s.name at all three call sites
(terminal-ui.js history-item + session-manager menu, session-ui.js run-mode history);
sessions without a name fall back to the generated name (unchanged behavior). The
unified session rows already carry name, so session-manager rows resume with it.
TDD: test/resume-name.test.ts drives the real _resolveResumeName via vm-harness.

(cherry picked from commit 56c7906a48d8b453ed55810a02ec70f72d34ed32)
2026-07-17 16:26:28 -04:00
Aamer Akhter 5792c2d62e COD-139 add session pinning (float pinned sessions to top of session manager list)
Pin/unpin a session via POST /api/sessions/:id/pin {pinned}; pinned sessions
sort above unpinned in the unified session manager list (COD-121), ordered by
pinnedAt descending. Pin state lives on SessionState, persists to state.json,
and survives reload/reconnect/restart (persisted-input carries pinned; the
merge skips undefined so a recovered live session can't clobber it). New SSE
event session:pinned re-sorts the open list live across clients. Pin/Unpin
affordance in the session-row kebab menu with a 📌 glyph + amber highlight.

(cherry picked from commit 82749747039afcd4a3104f6a97ce7d3c2ddd048d)
2026-07-17 16:25:29 -04:00
Aamer AkhterandClaude Opus 4.8 8807b3ff6d COD-131 sync tab order across devices via server state
Tab reordering (drag-and-drop + Ctrl+Shift+{/}) persisted only to
localStorage (codeman-session-order), so each device kept its own private
order. Add server-side persistence so the order follows the user across
devices, live. Takes the issue's recommended default (a): one global order,
server authoritative, localStorage as offline fallback.

- session-order.ts (new, pure + unit-tested): normalizeSessionOrder (coerce
  to string[], drop empty/non-string, dedup) and mergeSessionOrder (the
  pushing device's order wins; ids the device hadn't loaded fall to the end
  in their existing relative order, never dropped — graceful for
  closed/remote/parked sessions absent on that device).
- AppState.sessionOrder?: string[]; StateStore get/setSessionOrder + the field
  added to buildPartialJson() (the incremental serializer whitelists fields,
  so without this the value never reached disk / survived a restart).
- PUT /api/session-order (session-routes): parse -> merge -> persist ->
  broadcast session:orderChanged; getLightState() init snapshot now carries
  sessionOrder so a fresh load/reconnect restores it.
- SSE event session:orderChanged registered in sse-events.ts + constants.js.
- app.js: handleInit seeds localStorage from the server snapshot before
  syncSessionOrder(); saveSessionOrder() also PUTs to the server (debounced
  400ms, covers drag + both keyboard moves); _onSessionOrderChanged adopts a
  remote order and re-renders (no-op-guarded to avoid echo flicker).

Verified (orchestrator re-ran all gates): tsc 0, lint 0, frontend-syntax +
prettier clean, build ok; session-order + session-order-routes + state-store
56/56. Functional round-trip on an isolated beta: PUT {a,b,c} -> status
snapshot reflects it; merge PUT {c,a} vs {a,b,c} -> {c,a,b} (b preserved at
end); malformed payload rejected with a clean 400; sessionOrder persisted to
state.json and survived a restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

(cherry picked from commit 79415f2fdfbdf3fbe362a063534e7f84c553eefb)
2026-07-17 16:21:16 -04:00
Aamer Akhter 115ada1e9e docs: cover COD-105 remote discover/attach + detach-not-kill in remote-sessions.md
55f5ada (COD-105) added Phase 2 of the remote-tmux arc: discover codeman-*
sessions on a host and attach to non-owned ones, with detach-not-kill on close.

- Data model: SessionRemote.owned/remoteSessionName + RemoteSessionInfo;
  toSessionRemote (owned:true) vs toAttachedSessionRemote (owned:false).
- New Ownership section: discovery (listRemoteCodemanSessions, the literal-\t
  parse quirk, never-throws/VITEST), attach-vs-launch selection
  (buildRemoteSessionCommand), and the killSession detach-not-kill guarantee.
- API: GET /api/remote-hosts/:hostId/sessions + the attachRemoteSession
  create path.
- CLAUDE.md Remote Key Pattern notes discover/attach + detach-not-kill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit f321e1200a9a7c1e58c69ba936b680200fd53275)
2026-07-17 16:05:06 -04:00
Aamer AkhterandClaude Opus 4.8 b2ebdcbf47 COD-106 shared/collaborative remote tmux sessions (window-size latest + shared badge)
Two Codeman clients attaching the same durable remote tmux session at different
viewports would fight: tmux sizes a window to the SMALLEST attached client by
default. Push `window-size latest` to the remote session config so the window
tracks the most-recently-active client instead, letting concurrent clients
coexist; surface the client count for a "shared · N" badge.

Reconciled onto upstream PR #145: #145 moved the durable remote session onto the
dedicated `-L codeman-remote` socket under a `codeman-ssh-` name and scoped every
tmux set-option PER-SESSION (`set -t <name>`, never `-g`) so a shared remote tmux
server's OTHER sessions keep their own prefix/mouse/sizing. The original COD-106
commit added `set -g window-size latest` (GLOBAL) on the old `-L codeman` socket —
a regression against #145's hardening. This commit layers the window-size feature
onto #145's structure as `set -t <name> window-size latest` (per-session, on the
codeman-remote socket). Test assertions updated to the per-session form
(remote-shared-sessions.test.ts) and the byte-identical launch-command test
(remote-ssh-options.test.ts) extended with the window-size line — which supersedes
the separate f09323c9 assertion fix (dropped: it targeted the global form and also
carried unrelated CLAUDE.md doc changes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:03:03 -04:00
Aamer Akhter 6dba8b5227 COD-108 auto-reconnect remote tmux sessions on SSH drop
Continuous remote-only reconnect watcher closing the COD-104 durability
arc: when a remote session's local ssh pane dies mid-run, re-establish it
automatically instead of leaving a dead pane until the user pokes it.

Design decisions (per cod108 design doc):
- D1 event->owner: TmuxManager watcher DETECTS a dead remote pane and emits
  `remoteSessionDropped`; the session owner (server) reassembles the same
  RespawnPaneOptions and calls Session.reattachRemote() -> respawnPane, which
  re-runs the idempotent remote command (owned new-session -A / non-owned
  attach) and REJOINS the still-running durable remote tmux session. The
  watcher never reassembles options itself, and never routes through the
  Claude-idle respawn-controller.
- D2 bounded backoff: per-session exponential backoff [5s,15s,45s,2m,5m,5m],
  reset on a successful reattach, `remoteReconnectExhausted` emitted once after
  the cap. Pure, unit-tested schedule + eligibility decision.
- D3 always-on + kill-switch: `remoteAutoReconnect` app setting (default ON),
  read each tick; when false the watcher does nothing.

Guards: killSession() (incl. the non-owned DETACH early-return) and shutdown
add the session to an intentional-teardown guard set + clear its backoff
BEFORE teardown, so a closed/killed tab is never auto-revived. Exactly one
reconnect in flight per session (inFlight guard prevents stacked respawns).
Per-session reconnect/guard state cleared on session removal.

New: src/remote-reconnect.ts (pure backoff + decideReconnect), TmuxManager
startRemoteReconnectWatcher/stop + runRemoteReconnectTick + noteRemoteReconnect
+ guardRemoteReconnect + clearRemoteReconnectState; Session.reattachRemote()
(+ extracted _buildRespawnPaneOptions, shared with interactive start); server
wiring + watcher start; 3 SSE events (sse-events.ts + constants.js in sync,
broadcast + app.js exhausted "Reconnect" affordance); remoteAutoReconnect
schema + settings-ui toggle.

Tests: test/remote-auto-reconnect.test.ts (21) - pure schedule, eligibility
(guarded never reconnects, non-remote/pane-alive/not-due skip, over-cap
exhaust), and manager-level integration (dead remote pane -> dropped ->
backoff -> exhausted; guarded emits nothing; reset-on-success; kill-switch
off; state-cleared-on-remove). Verified real-remote against aa-desktop: drop
local ssh pane -> watcher emitted -> respawnPane reattached the SAME remote
session (remote pane_pid unchanged 3939->3939); test session cleaned up, the
real host sessions left untouched.

Checks: tsc, eslint, check:frontend-syntax, check:public-assets, prettier
--check, build all green; tmux-manager/session-routes/session-manager/
sse-registry-parity suites pass.

(cherry picked from commit d13d58b1994eb6594fd2eadea208104d36204f9d)
2026-07-17 15:59:36 -04:00
Aamer AkhterandClaude Opus 4.8 897bfdff59 COD-109 terminate owned durable remote tmux sessions (propagate kill to remote)
Since COD-104 a remote session lives in a durable tmux server on the host and
outlives the local pane, so killing a tab only DETACHED — even for sessions we
own. Propagate `kill-session` to the remote for OWNED sessions in killSession's
owned path (after COD-105's non-owned detach-only early-return); non-owned
detach-only is untouched.

Reconciled onto upstream PR #145: #145 already upstreamed this exact owned-kill
propagation as `buildRemoteKillCommand({ remote, sessionId })` on the dedicated
`-L codeman-remote` socket (matching buildRemoteLaunchCommand) and wired it into
killSession (Strategy 3b, owned-only, fire-and-forget). The original COD-109
commit added a second `buildRemoteKillCommand(remote, name)` overload on the old
`-L codeman` socket plus a duplicate kill block — a compile error AND a wrong
socket post-#145 (owned sessions no longer live on `codeman`). This commit keeps
#145's socket-correct implementation and drops the duplicate; the required
test/remote-kill-command.test.ts is retargeted to #145's `{ remote, sessionId }`
signature and the `codeman-remote` socket.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:54:32 -04:00
Aamer Akhter fb013e9de0 COD-105 discover + attach existing remote tmux sessions (detach-not-kill)
Phase 2 of the remote-tmux arc. Discover codeman-* tmux sessions already
running on a remote host (created by the remote's own Codeman or another
instance) and attach to one this Codeman didn't launch, with detach-not-kill
ownership for non-owned sessions.

- remote-hosts.ts: listRemoteCodemanSessions (ssh, VITEST-guarded, never throws)
  + pure parseRemoteSessionList + buildRemoteListSessionsCommand. Parser splits
  on the LITERAL \t the remote tmux emits (next-3.7 does not expand \t) AND a
  real tab. toAttachedSessionRemote builds a non-owned SessionRemote; toSessionRemote
  now marks the COD-104 launch path owned:true.
- tmux-manager.ts: buildRemoteAttachCommand (sibling of buildRemoteLaunchCommand);
  buildRemoteSessionCommand selects attach vs launch by ownership. killSession gains
  a detach-not-kill early return for non-owned remote sessions: tears down only the
  LOCAL pane (kills local ssh -> remote attach detaches), NEVER issues a remote
  kill-session.
- types/session.ts: RemoteSessionInfo; SessionRemote.owned + remoteSessionName.
- schemas.ts: CreateSessionSchema.attachRemoteSession {hostId, remoteSessionName};
  fixed a pre-existing no-useless-escape lint error in the jumpHost regex.
- case-routes.ts: GET /api/remote-hosts/:hostId/sessions (explicit discovery).
- session-routes.ts: attachRemoteSession create path -> non-owned session.
- UI (index.html/session-ui.js/styles.css): explicit "Discover existing sessions"
  button + Attach action (owned:false). No auto-discover.

Verified on aa-desktop: discovered codeman-disco1, attached (attached=1, shared
view), killed local probe pane -> remote SURVIVED_DETACH (attached=0). Tests:
parse/attach-cmd/ownership unit + discovery route, session-routes + case-routes green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 55f5ada9db6d01518a4adf6b752e460b5df39524)
2026-07-17 15:49:24 -04:00
Aamer Akhter 7f24a132d0 COD-104 fix: skip remote tmux prereq check under VITEST (test-mode)
COD-104 wired checkRemoteTmuxAvailable into the remote-session create path,
but it does a real `ssh` via exec — so 2 remote-create tests in
session-routes.test.ts hit a ~10s ssh timeout and failed (422). Mirror
TmuxManager's IS_TEST_MODE no-op-shell-under-VITEST: short-circuit the live
probe to {ok:true} under vitest. Command construction stays covered by
buildRemoteTmuxCheckCommand unit tests. session-routes.test.ts now 61/61.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6ae2c0b8160090a1f0f6b32a3fe8496d402ac2c6)
2026-07-17 15:43:06 -04:00
73 changed files with 7237 additions and 555 deletions
+3
View File
@@ -2,6 +2,9 @@
.agents/
skills-lock.json
# Written by install.sh into end-user clones when setup finishes
.install-complete
# Dependencies
node_modules/
+111
View File
@@ -1,5 +1,116 @@
# aicodeman
## 1.8.0
### Minor Changes
- Installer: choose your network binding, with LAN access as the new guided default.
The install script now asks at the end of setup how the dashboard should be reachable:
1. Any device on your network (0.0.0.0), the default. The installer prompts for a dashboard password (hidden input, confirmed twice); declining a password requires an explicit confirmation and the install ends with a prominent warning explaining the exposure.
2. This machine only (127.0.0.1), the safer option for tunnel/Tailscale setups.
The choice is wired into the generated systemd unit and launchd plist (values escaped for each format), the run-now launch path, and the printed URLs, which now include the detected LAN IP for instant phone access. Non-interactive installs keep the safe loopback default unless CODEMAN_HOST is preset, and the server binary's own default binding (127.0.0.1) is unchanged, so npm and manual installs behave exactly as before. New installer env presets: CODEMAN_HOST and CODEMAN_PASSWORD skip the prompts for automation.
## 1.7.1
### Patch Changes
- Mobile and UI polish plus docs refresh.
- Mobile: the header brand collapses to a single "C" home button on phones (<430px), freeing header space for session tabs while keeping the same tap target. The compact letter lives in its own span so i18n custom branding keeps rewriting only the full wordmark.
- UI fix: the absolutely-centered toolbar voice button no longer overlaps the case picker's chevron and "+" button. Below ~1500px (or with long case names widening the left toolbar group) it now falls back into normal flex flow where overlap is impossible; wide viewports keep the centered layout.
- Docs: README gains a hero pitch block with deep links, npm version + GitHub stars badges, and a star CTA; CLAUDE.md core-files table synced (Infra docker modules, app.js line count); blog article images added under docs/images/blog/.
## 1.7.0
### Minor Changes
- Community release (thanks @shenlvkang-collab for all four PRs) plus documentation fixes.
- fix(mobile): per-device settings now key off a stable handheld classification (`MobileDetection.isHandheldDevice()`: touch plus UA form-factor tokens, with User-Agent Client Hints fallback) instead of the instantaneous viewport width, so an Android foldable that unfolds past the desktop breakpoint keeps `codeman-app-settings-mobile` and opt-ins such as the Response Viewer and Extended Keyboard Bar. Responsive layout stays width-driven. Adds an OPPO Find N5 (unfolded) device profile and a fold/unfold/reload Playwright regression test (mobile suite now 136 devices). (#162)
- fix(paths): `SAFE_PATH_PATTERN` now accepts Unicode letters and numbers (`\p{L}\p{N}` with the `u` flag), so working directories like `/mnt/d/AI/中文项目` validate in Create Session, Quick Run, and Scheduled Run. All shell-metacharacter, traversal, and absolute-path protections are unchanged. (#163)
- fix(ui): newly created run sessions render their tab immediately instead of waiting for the `session:created` SSE event (idempotent upsert from the POST response, with a `GET /api/sessions/:id` fallback for quick-start modes), and the Run button holds an in-flight lock (min 500 ms) so a double click cannot create duplicate sessions. (#164)
- feat(ui): the synced custom display name and per-device English/Simplified Chinese UI language are described in their own entry (#165); on top of that PR, `renderIndexHtml` no longer recomputes `windowTitle` on solo-session renders, so a detached window cannot reset the push-notification `hostTitle` prefix to the default name.
- docs: corrected the `sse-events.ts` fileoverview breakdown (148 event constants, was stale at 120; per-category counts refreshed, including Cron, Docker, Remote auto-reconnect, and Multi-user) and the CLAUDE.md SSE registry count; READMEs synced with the 1.6.2 installer behavior.
### Patch Changes
- 8d9fc41: Add a synced custom display name and a per-device English/Simplified Chinese browser UI language picker under App Settings → Display.
## 1.6.2
### Patch Changes
- Installer (install.sh) reliability and safety overhaul, prompted by a review of the Linux flow:
- Install-completion marker (`.install-complete`): a bare re-run only takes the quiet update path when a previous install actually finished. Previously, a first install that failed during npm install/build (or was interrupted) left `.git` behind, so the retry silently became an "update" and the user never got the launch menu, the `codeman`/`tmux-chooser` symlinks, the PATH entry, or the `sc` alias. The marker is refreshed by updates and cleared by uninstall when the app dir is kept; added to .gitignore for end-user clones.
- `update` no longer runs an unconditional `git reset --hard` over local changes: interactive runs are asked to stash (declining keeps everything and skips the update), headless runs auto-stash with a dated message (same policy as scripts/self-update.sh).
- Service setup is verified instead of asserted: after starting codeman-web, the installer polls `systemctl --user is-active` (up to 6s) and only then prints "Codeman is running now!"; failures print an honest warning plus status/journalctl hints. Uses `restart` instead of `start` so re-running the installer over an already-running service actually loads the new build. A missing user D-Bus session (e.g. bare `ssh host 'curl | bash'`) is detected up front with copy-paste recovery commands instead of dying mid-setup via `set -e`. macOS gets the equivalent `launchctl list` verification, and the update path verifies its service restart too. The Cloudflare tunnel-service offer is skipped when service setup failed.
- Headless consent guard: with no interactive terminal AND no explicit `CODEMAN_NONINTERACTIVE=1`, the installer now refuses (with instructions) to run sudo package installs (git/node/tmux) or third-party `curl | bash` AI CLI installers, instead of silently taking the default-yes prompts. Explicit `CODEMAN_NONINTERACTIVE=1` keeps the previous full-auto behavior for CI/automation.
- AI CLI gate now recognizes Codex and Gemini (search paths mirrored from the CLI resolvers), so a box with only Codex or Gemini installed is no longer forced to install Claude Code/OpenCode. The install menu gains a "Skip" option (with npm install hints for Codex/Gemini), and the final reminder lists all four CLIs.
Docs: CLAUDE.md documents `src/remote-reconnect.ts` (pure COD-108 auto-reconnect backoff/eligibility logic) in the Infra table and the remote-sessions pattern.
## 1.6.1
### Patch Changes
- **Admin Panel for multi-user mode.** Admins in multi-user mode now get a prominent Admin Panel button at the top of the page (header, admin-only; the template ships it hidden and `admin-ui.js` reveals it after identity boot; hidden on phones per the mobile header policy, where user management stays reachable via App Settings > Users). It opens a full Admin Panel modal: a users table with role, enabled/disabled status, bypass-permissions grant, live sessions, active logins, case count, and last login; per-user actions for Promote/Demote, Enable/Disable, Grant/Revoke bypass, Reset password (copyable one-time password), Force logout, and Delete (with an optional "also delete their files" step); and a proper add-user form (role, optional password, bypass checkbox) replacing the old prompt() flow. Each user's cases open in a drawer listing their case folders (modified date, live-session badge) with per-folder delete. Two new admin endpoints back this: `GET /api/admin/users/:username/cases` and `DELETE /api/admin/users/:username/cases/:caseName`, guarded like `deleteUserSpace` (symlinks refused, realpath confined to the user's space, folders in use by a live session refused with 409, audit-logged). The panel and the App Settings Users tab live-refresh on the SSE `admin:usersChanged` event (now wired in app.js). New coverage in `test/admin-routes.test.ts` (list/delete, traversal + symlink refusal, non-admin 403) and `test/admin-ui.test.ts` (button reveal gating, panel render, case drawer); verified end to end against a live multi-user instance with curl and Playwright.
**Also in this release:** README/docs synced with 1.6.0 (remote SSH cases, session manager, permissions) and fixed installer prompts when run via `curl | bash`.
**Recap of the recent feature line, for readers catching up:**
- **Multi-user mode (shipped 1.5.0, opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`).** Named users with scrypt-hashed passwords, per-user case spaces under `~/codeman-users/<name>/cases`, and full ownership scoping of sessions, cases, cron jobs, scheduled runs, search, file previews, and SSE/WS streams. Non-admin users default to Claude's classifier-guarded `--permission-mode auto`; shell mode, cron `launchCommand`, and skip-permissions bypass switches require the per-user `canBypassPermissions` grant (now toggleable from the Admin Panel). Admin API with one-time passwords, last-admin invariants, and an append-only audit log; self-service `/api/me` password change; `codeman users add|passwd|list|rm` CLI. Off by default is byte-identical to single-user. Note: multi-user separates workspaces for a trusted team; it is not a security boundary (all sessions share the host OS account), so pair it with Docker cases for real isolation.
- **Docker cases (shipped 1.4.0/1.4.1).** A case can run inside an isolated per-case container (any of the five CLI backends), with one-click "Run in Docker" quick-create, durable in-container tmux that survives Codeman restarts and resumes conversations after container stops, hardened container creation (cap-drop ALL, no-new-privileges, non-root, memory/pid limits, never privileged, never the docker socket), commit-safe seeded credentials, config-drift detection, GPU passthrough, and portable export/import bundles to move a whole case between machines.
- **1.6.0 highlights.** Remote SSH cases with durable remote tmux (survives SSH drops, auto-reconnect, shared multi-client attach, discover + attach with detach-not-kill); the Cmd+K session palette and unified Session Manager with pinning, cross-device tab order, and first/last prompt search; full-scrollback replay; and the multi-user permission downgrade now threading through to remote launch/attach.
## 1.6.0
### Minor Changes
- Remote tmux durability, Session Manager polish, and an opt-in Cron button.
**Remote sessions: durability, discovery, and auto-reconnect** (PR #156 by @aakhter, COD-104 to COD-109)
- Durable remote launches survive an SSH drop: the agent runs inside `tmux -L codeman-remote new-session -A` on the remote host, and reconnecting lands back in the same session.
- Discover + attach: a "Discover existing sessions" action per remote host lists `codeman-*` tmux sessions on the host's canonical socket (started by the remote's own Codeman or another instance) and attaches to one. Attached (non-owned) sessions detach on tab close, never kill; a structural early-return in `killSession()` guarantees no remote `kill-session` can ever be issued for a session Codeman doesn't own (COD-105).
- Shared/collaborative sessions: per-session `window-size latest` so concurrent clients at different viewports don't clamp each other, plus a "shared - N clients" badge in discovery results (COD-106).
- Auto-reconnect watcher: a bounded-backoff (5s to 5m, ~6 attempts) watcher detects a dead remote pane and reattaches the still-running remote tmux session; intentional kills/detaches are guarded and never revived. Kill-switch setting `remoteAutoReconnect` (default on). SSE `remote:sessionDropped`/`sessionReconnected`/`reconnectExhausted`, with a manual Reconnect toast after exhaustion (COD-108).
- Owned durable sessions propagate `kill-session` to the remote on close (COD-109); the remote tmux prereq probe is skipped under the test runner (COD-104).
- All ssh command lines continue to flow through the single shell-safe `buildSshConnectionArgs()` (COD-107). New design doc: `docs/remote-sessions.md`.
- Maintainer additions: the discovery endpoint is admin-gated in multi-user mode, and the remote launch/attach chooser threads the multi-user permission downgrade (`claudeMode`/`allowedTools`) through to the remote agent.
**Session Manager: pinning, cross-device ordering, name/prompt retention** (PR #157 by @aakhter, COD-131/139/140/142/143/145)
- Session pinning: pin a session to the top of the Session Manager list (`POST /api/sessions/:id/pin`, `session:pinned` SSE, amber highlight + pin glyph). Pinned group orders most-recently-pinned first (COD-139).
- Pinned sessions survive kill: killing a pinned session demotes its record to a lightweight stopped entry instead of removing it, so it stays visible and resumable; cleanup skips pinned records (COD-142). The pin route also works on these persisted-only records, so a pinned-then-killed session can always be unpinned.
- Cross-device tab order: tab order syncs via server state (`PUT /api/session-order`, `session:orderChanged` SSE, persisted in `state.json`); the pushing device wins and server-only ids fall to the end, never dropped (COD-131).
- Resuming from the Session Manager keeps the session's original name instead of always synthesizing a fresh `w<N>-<dir>` one (COD-143).
- firstPrompt backfill for sessions whose Codeman id is not the transcript UUID (claudeSessionId join, then newest transcript in the same workingDir), and the most recent prompt is shown alongside the first and included in search (COD-140/145).
**Cron button now opt-in** (hidden by default)
- The Cron footer-toolbar button follows the same opt-in pattern as the Session Manager / Away Digest / File Viewer buttons: hidden by default, enable per device under App Settings -> Display -> Header Displays. Cron jobs themselves are unchanged.
Also: `docs/remote-sessions.md` synced with the shipped `-L codeman-remote` / `codeman-ssh-<id8>` naming.
## 1.5.1
### Patch Changes
- Docker session-mode deep-review fixes — the work intended for the skipped **1.4.2**, now merged onto the 1.5.x line — plus a recap of the multi-user mode shipped in 1.5.0.
**Docker resume actually works now.** `DockerCase.lastClaudeSessionId` was read at quick-start but never written, so the documented resume-after-container-stop never fired. Claude-mode docker panes now pin a deterministic conversation id (`claudeDockerPaneCommand()`): a fresh launch runs `claude --session-id <id> || claude --resume <id>` (a duplicate `--session-id` exits 1 "already in use", so the fallback resumes after a container stop/reboot — verified CLI behavior), an explicit resume runs `--resume <rid> || --session-id <sid>` so a stale id never dead-panes. The id is persisted at launch and again on hook / last-response conversation-id adoption. Verified end-to-end across a `docker stop` + relaunch and a full container recreate.
**Config-drift detection + recreate (was documented but entirely missing).** The `codeman.confighash` label was stamped but never read, so docker-host config edits silently never applied. Quick-start now compares via `checkDockerConfigDrift()` and refuses a drifted launch with `CONFLICT`; the UI confirms and calls the new `POST /api/docker-cases/:name/recreate` (refused while the case has live sessions), then relaunches with the new config. New SSE event `docker:containerRecreated`.
**Model picker now applies to docker sessions.** `modelOverride` was absent from `QuickStartSchema`, so the App Settings Claude Model choice was silently inert for docker runs. It is now accepted and applied via `updateCaseModel` for local and docker quick-starts (still rejected for remote, where the settings file would land on the wrong machine).
**Import hardening.** `importDockerBundle` validates the untrusted cross-machine manifest before trusting any field (`validateImportManifest`: engine/image/containerWorkdir/network/caseName/schemaVersion — a hostile `engine` could previously select the probe binary); the outer bundle tar gets the same member-traversal guard as the inner workspace tar; the quarantine image tag derives from the schema-validated case name.
**Remote-daemon correctness.** All docker probes and the base-image auto-build now honor a host's `context`/`daemonHost` (`dockerEngineArgv`) instead of always probing the local daemon.
**Smaller fixes:** commas are rejected in docker workspace/workdir/destination paths (a comma corrupts the `--mount type=bind,src=…` CSV spec, which shell escaping cannot protect); a dead `this.escapeHtml` reference in the exports refresh is fixed; `docker:importComplete` / `docker:containerRecreated` get frontend SSE listeners so other open tabs refresh; the File Viewer header button is hidden on phone headers like its siblings.
**Docs.** CLAUDE.md + READMEs synced with the current feature set, including a full zh-CN README re-translation.
**Multi-user mode (recap — shipped in 1.5.0).** Opt-in named users (`--multiuser` / `CODEMAN_MULTIUSER=1`, off by default) with per-user case spaces and full ownership scoping of sessions, cases, cron jobs, scheduled runs, search, file previews, and real-time SSE/WS streams. Non-admin users default to Claude's classifier-guarded `--permission-mode auto`; raw shell mode, cron `launchCommand`, skip-permissions, and the Codex/Gemini bypass switches require an explicit per-user `canBypassPermissions` grant. Machine-level resources are admin-only. Admin API (`/api/admin/users*`) with one-time passwords, last-admin invariants, and an append-only audit log; self-service `/api/me` + password change; and a `codeman users add|passwd|list|rm` CLI. Off by default is byte-identical to single-user. Note: multi-user separates workspaces for a trusted team; it is not a security boundary between mutually-distrusting users (all sessions share the host OS account) — pair with Docker cases for real isolation.
## 1.5.0
### Minor Changes
+24 -15
View File
File diff suppressed because one or more lines are too long
+83 -22
View File
@@ -5,7 +5,7 @@
<h2 align="center">Mission control for AI coding agents</h2>
<p align="center">
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Terminal - One Dashboard &bull; Any Device</em>
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Gemini &bull; Terminal - One Dashboard &bull; Any Device</em>
</p>
<p align="center">
@@ -14,6 +14,8 @@
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
<a href="https://www.npmjs.com/package/aicodeman"><img src="https://img.shields.io/npm/v/aicodeman?style=flat-square&label=npm&color=22c55e" alt="npm version"></a>
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
</p>
<p align="center">
@@ -24,6 +26,15 @@
<img src="docs/images/subagent-demo.gif" alt="Codeman — parallel subagent visualization" width="900">
</p>
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, or Gemini CLI inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
- **One dashboard, four CLIs** - run [Claude Code, OpenCode, Codex, or Gemini](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions)
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
- **Nothing gets lost** - tmux persistence across restarts and network drops, exactly-once input delivery, full-scrollback replay
- **Self-hosted and boring on purpose** - loopback-only by default, MIT licensed, no telemetry, runs entirely on your machine
---
## Quick Start - Installation
@@ -32,18 +43,34 @@
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
```
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it.
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it. A few things worth knowing:
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), or [Codex](https://developers.openai.com/codex/cli) (any combination works). After install:
- **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.
- **Network or local-only, your choice.** The installer asks whether the dashboard should be reachable from other devices on your network (`0.0.0.0`, the default, with a strongly recommended password prompt) or from 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. 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.
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), or [Gemini CLI](https://github.com/google-gemini/gemini-cli) (any combination works). The installer detects whichever of the four is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install:
```bash
codeman web
# Open http://localhost:3000 and start your first session
```
**Sharing with a small team?** Start it in multi-user mode instead: each person gets their own login and workspace.
```bash
codeman users add alice --admin # create the first admin account
codeman web --multiuser # named logins + per-user case spaces
```
Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
<details>
<summary><strong>Run as a background service</strong></summary>
The installer's final menu sets this up for you (option 2) and verifies the service actually comes up before claiming success. To configure it manually instead:
**Linux (systemd):**
```bash
@@ -106,7 +133,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
```
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), or [Codex](https://developers.openai.com/codex/cli)). After installing, `http://localhost:3000` is accessible from your Windows browser.
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), or [Gemini CLI](https://github.com/google-gemini/gemini-cli)). After installing, `http://localhost:3000` is accessible from your Windows browser.
</details>
@@ -142,7 +169,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
### 3. Read the dashboard
- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder.
- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder (tab order syncs across your devices).
- **Terminal (center)** — a real `xterm.js` terminal; full TUIs render correctly. Type directly and press **Enter** to send. `Shift+Enter` inserts a newline.
- **Side panels** — Respawn, Ralph, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar).
@@ -155,13 +182,13 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
### 5. Make it autonomous
| Mode | Use it for | Where |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| **Respawn** | Long unattended runs — auto-restarts the CLI on idle/limit, with adaptive timing. Presets: `solo-work`, `overnight-autonomous`, … | Respawn tab |
| **Ralph / Todo** | A self-driving loop that tracks a todo list and keeps working until done. | Ralph tab |
| **Orchestrator** | Turn one goal into a phased plan and drive it to completion across agents. | Orchestrator panel |
| **Cron** | Saved, named jobs on a schedule (`once`/`interval`/`daily`/`weekly`) that spawn a session and send a prompt when due. | ⏰ Cron button |
| **Auto-resume** | Automatically continue after a subscription rate-limit resets. | Respawn tab (top) |
| Mode | Use it for | Where |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| **Respawn** | Long unattended runs — auto-restarts the CLI on idle/limit, with adaptive timing. Presets: `solo-work`, `overnight-autonomous`, … | Respawn tab |
| **Ralph / Todo** | A self-driving loop that tracks a todo list and keeps working until done. | Ralph tab |
| **Orchestrator** | Turn one goal into a phased plan and drive it to completion across agents. | Orchestrator panel |
| **Cron** | Saved, named jobs on a schedule (`once`/`interval`/`daily`/`weekly`) that spawn a session and send a prompt when due. | ⏰ Cron button _(opt-in: App Settings → Display → Header Displays)_ |
| **Auto-resume** | Automatically continue after a subscription rate-limit resets. | Respawn tab (top) |
### 6. Reach it from anywhere
@@ -171,7 +198,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
### 7. Operate & maintain
- **App Settings** — model, effort, theme/skin, notifications, display toggles, per-CLI options.
- **App Settings** — model, effort, permission startup mode, theme/skin, notifications, display toggles, per-CLI options, a synced custom display name, and per-device English/Simplified Chinese UI language.
- **Self-update** — git-clone installs update in place from **Settings → Updates**.
- **Deploy your own changes** — see [Development](#development).
@@ -318,6 +345,14 @@ Run **20 parallel sessions** with full visibility — real-time xterm.js termina
Every session runs inside **tmux** — sessions survive server restarts, network drops, and machine sleep. Auto-recovery on startup with dual redundancy. Ghost session discovery finds orphaned tmux sessions. Managed sessions are environment-tagged so the agent won't kill its own session.
### Session Manager & Command Palette
`Ctrl/Cmd/Alt+K` opens a fuzzy session palette; **Browse all sessions** opens the Session Manager: one deduped list of everything Codeman knows about (live sessions, past sessions from state and lifecycle history, and Claude transcripts), each row showing its first and most recent prompt.
- **Pinning**: pin a session to float it to the top of the list. Pinned sessions even survive kill (they demote to a lightweight stopped entry that stays visible and resumable).
- **Name retention**: resuming a past session keeps its original name instead of minting a new one.
- **Cross-device tab order**: drag-reordered tabs persist server-side, so your ordering follows you from desktop to phone.
### Hostname-Aware Window Title
Running Codeman on multiple hosts (laptop, dev box, NAS)? The browser tab title is `codeman:<hostname>` so you can tell which backend each tab points at without clicking in:
@@ -365,13 +400,15 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
## More Features
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
- **Multi-CLI** — run **Claude Code**, **OpenCode**, or **Codex** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, or **Gemini** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*`/`GOOGLE_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
- **Voice input** — dictate prompts with Deepgram Nova-3 (Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
- **Image input** — paste or drag-and-drop images straight into a session
- **Gesture control** _(opt-in)_ — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Display
- **Multi-monitor span** _(macOS)_ — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
- **File Viewer button** _(opt-in)_ — a header button that toggles the built-in file browser panel with one tap; enable under App Settings → Display → Header Displays
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean
- **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:<host>` so multi-host setups stay unambiguous
@@ -384,11 +421,26 @@ Run a case inside its own hardened Docker container instead of directly on your
- **One click** — on **New Case → Create New**, tick **🐳 Run in an isolated Docker container**. Codeman creates the case folder, spins up a container with default settings, and starts the agent inside it. No host/image/network fields to fill in.
- **Resource templates** — expand the checkbox for a **Small / Medium / Large / GPU** preset (memory, CPUs, GPU), or set your own. **Disk is elastic** — storage grows as data flows in, no fixed cap.
- **Shared per-case container** — many sessions can `docker exec` into the same container; killing one session never tears the container out from under the others.
- **Hardened by default** — non-root, `--cap-drop ALL`, `no-new-privileges`, PID/memory caps, never `--privileged` or the docker socket. Your existing `~/.claude` login is bind-mounted (credentials stay on the host, never captured in exports); a **sealed** profile (no host mounts, network off) is one toggle away.
- **Hardened by default** — non-root, `--cap-drop ALL`, `no-new-privileges`, PID/memory caps, never `--privileged` or the docker socket; a **sealed** profile (no host credentials, network off) is one toggle away.
- **Seamless auth, isolated credentials** — your host Claude / Codex / Gemini / OpenCode logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets.
- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case.
- **Durable** — reconnect after a restart lands back in the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript.
Prerequisite: Docker (or Podman) and the base image — build it once with `node scripts/build-agent-image.mjs`. Full guide: [`docs/docker-cases.md`](docs/docker-cases.md).
Prerequisite: just Docker (or Podman). The agent base image builds itself automatically on first use, with progress streamed to the UI (or pre-build it with `node scripts/build-agent-image.mjs`). Full guide: [`docs/docker-cases.md`](docs/docker-cases.md).
---
## Remote SSH Sessions
Point a case at another machine and run the agent **there**, over SSH, with the same dashboard, mobile UI, and autonomy features. Your laptop is just a window onto a session that lives on the remote host.
- **Durable by design**: the agent runs inside a dedicated tmux session on the remote host, so a dropped SSH connection, network change, or laptop sleep never kills the run. Reconnecting lands back in the same live conversation.
- **Auto-reconnect**: a bounded-backoff watcher notices a dead SSH pane and silently reattaches to the still-running remote session (kill-switch in settings; intentional kills are never revived).
- **Discover & attach**: list the `codeman-*` sessions already running on a host (started by that machine's own Codeman, or by another operator) and attach to one. Attached sessions you don't own **detach on tab close, never kill**.
- **Shared sessions**: several clients can attach the same remote session at different window sizes without clamping each other; discovery shows a "shared" badge with the client count.
- **Injection-safe**: every ssh command line flows through a single shell-escaping builder, and host/path/identity fields are schema-guarded.
Set it up under **New Case → Remote** (host, user, identity file, optional jump host). Full design: [`docs/remote-sessions.md`](docs/remote-sessions.md).
---
@@ -535,13 +587,14 @@ When someone authenticates via QR, the desktop shows a notification toast with t
## Security
Codeman launches sessions with `--dangerously-skip-permissions`, so the web UI is by design a remote-code-execution surface for whoever can reach it — the whole security model exists to control _who_ that is. Recent hardening (v0.9.0 + v0.9.5) closes the browser-driven attack paths that bite self-hosted dev tools. Full model: [`docs/security-architecture.md`](docs/security-architecture.md). **Found a vulnerability?** See [`SECURITY.md`](SECURITY.md) for private disclosure and the list of known limitations.
By default Codeman launches sessions with `--dangerously-skip-permissions`, so the web UI is by design a remote-code-execution surface for whoever can reach it — the whole security model exists to control _who_ that is. (The startup permission mode is configurable; see below.) Recent hardening (v0.9.0 + v0.9.5) closes the browser-driven attack paths that bite self-hosted dev tools. Full model: [`docs/security-architecture.md`](docs/security-architecture.md). **Found a vulnerability?** See [`SECURITY.md`](SECURITY.md) for private disclosure and the list of known limitations.
### Network & access
- **Loopback by default** — binds `127.0.0.1`, reachable only from the same machine, so the no-password default is safe out of the box. Binding a non-loopback host without `CODEMAN_PASSWORD` _starts but prints a loud warning_ with three concrete fixes (set a password, loopback + an authenticated tunnel, or explicitly acknowledge with `--allow-unauthenticated-network`)
- **Loopback by default** — the server binary binds `127.0.0.1`, reachable only from the same machine, so the no-password default is safe out of the box (the guided installer asks about network access and configures the binding + password for you). Binding a non-loopback host without `CODEMAN_PASSWORD` _starts but prints a loud warning_ with three concrete fixes (set a password, loopback + an authenticated tunnel, or explicitly acknowledge with `--allow-unauthenticated-network`)
- **Optional auth, real sessions** — HTTP Basic via `CODEMAN_USERNAME` (default `admin`) / `CODEMAN_PASSWORD`. Success issues an opaque 256-bit `codeman_session` cookie (`randomBytes(32)`) — validated server-side, not client-signed, so it can't be forged offline (24h TTL, auto-extend, device-context audit log)
- **Per-IP rate limiting** — 10 failed attempts → `429` with `Retry-After` (15-min decay). A valid cookie or correct password recovers _immediately_ even while an attacker hammers the same IP — important because all tunnel traffic shares one loopback IP. QR auth has its own separate limiter
- **Configurable permission mode** - `--dangerously-skip-permissions` is only the default. **App Settings → Claude CLI → Startup Mode** can switch new sessions to Anthropic's classifier-guarded `auto` mode (low-prompt, needs Claude Code 2.1.207+), `normal` prompting, or an explicit allowed-tools list. In multi-user mode, non-granted users are forced to `auto`, and shell sessions / skip-permissions require an explicit per-user grant
### Always-on browser hardening (v0.9.5)
@@ -555,7 +608,7 @@ These run for **every** request — before auth, even on the default no-password
### Input, files & headers
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` env-prefix allowlist gates which settings each CLI can receive
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` env-prefix allowlist gates which settings each CLI can receive
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 50 MB raw & download; `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
@@ -691,7 +744,7 @@ Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_pro
## API
REST over Fastify — **~160 handlers across 18 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse<T>` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset:
REST over Fastify — **~190 handlers across 20 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse<T>` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset:
### Sessions
@@ -701,6 +754,9 @@ REST over Fastify — **~160 handlers across 18 route modules**, plus an SSE str
| `POST` | `/api/quick-start` | Create case + start session (`{caseName?, mode?, effort?, envOverrides?}`) |
| `POST` | `/api/sessions/:id/input` | Send input (`{input, useMux?, clientId?, seq?}` — `clientId`+`seq` = exactly-once) |
| `GET` | `/api/sessions/:id/output` | Read terminal output |
| `GET` | `/api/sessions/unified` | Unified live + history list (Session Manager) — `?q=&limit=` |
| `POST` | `/api/sessions/:id/pin` | Pin/unpin in the Session Manager (`{pinned}`) |
| `PUT` | `/api/session-order` | Sync tab order across devices (`{order: [ids]}`) |
| `DELETE` | `/api/sessions/:id` | Delete session |
### Respawn
@@ -791,7 +847,7 @@ flowchart TB
end
subgraph External["External"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex</small>"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Gemini</small>"]
BG["Background Agents<br/><small>(Task tool)</small>"]
end
end
@@ -823,7 +879,7 @@ flowchart TB
npm install
npx tsx src/index.ts web # Dev mode
npm run build # Production build
npm test # Run tests
npm run test:ci # Run tests (the CI suite; browser suites need extra setup)
```
See [CLAUDE.md](./CLAUDE.md) for full documentation.
@@ -880,3 +936,8 @@ MIT — see [LICENSE](LICENSE)
<p align="center">
<strong>Track sessions. Visualize agents. Control respawn. Let it run while you sleep.</strong>
</p>
<p align="center">
If Codeman saves you time, <a href="https://github.com/Ark0N/Codeman/stargazers">a star</a> helps other people find it.<br>
Bug reports and feature ideas are welcome in <a href="https://github.com/Ark0N/Codeman/issues">Issues</a>.
</p>
+353 -94
View File
@@ -5,7 +5,7 @@
<h2 align="center">AI 编程智能体的任务控制中心</h2>
<p align="center">
<em>Claude Code &bull; OpenCode &bull; Codex —— 统一仪表盘 &bull; 任意设备</em>
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Gemini &bull; 终端 —— 统一仪表盘 &bull; 任意设备</em>
</p>
<p align="center">
@@ -14,7 +14,7 @@
<p align="center">
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="License: MIT"></a>
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-18%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 18+"></a>
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-22%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 22+"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
@@ -34,19 +34,35 @@
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
```
该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。
该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。几点须知:
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai) 或 [Codex](https://developers.openai.com/codex/cli)(任意组合均可)。安装完成后:
- **先询问,后改动。** 所有系统级改动(安装软件包、下载 AI CLI)都会先征求确认;结束时的菜单可选择:直接在本终端运行、安装为后台服务(systemd/launchd,开机自启),或暂不启动。不选就不会有任何后台进程。
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。
- **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli)(任意组合均可)。安装器会自动检测这四个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后:
```bash
codeman web
# 打开 http://localhost:3000,开启你的第一个会话
```
**想和小团队共用一台?** 改用多用户模式启动:每人拥有自己的登录与工作空间。
```bash
codeman users add alice --admin # 创建第一个管理员账号
codeman web --multiuser # 命名登录 + 按用户隔离的案例空间
```
详见下文[多用户模式](#多用户模式可选启用)。
<details>
<summary><strong>作为后台服务运行</strong></summary>
安装器结尾的菜单(选项 2)可以帮你完成这一步,并在宣告成功前校验服务确实已启动。如需手动配置:
**Linux(systemd):**
```bash
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/codeman-web.service << EOF
@@ -69,6 +85,7 @@ loginctl enable-linger $USER
```
**macOS(launchd):**
```bash
mkdir -p ~/Library/LaunchAgents
cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
@@ -96,6 +113,7 @@ cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
EOF
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
```
</details>
<details>
@@ -105,11 +123,79 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
```
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai) 或 [Codex](https://developers.openai.com/codex/cli))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
</details>
---
## 使用 Codeman —— 人类操作指南
从头到尾走一遍如何在浏览器里驾驭 Codeman。如果你刚装好,就从这里开始。
### 1. 启动服务器
```bash
codeman web # localhost:3000(仅环回 —— 安全默认值)
codeman web --port 8080 # 自定义端口(或设置 CODEMAN_PORT)
codeman web --https # 自签名 TLS(仅远程访问时需要)
codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_PASSWORD(见「安全」)
```
打开打印出的 URL。整个页面是一个单一仪表盘;下面的一切都在这里完成。
### 2. 创建你的第一个会话
点击 **+ New Session**(或 **Quick Start**)。一个会话就是一个运行在自己 tmux 终端里的 AI CLI。你可以选择:
| 字段 | 作用 |
| ---------------------- | ------------------------------------------------------------------------------------------- |
| **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。 |
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Gemini` 或 `Terminal`(普通 shell)。 |
| **模型** | 每会话模型(App Settings → Claude Model)。软默认值 —— 会话内 `/model` 依然有效。 |
| **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 |
点击启动 —— Codeman 通过真实 PTY 拉起 CLI,并经 SSE 流式传输到你的浏览器。
### 3. 读懂仪表盘
- **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序(标签顺序会跨设备同步)。
- **终端(中央)** —— 真实的 `xterm.js` 终端;完整 TUI 正常渲染。直接输入并按 **Enter** 发送。`Shift+Enter` 插入换行。
- **侧边面板** —— Respawn、Ralph、Orchestrator、Cron、Subagents、Settings(从工具栏切换)。
### 4. 与智能体对话
- **直接在终端输入提示** —— 即使跨越重连,输入也是精确一次送达(连接中断绝不会丢失或重复发送提示)。
- **粘贴或拖放图片**,直接进入会话。
- **语音输入** —— `Ctrl+Shift+V`(Deepgram Nova-3,自动静音停止)。
- **附件** —— 注册外部文件/文档,并内联预览 Office/PDF。
### 5. 让它自主运行
| 模式 | 用途 | 位置 |
| ---------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| **Respawn** | 长时间无人值守运行 —— 空闲/限额时自动重启 CLI,带自适应时序。预设:`solo-work`、`overnight-autonomous` 等 | Respawn 标签页 |
| **Ralph / Todo** | 一个自驱循环,跟踪 todo 列表并持续工作直到完成。 | Ralph 标签页 |
| **Orchestrator** | 把一个目标变成分阶段计划,并跨多个智能体推动完成。 | 编排器面板 |
| **Cron** | 已保存的、命名的定时任务(`once`/`interval`/`daily`/`weekly`),到期时拉起会话并发送提示。 | ⏰ Cron 按钮(可选启用:App Settings → Display → Header Displays) |
| **Auto-resume** | 订阅限额重置后自动继续。 | Respawn 标签页(顶部) |
### 6. 随时随地访问
- **手机/平板** —— UI 完全触控优化;扫描桌面上的**二维码**即可免密码登录。
- **网络之外** —— `./scripts/tunnel.sh start` 打开一条 Cloudflare 隧道(先设置 `CODEMAN_PASSWORD`)。
- **SSH** —— `sc` 选择器可从终端附着任意会话(`sc` 交互式,`sc 2` 快速附着,`sc -l` 列表)。
### 7. 运维与维护
- **App Settings** —— 模型、effort、权限启动模式、主题/皮肤、通知、显示开关、各 CLI 的专属选项,以及跨设备同步的自定义显示名称和按设备保存的英文/简体中文界面语言。
- **自更新** —— git-clone 安装可在 **Settings → Updates** 中原地更新。
- **部署你自己的改动** —— 见[开发](#开发)。
> ⚠️ **安全提示:** 如果你正在 Codeman 受管会话*内部*工作(`echo $CODEMAN_MUX` → `1`),绝不要直接运行 `tmux kill-session` / `pkill claude` —— 请使用 Web UI 或 `./scripts/tmux-manager.sh`。
---
## 移动端优化的 Web UI
在任意手机上都能获得最跟手的 AI 编程智能体体验。完整的 xterm.js 终端、本地回显、滑动导航,以及为真正的远程办公而设计的触控优化界面 —— 而不是把桌面 UI 硬塞进小屏幕。
@@ -216,7 +302,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
```
- **多层空闲检测** —— 完成消息、AI 驱动的空闲检查、输出静默、token 稳定性
- **用量限额自动恢复**(*可选,默认关闭*)—— 当 Claude 因订阅用量限额而停止("You've hit your limit · resets 3pm")时,Codeman 会解析重置时间,等到限额刷新(外加 2 分钟安全缓冲)后自动关闭限额对话框并发送 `continue`,让通宵任务平稳跨过 5 小时窗口而不是停摆到早晨。可识别 Claude Code 各版本的全部限额消息格式;若仍受限会自动重试;计划在 Codeman 重启后依然生效;暂停期间会阻止重生循环,避免 `/clear` 清掉等待中的对话。在会话 Respawn 标签页顶部按会话启用
- **用量限额自动恢复**(_可选,默认关闭_)—— 当 Claude 因订阅用量限额而停止("You've hit your limit · resets 3pm")时,Codeman 会解析重置时间,等到限额刷新(外加 2 分钟安全缓冲)后自动关闭限额对话框并发送 `continue`,让通宵任务平稳跨过 5 小时窗口而不是停摆到早晨。可识别 Claude Code 各版本的全部限额消息格式;若仍受限会自动重试;计划在 Codeman 重启后依然生效;暂停期间会阻止重生循环,避免 `/clear` 清掉等待中的对话。在会话 Respawn 标签页顶部按会话启用
- **熔断器** —— 当 Claude 卡住时防止重生抖动(CLOSED → HALF_OPEN → OPEN 状态,跟踪连续无进展与重复错误)
- **健康评分** —— 0–100 健康分,分项涵盖循环成功率、熔断器状态、迭代进展与卡死恢复
- **内置预设** —— `solo-work`(3s 空闲,60min)、`subagent-workflow`(45s,240min)、`team-lead`(90s,480min)、`ralph-todo`(8s,480min)、`overnight-autonomous`(10s,480min)
@@ -249,6 +335,14 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
每个会话都运行在 **tmux** 内 —— 会话可在服务器重启、网络中断与机器休眠后存续。启动时自动恢复,具备双重冗余。幽灵会话发现机制能找到孤立的 tmux 会话。受管会话带有环境标签,因此智能体不会杀掉自己的会话。
### 会话管理器与命令面板
`Ctrl/Cmd/Alt+K` 打开模糊搜索的会话面板;**Browse all sessions** 打开会话管理器:一份去重后的完整清单,涵盖 Codeman 所知的一切(活动会话、来自状态与生命周期历史的既往会话,以及 Claude 转录),每一行都显示其第一条与最近一条提示。
- **置顶(Pin)**:把会话固定到列表顶部。被置顶的会话甚至能挺过被杀掉(降级为一条轻量的已停止记录,依然可见、可恢复)。
- **名称保留**:从会话管理器恢复既往会话时保留其原有名称,而不是生成一个新名称。
- **跨设备标签顺序**:拖拽排序的标签顺序保存在服务端,你的排列会从桌面跟随到手机。
### 主机名感知的窗口标题
在多台主机上运行 Codeman(笔记本、开发机、NAS)?浏览器标签标题是 `codeman:<主机名>`,让你无需点进去就能分辨每个标签对应哪个后端:
@@ -262,10 +356,10 @@ codeman web --title-hostname dev-box # codeman:dev-box(用于覆盖嘈
### 智能 Token 管理
| 阈值 | 动作 | 结果 |
|-----------|--------|--------|
| 阈值 | 动作 | 结果 |
| --------------- | --------------- | ---------------------- |
| **110k tokens** | 自动 `/compact` | 上下文被摘要,工作继续 |
| **140k tokens** | 自动 `/clear` | 以 `/init` 全新开始 |
| **140k tokens** | 自动 `/clear` | 以 `/init` 全新开始 |
### 通知
@@ -296,17 +390,70 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
## 更多特性
- **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新)
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode** 或 **Codex**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*` 与 `CODEX_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex** 或 **Gemini**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*` 与 `GEMINI_*`/`GOOGLE_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)
- **Docker 会话** —— 在隔离且加固的容器中运行案例。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一案例的多个会话共享一个容器;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md)
- **远程 SSH 会话**:把案例指向另一台机器,让智能体在那里一个持久的远程 tmux 中运行:SSH 断连不中断任务、自动重连,还能发现并附着主机上已在运行的会话。详见 [`docs/remote-sessions.md`](docs/remote-sessions.md)
- **Effort 与 Ultracode** —— 设置每会话的默认 effort(`low`–`max`),或启用 **ultracode**(动态多智能体工作流)。这些都只是软默认值 —— 会话中可随时用 `/effort` 切换。扩展思考预算也可配置
- **语音输入** —— 用 Deepgram Nova-3 口述提示(带 Web Speech API 回退):切换录音、自动静音停止、实时音量表(`Ctrl+Shift+V`)
- **图像输入** —— 直接把图片粘贴或拖放进会话
- **手势控制** *(可选)* —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Display 启用
- **多显示器横跨** *(macOS)* —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝
- **手势控制** _(可选)_ —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Display 启用
- **多显示器横跨** _(macOS)_ —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝
- **文件查看器按钮** _(可选)_ —— 头部新增一个按钮,一键切换内置文件浏览器面板;在 App Settings → Display → Header Displays 中启用
- **CJK / 输入法支持** —— 完整支持中文 / 日文 / 韩文的组合输入
- **操作系统通知与主机名感知标题** —— 桌面提醒与标签标题以 `codeman:<host>` 为前缀,使多主机配置不再含糊
---
## 隔离的 Docker 会话
让案例(case)运行在专属的加固 Docker 容器里,而不是直接跑在主机上:获得安全隔离、可复现的工具链和一键可移植性。
- **一键启动** —— 在 **New Case → Create New** 中勾选 **🐳 Run in an isolated Docker container**。Codeman 会创建案例文件夹、用默认设置启动容器,并在容器内启动智能体。无需填写任何主机/镜像/网络字段。
- **资源模板** —— 展开复选框可选 **Small / Medium / Large / GPU** 预设(内存、CPU、GPU),也可以完全自定义。**磁盘是弹性的** —— 存储随数据增长,没有固定上限。
- **按案例共享容器** —— 多个会话可以 `docker exec` 进同一个容器;结束某个会话绝不会影响其他会话所在的容器。
- **默认加固** —— 非 root、`--cap-drop ALL`、`no-new-privileges`、PID/内存上限,绝不使用 `--privileged` 或 docker socket;**密封(sealed)** 配置(不注入主机凭据、关闭网络)只需一个开关。
- **无感认证、凭据隔离** —— 主机上的 Claude / Codex / Gemini / OpenCode 登录在容器内开箱即用:凭据在启动时以只读种子方式复制注入,onboarding/信任提示已预先答复,不会弹出登录向导。容器保留自己的副本,绝不回写主机的凭据存储;跨边界共享的只有对话转录,导出文件也绝不包含机密。
- **迁移到另一台机器** —— 把容器的完整环境(工具链 + 工作区)导出为可移植的 `.tar.gz`,在另一台机器上导入到新案例即可继续。
- **持久耐用** —— Codeman 重启后重连会回到同一个存活的智能体;容器停止/重启后则从绑定挂载的转录恢复对话。
前置条件:只需 Docker(或 Podman)。智能体基础镜像会在首次使用时自动构建,构建进度实时显示在 UI 中(也可用 `node scripts/build-agent-image.mjs` 预构建)。完整指南:[`docs/docker-cases.md`](docs/docker-cases.md)。
---
## 远程 SSH 会话
把案例(case)指向另一台机器,通过 SSH 让智能体**在那台机器上**运行,同时保留同样的仪表盘、移动端 UI 与自主运行特性。你的笔记本只是一扇窗口,会话本体活在远程主机上。
- **天生持久**:智能体运行在远程主机上一个专用的 tmux 会话里,SSH 断连、网络切换或笔记本休眠都不会中断任务。重新连接后回到同一个活跃对话。
- **自动重连**:一个带上限退避的监视器发现 SSH 面板断开后,会静默重新附着到仍在运行的远程会话(设置中有总开关;主动杀掉的会话绝不会被复活)。
- **发现与附着**:列出主机上已在运行的 `codeman-*` 会话(由那台机器自己的 Codeman 或其他操作者启动)并附着其一。非你所有的已附着会话在关闭标签时**只分离,绝不杀掉**。
- **共享会话**:多个客户端可以以不同窗口尺寸同时附着同一个远程会话而互不挤压;发现列表会显示带客户端计数的「shared」徽标。
- **注入安全**:所有 ssh 命令行都经由单一的 shell 转义构建器生成,主机/路径/身份文件字段均有模式校验。
在 **New Case → Remote** 中配置(主机、用户、身份文件、可选跳板机)。完整设计:[`docs/remote-sessions.md`](docs/remote-sessions.md)。
---
## 多用户模式(可选启用)
与一个小型互信团队共享同一个 Codeman,每人拥有自己的登录与工作空间。**默认关闭**:不加该开关时,行为与单用户完全一致。
用 `codeman web --multiuser`(或 `CODEMAN_MULTIUSER=1`)启用。创建第一个管理员后,可通过 CLI 或 App Settings 中的 **Users** 标签页管理用户:
```bash
codeman users add alice --admin # 提示输入密码(或 --password-stdin)
codeman users add bob # 普通用户
codeman users list
```
- **按用户的空间**:每个用户的案例位于 `~/codeman-users/<name>/cases`;会话、案例、搜索与实时事件都按属主隔离。管理员可以看到全部。
- **可单独吊销的登录**:命名用户的密码以 scrypt 哈希保存在 `~/.codeman/users.json`;可随时禁用、重置(一次性密码)或删除账号。管理员操作审计记录在 `~/.codeman/admin-audit.jsonl`。
- **普通用户的更安全默认值**:非管理员以 `--permission-mode auto` 运行 Claude(Anthropic 的分类器护栏模式);raw shell 会话、cron `launchCommand` 与跳过权限模式需要按用户显式授权。
> ⚠️ **这只是工作空间的划分,不是用户之间的沙箱。** 所有会话都以同一个操作系统账户运行,因此有心用户的智能体依然能触及他人的文件。若需要真正的隔离,请结合 **Docker 案例**,或在不同的操作系统账户下运行独立实例。参见 [`docs/multi-user-plan.md`](docs/multi-user-plan.md) 与 [`docs/security-architecture.md`](docs/security-architecture.md) 的多用户章节。
---
## 远程访问 —— Cloudflare 隧道
使用免费的 [Cloudflare 快速隧道](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/),从手机或本地网络外的任意设备访问 Codeman —— 无需端口转发、无需 DNS、无需静态 IP。
@@ -374,14 +521,14 @@ loginctl enable-linger $USER
该设计参考了 ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin)(USENIX Security 2025),该研究发现 Top-100 网站中有 47 个因横跨 42 个 CVE 的 6 个关键设计缺陷而易受二维码认证攻击。Codeman 全部六个都做了应对:
| USENIX 缺陷 | 缓解措施 |
|-------------|------------|
| **缺陷 1**:缺少一次性强制 | 令牌首次扫描即原子性消费 —— 重放永远失败 |
| **缺陷 2**:长生命周期令牌 | 60s TTL + 90s 宽限,由定时器自动轮换 |
| **缺陷 3**:可预测的令牌生成 | `crypto.randomBytes(32)` —— 256 位熵。短码采用拒绝采样以消除取模偏差 |
| **缺陷 4**:客户端令牌生成 | 仅服务端 —— 令牌在嵌入二维码前绝不离开服务器 |
| **缺陷 5**:缺少状态通知 | 桌面提示:*「设备 [IP] 已通过二维码认证(Safari)。不是你?[吊销]」* —— 实时 QRLjacking 检测 |
| **缺陷 6**:会话绑定不足 | 存储 IP + User-Agent 以供审计。通过 API 手动吊销会话。HttpOnly + Secure + SameSite=lax cookie |
| USENIX 缺陷 | 缓解措施 |
| ---------------------------- | --------------------------------------------------------------------------------------------- |
| **缺陷 1**:缺少一次性强制 | 令牌首次扫描即原子性消费 —— 重放永远失败 |
| **缺陷 2**:长生命周期令牌 | 60s TTL + 90s 宽限,由定时器自动轮换 |
| **缺陷 3**:可预测的令牌生成 | `crypto.randomBytes(32)` —— 256 位熵。短码采用拒绝采样以消除取模偏差 |
| **缺陷 4**:客户端令牌生成 | 仅服务端 —— 令牌在嵌入二维码前绝不离开服务器 |
| **缺陷 5**:缺少状态通知 | 桌面提示:_「设备 [IP] 已通过二维码认证(Safari)。不是你?[吊销]」_ —— 实时 QRLjacking 检测 |
| **缺陷 6**:会话绑定不足 | 存储 IP + User-Agent 以供审计。通过 API 手动吊销会话。HttpOnly + Secure + SameSite=lax cookie |
#### 时序安全的查找
@@ -406,23 +553,23 @@ URL 被刻意保持精简(`/q/` 路径 + 6 字符码 ≈ 53–56 个字符)
#### 威胁覆盖
| 威胁 | 为何无效 |
|--------|-------------------|
| **二维码截图被分享** | 一次性:首次扫描即消费。60s TTL:攻击者动手前已过期。桌面通知会立即提醒你。 |
| **重放攻击** | 原子性一次性消费 + 60s TTL。旧 URL 始终返回 401。 |
| 威胁 | 为何无效 |
| ----------------------- | ------------------------------------------------------------------------------------ |
| **二维码截图被分享** | 一次性:首次扫描即消费。60s TTL:攻击者动手前已过期。桌面通知会立即提醒你。 |
| **重放攻击** | 原子性一次性消费 + 60s TTL。旧 URL 始终返回 401。 |
| **Cloudflare 边缘日志** | 短码是不透明的 6 字符查找键,而非真正的 256 位令牌。一次性意味着从日志重放永远失败。 |
| **暴力破解** | 568 亿种组合、任意时刻约 2 个有效、双层速率限制,早在统计可行性之前就已拦截。 |
| **QRLjacking** | 60s 轮换迫使实时转发。桌面提示提供即时检测。自托管单用户场景使钓鱼难以成立。 |
| **时序攻击** | 基于哈希的 Map 查找 —— 无字符串比较时序泄露。 |
| **会话 cookie 窃取** | HttpOnly + Secure + SameSite=lax + 24h TTL。可在 `POST /api/auth/revoke` 手动吊销。 |
| **暴力破解** | 568 亿种组合、任意时刻约 2 个有效、双层速率限制,早在统计可行性之前就已拦截。 |
| **QRLjacking** | 60s 轮换迫使实时转发。桌面提示提供即时检测。自托管单用户场景使钓鱼难以成立。 |
| **时序攻击** | 基于哈希的 Map 查找 —— 无字符串比较时序泄露。 |
| **会话 cookie 窃取** | HttpOnly + Secure + SameSite=lax + 24h TTL。可在 `POST /api/auth/revoke` 手动吊销。 |
#### 横向对比
| 平台 | 模型 | 对比 |
|----------|-------|------------|
| **Discord** | 长生命周期令牌、无确认、[屡被利用](https://owasp.org/www-community/attacks/Qrljacking) | Codeman:一次性 + TTL + 通知 |
| **WhatsApp Web** | 手机确认「关联设备?」,约 60s 轮换 | 轮换相当;WhatsApp 额外加了显式确认(对单用户而言是可接受的取舍) |
| **Signal** | 临时公钥、端到端加密信道 | 加密更强,但 [2025 年仍被俄罗斯国家级行为者](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger)通过社会工程攻破 |
| 平台 | 模型 | 对比 |
| ---------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Discord** | 长生命周期令牌、无确认、[屡被利用](https://owasp.org/www-community/attacks/Qrljacking) | Codeman:一次性 + TTL + 通知 |
| **WhatsApp Web** | 手机确认「关联设备?」,约 60s 轮换 | 轮换相当;WhatsApp 额外加了显式确认(对单用户而言是可接受的取舍) |
| **Signal** | 临时公钥、端到端加密信道 | 加密更强,但 [2025 年仍被俄罗斯国家级行为者](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger)通过社会工程攻破 |
> 完整设计理由、安全分析与实现细节:[`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
@@ -430,13 +577,14 @@ URL 被刻意保持精简(`/q/` 路径 + 6 字符码 ≈ 53–56 个字符)
## 安全
Codeman 用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设计上对任何能访问到它的人都是一个远程代码执行面 —— 整套安全模型的存在就是为了控制*谁*能访问。近期加固(v0.9.0 + v0.9.5)封堵了那些常困扰自托管开发工具的浏览器驱动攻击路径。完整模型:[`docs/security-architecture.md`](docs/security-architecture.md)。
Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设计上对任何能访问到它的人都是一个远程代码执行面 —— 整套安全模型的存在就是为了控制*谁*能访问。(启动权限模式可配置,见下文。)近期加固(v0.9.0 + v0.9.5)封堵了那些常困扰自托管开发工具的浏览器驱动攻击路径。完整模型:[`docs/security-architecture.md`](docs/security-architecture.md)。**发现了漏洞?** 私下披露方式与已知限制清单见 [`SECURITY.md`](SECURITY.md)。
### 网络与访问
- **默认仅环回** —— 绑定 `127.0.0.1`,仅可从本机访问,因此「无密码」默认配置开箱即安全。在未设置 `CODEMAN_PASSWORD` 的情况下绑定非环回主机会*启动但打印一条醒目警告*,并给出三个具体修复方案(设置密码、环回 + 一个带认证的隧道,或用 `--allow-unauthenticated-network` 显式确认)
- **可选认证,真实会话** —— 通过 `CODEMAN_USERNAME`(默认 `admin`)/ `CODEMAN_PASSWORD` 的 HTTP Basic 认证。成功后签发一个不透明的 256 位 `codeman_session` cookie(`randomBytes(32)`)—— 服务端校验,而非客户端签名,因此无法离线伪造(24h TTL、自动延长、设备上下文审计日志)
- **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。二维码认证有自己独立的限制器
- **可配置的权限模式**:`--dangerously-skip-permissions` 只是默认值。**App Settings → Claude CLI → Startup Mode** 可以把新会话切换为 Anthropic 的分类器护栏 `auto` 模式(低打扰,需要 Claude Code 2.1.207+)、`normal` 提示模式,或一份显式的允许工具列表。多用户模式下,未获授权的用户会被强制为 `auto`,shell 会话与跳过权限需要按用户显式授权
### 始终开启的浏览器加固(v0.9.5)
@@ -450,7 +598,7 @@ Codeman 用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设
### 输入、文件与响应头
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 50 MB 原始与下载;`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
@@ -481,73 +629,180 @@ sc -l # 列出会话
> Ctrl 绑定在 macOS 上也接受 Cmd。
| 快捷键 | 动作 |
|----------|--------|
| `Ctrl/Cmd+W` | 杀掉当前会话 |
| `Ctrl/Cmd+Tab` | 下一个会话 |
| `Alt+1`–`Alt+9` | 切换到第 N 个标签 |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | 将当前标签左移 / 右移 |
| `Ctrl/Cmd+L` | 清屏 |
| `Ctrl+Shift+R` | 恢复终端尺寸 |
| `Ctrl+Shift+V` | 切换语音输入 |
| `Ctrl/Cmd +` / `-` | 字体大小 |
| `Ctrl/Cmd+?` | 键盘帮助 |
| `Shift+Enter` | 插入换行(发送到终端) |
| `Escape` | 关闭面板与模态框 |
| 快捷键 | 动作 |
| ------------------------------- | -------------------------------------------------------- |
| `Ctrl/Cmd+W` | 杀掉当前会话 |
| `Ctrl/Cmd/Option+K` | 查找已打开的会话或新建一个 |
| `Ctrl/Cmd+Tab` | 下一个会话 |
| `Alt/Option+[` / `Alt/Option+]` | 上一个 / 下一个会话 |
| `Alt/Option+1`–`Alt/Option+9` | 切换到第 N 个标签(按物理键位,macOS Option 布局也适用) |
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | 将当前标签左移 / 右移 |
| `Ctrl/Cmd+L` | 清屏 |
| `Ctrl+Shift+R` | 恢复终端尺寸 |
| `Ctrl+Shift+V` | 切换语音输入 |
| `Ctrl/Cmd +` / `-` | 字体大小 |
| `Ctrl/Cmd+?` | 键盘帮助 |
| `Shift+Enter` | 插入换行(发送到终端) |
| `Escape` | 关闭面板与模态框 |
---
## 从智能体驱动 Codeman —— 编程指南
面向不经浏览器控制 Codeman 的 AI 智能体与自动化:一个拉起工作会话的智能体、一个 CI 机器人,或是**运行在 Codeman 会话*内部*、编排其他会话的 Claude Code**。UI 能做的一切都是 HTTP + CLI,因此智能体也能做。
### 检测自己身处 Codeman 内部
当 CLI 运行在 Codeman 受管会话中时,以下环境变量会被设置 —— 读取它们,别硬编码任何东西:
| 变量 | 含义 |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `CODEMAN_MUX=1` | 你在一个受管 tmux 会话里。**绝不要** `tmux kill-session` / `pkill claude` / `pkill tmux` —— 你会杀掉自己或兄弟会话。 |
| `CODEMAN_API_URL` | API 的基础 URL(例如 `https://127.0.0.1:3000`)。下面每个调用都用它。 |
| `CODEMAN_SESSION_ID` | *你自己的*会话 id。用它避免对自己下手。 |
| `CODEMAN_HOOK_SECRET_FILE` | hook 密钥文件的路径(受管隧道开启时调用 `/api/hook-event` 必需)。 |
### 行路规则(POST 之前先读)
1. **只发单行输入。** 编程输入会作为字面文本 **+ Enter** 一次性发送。多行字符串会破坏智能体 TUI(Ink)—— 发送一行,或拆成多次调用。
2. **让输入幂等。** 在 `POST …/input` 上带上稳定的 `clientId` 和按会话单调递增的 `seq`。服务端会去重,因此连接中断后的重试不会重复投递提示。
3. **认证。** 若设置了 `CODEMAN_PASSWORD`,发送 HTTP Basic 认证(用户 `admin` 或 `CODEMAN_USERNAME`)或 `codeman_session` cookie。默认的环回安装无密码。缺失的 `Origin` 头被允许,因此普通 `curl` 可用;跨站的浏览器 origin 会被拒绝(CSRF 防护)。
4. **响应信封。** 多数端点返回 `{ "success": true, "data": … }`(错误:`{ "success": false, "error", "errorCode" }`)。少数遗留 GET 返回裸响应体 —— **两种都要处理**(`body.data ?? body`)。
5. **`/api/v1/*`** 是 `/api/*` 的稳定别名。
### 常用配方
```bash
API="${CODEMAN_API_URL:-http://127.0.0.1:3000}"
# (若设置了密码,给每个调用加上 -u admin:"$CODEMAN_PASSWORD")
# 1. 看看有什么在运行
curl -s "$API/api/sessions" | jq '.data // .'
# 2. 拉起一个工作会话(「case」= 命名工作目录)
curl -s -X POST "$API/api/quick-start" \
-H 'Content-Type: application/json' \
-d '{"caseName":"refactor-auth","mode":"claude","effort":"high"}' | jq
# 3. 向会话发送提示(精确一次:clientId + seq)
curl -s -X POST "$API/api/sessions/$SID/input" \
-H 'Content-Type: application/json' \
-d '{"input":"Run the test suite and summarize failures","useMux":true,"clientId":"agent-1","seq":1}'
# 4. 读回终端内容
curl -s "$API/api/sessions/$SID/output" | jq -r '.data // .'
# 5. 流式接收实时事件(会话输出、智能体活动、状态)
curl -sN "$API/api/events" # Server-Sent Events
# 6. 调度周期性工作(cron 风格任务)
curl -s -X POST "$API/api/cron/jobs" \
-H 'Content-Type: application/json' \
-d '{"name":"nightly-deps","agentType":"claude","workingDir":"/home/me/proj",
"promptMode":"inline_text","promptText":"Update dependencies and open a PR",
"inputMode":"typed","scheduleType":"daily","dailyTime":"03:00",
"enabled":true,"concurrencyPolicy":"warn_only"}' | jq
# 7. 查看后台子智能体及其活动记录
curl -s "$API/api/subagents" | jq '.data // .'
curl -s "$API/api/subagents/$AID/transcript" | jq -r '.data // .'
# 8. 全系统快照(会话、设置、重生、统计)
curl -s "$API/api/status" | jq
```
### 或使用内置 CLI
同样的操作也有命令形式(`codeman <cmd>`,括号内为别名)—— 在会话内的 shell 工具里很顺手:
```bash
codeman session start -d /path/to/repo # (s) 启动会话
codeman session list # 列出会话
codeman session logs <id> # 查看输出
codeman task add "fix the failing test" # (t) 排入任务
codeman ralph start --min-hours 8 # (r) 启动自主循环
codeman attach <path> # 附着 Claude hook 上下文
```
### Hook(事件*回流*到 Codeman)
Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission_prompt`、`idle_prompt`、`stop`、`task_completed` 等),让仪表盘实时响应。该端点在环回上免认证,但在受管隧道下需要 `X-Codeman-Hook-Secret` 头(从 `$CODEMAN_HOOK_SECRET_FILE` 读取)。通常你不需要手动调用它 —— Codeman 会自动接好 —— 但自主层正是靠它「看见」智能体在做什么。
> 完整端点列表与请求/响应形状见下文。
---
## API
基于 Fastify 的 REST —— **15 个路由模块中约 140 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。以下是一个有代表性的子集:
基于 Fastify 的 REST —— **20 个路由模块中约 190 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。所有响应都使用 `ApiResponse<T>` 信封(`{success, data}` / `{success, error, errorCode}`);`/api/v1/*` 是稳定别名。以下是一个有代表性的子集:
### 会话(Sessions)
| 方法 | 端点 | 说明 |
|--------|----------|-------------|
| `GET` | `/api/sessions` | 列出全部 |
| `POST` | `/api/quick-start` | 创建 case 并启动会话 |
| `DELETE` | `/api/sessions/:id` | 删除会话 |
| `POST` | `/api/sessions/:id/input` | 发送输入 |
| 方法 | 端点 | 说明 |
| -------- | -------------------------- | ------------------------------------------------------------------------------ |
| `GET` | `/api/sessions` | 列出全部 |
| `POST` | `/api/quick-start` | 创建 case + 启动会话(`{caseName?, mode?, effort?, envOverrides?}`) |
| `POST` | `/api/sessions/:id/input` | 发送输入(`{input, useMux?, clientId?, seq?}` —— `clientId`+`seq` = 精确一次) |
| `GET` | `/api/sessions/:id/output` | 读取终端输出 |
| `GET` | `/api/sessions/unified` | 统一的活动 + 历史清单(会话管理器):`?q=&limit=` |
| `POST` | `/api/sessions/:id/pin` | 在会话管理器中置顶 / 取消置顶(`{pinned}`) |
| `PUT` | `/api/session-order` | 跨设备同步标签顺序(`{order: [ids]}`) |
| `DELETE` | `/api/sessions/:id` | 删除会话 |
### 重生(Respawn)
| 方法 | 端点 | 说明 |
|--------|----------|-------------|
| 方法 | 端点 | 说明 |
| ------ | ---------------------------------- | -------------------- |
| `POST` | `/api/sessions/:id/respawn/enable` | 启用,带配置与定时器 |
| `POST` | `/api/sessions/:id/respawn/stop` | 停止控制器 |
| `PUT` | `/api/sessions/:id/respawn/config` | 更新配置 |
| `POST` | `/api/sessions/:id/respawn/stop` | 停止控制器 |
| `PUT` | `/api/sessions/:id/respawn/config` | 更新配置 |
### Ralph / Todo
| 方法 | 端点 | 说明 |
|--------|----------|-------------|
| `GET` | `/api/sessions/:id/ralph-state` | 获取循环状态 + todos |
| `POST` | `/api/sessions/:id/ralph-config` | 配置跟踪 |
| 方法 | 端点 | 说明 |
| ------ | -------------------------------- | -------------------- |
| `GET` | `/api/sessions/:id/ralph-state` | 获取循环状态 + todos |
| `POST` | `/api/sessions/:id/ralph-config` | 配置跟踪 |
### 编排器(Orchestrator)
| 方法 | 端点 | 说明 |
|--------|----------|-------------|
| `POST` | `/api/orchestrator/start` | 从目标启动编排 |
| `POST` | `/api/orchestrator/approve` | 批准生成的计划 |
| `GET` | `/api/orchestrator/status` | 当前阶段 + 进度 |
| `POST` | `/api/orchestrator/stop` | 停止并清理 |
| 方法 | 端点 | 说明 |
| ------ | --------------------------- | --------------- |
| `POST` | `/api/orchestrator/start` | 从目标启动编排 |
| `POST` | `/api/orchestrator/approve` | 批准生成的计划 |
| `GET` | `/api/orchestrator/status` | 当前阶段 + 进度 |
| `POST` | `/api/orchestrator/stop` | 停止并清理 |
### Cron(定时任务)
| 方法 | 端点 | 说明 |
| ---------------- | ---------------------------- | --------------------- |
| `GET` / `POST` | `/api/cron/jobs` | 列出 / 创建 cron 任务 |
| `PUT` / `DELETE` | `/api/cron/jobs/:id` | 更新 / 删除任务 |
| `PUT` | `/api/cron/jobs/:id/enabled` | 启用 / 禁用 |
| `POST` | `/api/cron/jobs/:id/run` | 立即运行 |
| `GET` | `/api/cron/jobs/:id/runs` | 运行历史 |
### 子智能体(Subagents)
| 方法 | 端点 | 说明 |
|--------|----------|-------------|
| `GET` | `/api/subagents` | 列出所有后台智能体 |
| `GET` | `/api/subagents/:id` | 智能体信息与状态 |
| `GET` | `/api/subagents/:id/transcript` | 完整活动记录 |
| `DELETE` | `/api/subagents/:id` | 杀掉智能体进程 |
| 方法 | 端点 | 说明 |
| -------- | ------------------------------- | ------------------ |
| `GET` | `/api/subagents` | 列出所有后台智能体 |
| `GET` | `/api/subagents/:id` | 智能体信息与状态 |
| `GET` | `/api/subagents/:id/transcript` | 完整活动记录 |
| `DELETE` | `/api/subagents/:id` | 杀掉智能体进程 |
### 系统(System)
| 方法 | 端点 | 说明 |
|--------|----------|-------------|
| `GET` | `/api/events` | SSE 流 |
| `GET` | `/api/status` | 完整应用状态 |
| `POST` | `/api/hook-event` | Hook 回调 |
| `GET` | `/api/system/update/check` | 检查新发行版 |
| `POST` | `/api/system/update` | 自更新(git-clone 安装) |
| `POST` | `/api/clipboard` | 把文本推送到所有已连接浏览器(`{text}`) |
| `GET` | `/api/sessions/:id/run-summary` | 时间线 + 统计 |
| 方法 | 端点 | 说明 |
| ------ | ------------------------------- | ---------------------------------------- |
| `GET` | `/api/events` | SSE 流 |
| `GET` | `/api/status` | 完整应用状态 |
| `POST` | `/api/hook-event` | Hook 回调 |
| `GET` | `/api/system/update/check` | 检查新发行版 |
| `POST` | `/api/system/update` | 自更新(git-clone 安装) |
| `POST` | `/api/clipboard` | 把文本推送到所有已连接浏览器(`{text}`) |
| `GET` | `/api/sessions/:id/run-summary` | 时间线 + 统计 |
---
@@ -582,7 +837,7 @@ flowchart TB
end
subgraph External["外部"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex</small>"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Gemini</small>"]
BG["后台智能体<br/><small>(Task 工具)</small>"]
end
end
@@ -614,7 +869,7 @@ flowchart TB
npm install
npx tsx src/index.ts web # 开发模式
npm run build # 生产构建
npm test # 运行测试
npm run test:ci # 运行测试(CI 套件;浏览器套件需要额外环境)
```
完整文档见 [CLAUDE.md](./CLAUDE.md)。
@@ -625,14 +880,14 @@ npm test # 运行测试
本代码库经历了一次全面的 7 阶段重构,消除了上帝对象、集中了配置,并建立了模块化架构:
| 阶段 | 改了什么 | 影响 |
|-------|-------------|--------|
| **性能** | 缓存端点、SSE 自适应批处理、缓冲区分块 | 终端延迟低于 16ms |
| **路由抽取** | `server.ts` 拆分为 15 个领域路由模块 + 认证中间件 + 端口接口 | server.ts 代码量 **−67%**(6,736 → 2,254) |
| **领域拆分** | `types.ts` → 16 个领域文件、`ralph-tracker` → 7 个文件、`respawn-controller` → 5 个文件、`session` → 6 个文件 | 不再有上帝文件 |
| **前端模块** | `app.js` → 18 个抽取模块,横跨基础设施、领域与特性层 | app.js 核心降至 **约 3.4K 行** |
| **配置合并** | 约 70 个散落的魔法数字 → 10 个领域聚焦的配置文件 | 零跨文件重复 |
| **测试基础设施** | 共享 mock 库、12 个路由测试文件、统一的 MockSession | 路由处理器可通过 `app.inject()` 测试 |
| 阶段 | 改了什么 | 影响 |
| ---------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| **性能** | 缓存端点、SSE 自适应批处理、缓冲区分块 | 终端延迟低于 16ms |
| **路由抽取** | `server.ts` 拆分为 15 个领域路由模块 + 认证中间件 + 端口接口 | server.ts 代码量 **−67%**(6,736 → 2,254) |
| **领域拆分** | `types.ts` → 16 个领域文件、`ralph-tracker` → 7 个文件、`respawn-controller` → 5 个文件、`session` → 6 个文件 | 不再有上帝文件 |
| **前端模块** | `app.js` → 18 个抽取模块,横跨基础设施、领域与特性层 | app.js 核心降至 **约 3.4K 行** |
| **配置合并** | 约 70 个散落的魔法数字 → 10 个领域聚焦的配置文件 | 零跨文件重复 |
| **测试基础设施** | 共享 mock 库、12 个路由测试文件、统一的 MockSession | 路由处理器可通过 `app.inject()` 测试 |
完整细节:[`docs/archive/code-structure-findings.md`](docs/archive/code-structure-findings.md)
@@ -654,6 +909,10 @@ npm install xterm-zerolag-input
---
## 版本策略
Codeman 遵循 [SemVer](https://semver.org/)。版本号真正承诺的内容,以及哪些算内部实现(HTTP/SSE API、磁盘上的状态、实验性特性),都写在 [`docs/versioning-policy.md`](docs/versioning-policy.md) 中。如果你的脚本依赖 HTTP API,请锁定到确切版本。
## 许可证
MIT —— 见 [LICENSE](LICENSE)
+2 -1
View File
@@ -52,8 +52,9 @@ curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"cl
## Lifecycle
- **Reconnect after a Codeman restart** lands back in the same live agent (the in-container tmux survives).
- **Container stop / host reboot** recreates the container and, when a resume id was captured, **resumes** the last conversation from the bind-mounted transcript.
- **Container stop / host reboot** restarts the container and **resumes** the last conversation from the bind-mounted transcript. Claude sessions launch with a pinned conversation id (`--session-id <sessionId>`, with a `--resume` fallback when the transcript already exists), and the case remembers its last conversation (`lastClaudeSessionId`), so a relaunch after the container was stopped, rebooted, or recreated continues where it left off.
- **Killing one session** only kills that session's in-container tmux session; the shared container stays up for sibling sessions.
- **Editing the docker host config** (image, memory, network, ...) is detected on the next launch: the desired config hash is compared against the container's `codeman.confighash` label, and a mismatch refuses the launch with a "config changed, recreate?" confirm. Confirming calls `POST /api/docker-cases/:name/recreate` (refused while sessions of the case are live), which removes the container so the next launch recreates it with the new config; the workspace and the conversation survive.
- **Deleting the case** `docker rm -f`s the container (the bind-mounted workspace on the host survives). An instance-scoped boot reaper removes containers whose case is gone.
## Isolation & security
Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 941 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+246
View File
@@ -0,0 +1,246 @@
# Remote Sessions (SSH)
Codeman can run a session's agent on a **remote host over SSH** instead of the
local machine. The agent (Claude, OpenCode, Codex, Gemini, or a plain shell)
runs inside a `tmux` server **on the remote host**, so it survives the SSH
connection dropping; Codeman attaches to it the same way it attaches to a local
managed session.
This document covers the data model, the shell-safe SSH command construction
(COD-107), the durable-launch design (COD-104), and the operational caveats.
For the local session/mux machinery this builds on, see the **Mux** and
**Session** entries in `CLAUDE.md` → Architecture.
## Why it exists
A developer box (`AA-DESKTOP`) often needs to drive an agent on another machine —
a NAS, a build server, a host reachable only through a jump box or a
cloudflared SOCKS5 proxy. Rather than wrap `ssh` by hand per host, Codeman
stores reusable **remote hosts** + **remote cases** and reproduces the exact
connection the operator already uses (`ssh-aa-desktop`-style configs:
custom port, identity file, `-J` jump host, `-o ProxyCommand`).
## Data model
Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
| Type | Role |
|------|------|
| `RemoteSshOptions` | The **HOW-to-reach** fields, shared by host + session: `identityFile`, `socksProxy` (`host:port`), `jumpHost` (`[user@]host[:port]`), `extraSshOptions` (`KEY=VALUE[]`). Every field optional — all-absent reproduces port-22, default-identity, directly-SSH-able behavior. |
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
| `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini'>` — the modes that can run remotely. |
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
Persistence is two flat JSON arrays in the instance data dir:
- `~/.codeman/remote-hosts.json` — `readRemoteHosts()` / `writeRemoteHosts()`
- `~/.codeman/remote-cases.json` — `readRemoteCases()` / `writeRemoteCases()`
(Paths via `remoteHostsPath()` / `remoteCasesPath()`; both honor `CODEMAN_INSTANCE`
because the config dir is the instance data dir.)
On the live `Session`, the remote rides as `_remote?: SessionRemote`. When
attaching, `resolveMuxAttachCwd()` forces the cwd to `/tmp` for remote sessions —
the local working directory is meaningless on the remote box.
## SSH command construction (COD-107 — the injection surface)
**All** SSH command lines flow through one function so user-controlled fields are
escaped once and the launch + prereq probe can never drift apart:
```ts
// src/remote-hosts.ts
buildSshConnectionArgs(remote: RemoteSshOptions & Pick<RemoteHost, 'port'>): string[]
```
It returns the **ordered leading tokens** of an ssh command line (no `-t`, no
target, no remote command):
```
ssh -o BatchMode=yes
[-p <port>]
[-i <abs-identity>] # ~ / $HOME expanded, then shellescaped
[-J <jumpHost>] # shellescaped, single token
[-o ProxyCommand=nc -X 5 -x <socks> %h %p] # ONE shellescaped -o token
[-o <KEY=VALUE>] … # each extra option, shellescaped
```
Rules that keep this safe — **do not bypass them by hand-building an ssh line elsewhere:**
- **Every** user-controlled value (`-i`, `-J`, `-o`, ProxyCommand) is POSIX
single-quote `shellescape`d (`'…'` with embedded `'\''`). The helper mirrors
the one in `tmux-manager.ts`.
- **`~`/`$HOME` in `identityFile` is expanded at build time** (`expandIdentityPath`),
*before* escaping — ssh does not expand `~` inside `-i`, and the escaped value
never reaches a shell that would.
- **The ProxyCommand is one shellescaped `-o KEY=VALUE` token**, so its spaces and
the `%h`/`%p` placeholders reach ssh as a single argument. `%h %p` survive
verbatim — **ssh** expands them to the real host/port, not the shell.
- **Empty options ⇒ `['ssh', '-o BatchMode=yes']`** (+ `-p` only when set) —
byte-identical to the historical behavior.
Token construction is unit-tested independently of any live connection (see
`test/` for `buildSshConnectionArgs` / `buildRemoteTmuxCheckCommand` cases).
## Durable launch (COD-104)
`buildRemoteLaunchCommand({ mode, remote, sessionId })` in `tmux-manager.ts`
builds the command that launches (or **reattaches** to) the remote session:
```
ssh -o BatchMode=yes -t <connection-args> user@host \
'tmux -L codeman-remote new-session -A -s codeman-ssh-<id8> -c <remotePath> "cd <remotePath> && exec <cli>" \; \
set -t codeman-ssh-<id8> status off \; set -t codeman-ssh-<id8> mouse off \; \
set -t codeman-ssh-<id8> prefix C-q \; set -s escape-time 0 \; \
set -t codeman-ssh-<id8> window-size latest'
```
Key points:
- **`new-session -A -s codeman-ssh-<id8>`** = attach-if-exists-else-create, so a
reconnect (same deterministic `remoteTmuxSessionName(sessionId)` — `codeman-ssh-` +
the first 8 chars of the session id) lands back in
the **same** remote session rather than spawning a duplicate. This is what makes
the remote agent survive an SSH drop. The name deliberately fails
`SAFE_MUX_NAME_PATTERN` so a Codeman running ON the remote host never adopts it.
- **`-L codeman-remote`** = a DEDICATED socket for sessions launched by remote
Codemans, NOT the canonical `-L codeman` socket the remote host's own Codeman
uses. Options are set per-session (`set -t`), never `-g`, so a shared remote
tmux server's other sessions are untouched (#145 hardening). Note the
asymmetry: **discovery/attach (COD-105) target the canonical `-L codeman`
socket** — they join sessions the remote's own Codeman manages, while owned
durable launches live on `-L codeman-remote`.
- **`exec <cli>`** replaces the pane shell with the agent, so the pane PID *is*
the agent. The per-mode command comes from `remote.commands?.[mode]` or
`defaultRemoteCommandForMode(mode)` (`exec claude` / `exec opencode` /
`exec codex` / `exec gemini` / `exec bash -l`).
- The **whole tmux invocation is a single shell-quoted ssh argument**, and the
pane command is independently quoted, so a `remotePath` with spaces is safe.
- Connection options come from the **same `buildSshConnectionArgs(remote)`** as
the prereq probe; `-t` is inserted right after `ssh -o BatchMode=yes`,
preserving historical token order.
### tmux prerequisite probe
Because durable remote sessions require tmux on the remote host,
`checkRemoteTmuxAvailable(host)` runs `command -v tmux` over SSH **before**
creating a remote case/session and returns a structured, never-throwing result:
- empty stdout / non-zero exit → *"remote host `<host>` needs tmux installed for
durable remote sessions"*
- stderr present → *"could not verify tmux on remote host `<host>`: `<stderr>`"*
(a real connection failure, surfaced to the operator)
- success → `{ ok: true, tmuxPath }`
It connects with the **identical** options as the launch
(`buildRemoteTmuxCheckCommand` reuses `buildSshConnectionArgs` and inserts
`-o ConnectTimeout=10`), so a proxied/custom-port/identity host that the launch
can reach also passes the probe (and vice-versa).
**Test-mode short-circuit:** under `VITEST` the probe returns
`{ ok: true, tmuxPath: '(test-mode)' }` without opening a socket — mirroring
`TmuxManager`'s no-op-shell-under-VITEST (`IS_TEST_MODE`). Without it, remote-case
create-path tests would hit a real ~10s ssh timeout. Only the live probe is
skipped; command construction is still asserted by unit tests.
## Ownership: launched vs. discovered-and-attached (COD-105)
COD-104 (above) was Phase 1 — Codeman *launches* a remote session and owns it.
COD-105 is Phase 2 — Codeman can also **discover** `codeman-*` tmux sessions
already running on a remote host (created by the remote's own Codeman or another
instance) and **attach** to one it didn't launch. Ownership decides what happens
when the tab closes.
`SessionRemote.owned` carries this:
- **`owned: true`** (or absent — legacy/COD-104 sessions persisted before this
field) — we launched it via `buildRemoteLaunchCommand` and may explicitly kill it.
- **`owned: false`** — discovered + attached; another Codeman owns the remote
session. `remoteSessionName` holds its existing tmux name. Closing the tab
**detaches**, never kills.
### Discovery
`listRemoteCodemanSessions(host)` lists the remote's `codeman-*` sessions:
- `buildRemoteListSessionsCommand()` runs `tmux -L codeman list-sessions -F "…"`
over SSH (connection args from the shared `buildSshConnectionArgs`, so discovery
connects identically to launch/probe). `2>/dev/null` swallows tmux's "no server
running" stderr.
- `parseRemoteSessionList()` is a **pure, unit-tested** parser. ⚠️ Quirk: the
remote tmux's `-F "…\t…"` format emits the **literal two-character `\t`**, not a
real tab (verified on tmux next-3.7), so the parser splits on `/\\t|\t/` (literal
backslash-t **or** a real tab, for builds that do expand it). It keeps only
`codeman-*` names, coerces types, and skips malformed lines.
- `listRemoteCodemanSessions()` **never throws** — unreachable host / no tmux / no
sessions all map to `[]`. Like the prereq probe, it **no-ops to `[]` under
`VITEST`** so a request path never opens a real ssh connection.
Discovery is **explicit** — the UI has a "Discover existing sessions" button per
host; Codeman never auto-discovers on host select.
### Attach vs. launch selection
`buildRemoteSessionCommand(mode, remote, sessionId)` in `tmux-manager.ts` picks the
remote command line by ownership:
- **`owned === false`** → `buildRemoteAttachCommand(remote, name)` — emits
`ssh … -t … 'tmux -L codeman attach -t <remoteSessionName>'`. It uses **`attach`,
NOT `new-session -A`**, so it only *joins* an existing session and never creates
one.
- **owned (default)** → `buildRemoteLaunchCommand` (the COD-104 path above).
### Detach-not-kill
`TmuxManager.killSession()` has an **early return for non-owned remote sessions**:
it tears down **only the LOCAL pane** holding the ssh client (`tmux -L codeman
kill-session` on *this* host's socket). Killing the local ssh sends SIGHUP to the
remote `tmux attach`, which **detaches** — the durable remote session survives.
The early return is a structural guarantee that **no code path can ever issue a
remote `kill-session` for a session we don't own** — the only `kill-session` run is
on the local socket, which never reaches the remote socket.
## API
Routes are registered in `src/web/routes/case-routes.ts`:
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/remote-hosts` | List saved hosts |
| `POST` | `/api/remote-hosts` | Create a host |
| `PUT` | `/api/remote-hosts/:id` | Update a host |
| `DELETE` | `/api/remote-hosts/:id` | Delete a host |
| `GET` | `/api/remote-hosts/:hostId/sessions` | Discover `codeman-*` sessions on the host (COD-105; `listRemoteCodemanSessions`, never errors) |
| `POST` | `/api/cases/remote-link` | Link a case to a remote host (creates the `RemoteCase`) |
Attaching to a discovered session is a **session-create** path, not a host route:
`POST /api/sessions` accepts `attachRemoteSession: { hostId, remoteSessionName }`
(schema in `schemas.ts`; `remoteSessionName` must match `^codeman-[a-zA-Z0-9._-]+$`),
which `session-routes.ts` turns into a non-owned (`owned: false`) session.
Frontend touchpoints: the remote-host management UI is in `session-ui.js` /
`panels-ui.js`; a remote session is created by picking a remote host/case in the
session-create flow, or via the per-host **"Discover existing sessions"** button →
**Attach** action (creates an `owned: false` session).
## Security notes
- **`identityFile` is a path only — never key bytes.** Codeman stores the path and
passes it to `ssh -i`; the key never enters Codeman's state or the wire.
- The injection surface is the SSH option fields. The single-source
`buildSshConnectionArgs` + `shellescape` discipline (COD-107) is the control —
audit any new code path that constructs an ssh command to route through it
rather than concatenating options inline.
- `BatchMode=yes` means **no interactive password/passphrase prompts** — remote
hosts must be reachable with key-based or agent auth (or an unencrypted key the
agent has loaded). A host needing a passphrase will fail the probe with an ssh
diagnostic rather than hang.
## Related
- `CLAUDE.md` → Architecture → **Remote** row, and the **Remote sessions (SSH)**
Key Pattern.
- `docs/security-architecture.md` — overall network/auth model.
- COD-104 (tmux prereq + durable launch), COD-105 (discover + attach, detach-not-kill ownership), COD-107 (shell-safe connection args).
+479 -55
View File
@@ -5,12 +5,22 @@
# Usage: curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
#
# Environment variables:
# CODEMAN_NONINTERACTIVE=1 - Skip all prompts (for CI/automation)
# CODEMAN_NONINTERACTIVE=1 - Skip all prompts and accept their defaults
# (CI/automation). Required for headless runs
# that need system changes (sudo package
# installs, AI CLI download); without it those
# steps abort instead of running silently.
# CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app)
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd/launchd service setup prompt
# CODEMAN_NODE_VERSION - Node.js major version to install (default: 22)
# CODEMAN_REPO_URL - Custom git repository URL (default: upstream Codeman)
# CODEMAN_BRANCH - Git branch to install (default: master)
# CODEMAN_HOST - Preset the network binding and skip the prompt
# (e.g. 0.0.0.0 for LAN access, 127.0.0.1 for
# local-only; interactive default is 0.0.0.0,
# non-interactive default is 127.0.0.1)
# CODEMAN_PASSWORD - Preset the dashboard password (skips the
# password prompt when binding to the network)
set -euo pipefail
@@ -26,6 +36,13 @@ TARGET_NODE_VERSION="${CODEMAN_NODE_VERSION:-22}"
NONINTERACTIVE="${CODEMAN_NONINTERACTIVE:-0}"
SKIP_SYSTEMD="${CODEMAN_SKIP_SYSTEMD:-0}"
# Network binding chosen during install (choose_network_binding). Empty
# BIND_HOST means "not chosen" (e.g. the update path) and falls back to the
# server's own loopback default.
BIND_HOST=""
BIND_PASSWORD=""
BIND_ACK="0"
# puppeteer is a devDependency used only by scripts/browser-comparison.mjs — its
# ~150MB chrome-headless-shell download is never needed to build or run Codeman.
# Skipping it avoids a slow download and a fatal install failure when a prior
@@ -53,6 +70,26 @@ OPENCODE_SEARCH_PATHS=(
"$HOME/bin/opencode"
)
# Codex CLI search paths (from src/utils/codex-cli-resolver.ts)
CODEX_SEARCH_PATHS=(
"$HOME/.codex/bin/codex"
"$HOME/.local/bin/codex"
"/usr/local/bin/codex"
"$HOME/.bun/bin/codex"
"$HOME/.npm-global/bin/codex"
"$HOME/bin/codex"
)
# Gemini CLI search paths (from src/utils/gemini-cli-resolver.ts)
GEMINI_SEARCH_PATHS=(
"$HOME/.gemini/bin/gemini"
"$HOME/.local/bin/gemini"
"/usr/local/bin/gemini"
"$HOME/.bun/bin/gemini"
"$HOME/.npm-global/bin/gemini"
"$HOME/bin/gemini"
)
# ============================================================================
# Color Output
# ============================================================================
@@ -107,16 +144,39 @@ die() {
}
# Security notice — printed at the very end of install/update so it is the last
# thing the user sees (the default loopback bind + how to expose it safely).
# thing the user sees. Adapts to the binding chosen during install; the update
# path (BIND_HOST empty) gets the generic text.
print_security_notice() {
echo ""
echo -e " ${YELLOW}${BOLD}Security:${NC}"
echo -e " Codeman binds ${BOLD}127.0.0.1${NC} (this machine only) — no password needed by default."
echo -e " To reach it from another device, do ONE of:"
echo -e " ${CYAN}•${NC} tailscale serve / cloudflared tunnel ${DIM}(recommended)${NC}, or"
echo -e " ${CYAN}•${NC} ${CYAN}codeman web --host 0.0.0.0${NC} AND set ${CYAN}CODEMAN_PASSWORD${NC}"
echo -e " A non-loopback bind without a password still starts, but warns loudly."
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
if [[ "$BIND_HOST" == "0.0.0.0" && -z "$BIND_PASSWORD" ]]; then
echo -e " ${RED}${BOLD}============================================================${NC}"
echo -e " ${RED}${BOLD} WARNING: NETWORK ACCESS WITHOUT A PASSWORD${NC}"
echo -e " ${RED}${BOLD}============================================================${NC}"
echo -e " ${RED}The dashboard is reachable by EVERY device on your network,${NC}"
echo -e " ${RED}and whoever opens it can run commands as ${BOLD}$USER${NC}${RED} through${NC}"
echo -e " ${RED}your AI agents. Anyone on your Wi-Fi owns this machine.${NC}"
echo ""
echo -e " Fix it by setting a password (takes 30 seconds):"
echo -e " ${CYAN}•${NC} re-run the installer and choose a password, or"
echo -e " ${CYAN}•${NC} add ${CYAN}Environment=CODEMAN_PASSWORD=<yours>${NC} to the service"
echo -e " Or switch back to local-only: ${CYAN}CODEMAN_HOST=127.0.0.1${NC}"
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
elif [[ "$BIND_HOST" == "0.0.0.0" ]]; then
echo -e " ${YELLOW}${BOLD}Security:${NC}"
echo -e " The dashboard is reachable from your network at port 3000 and is"
echo -e " password-protected (user ${BOLD}admin${NC}). Keep that password strong:"
echo -e " whoever logs in can run commands through your agents."
echo -e " For access from OUTSIDE your network, prefer Tailscale or a tunnel."
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
else
echo -e " ${YELLOW}${BOLD}Security:${NC}"
echo -e " Codeman binds ${BOLD}127.0.0.1${NC} (this machine only) — no password needed by default."
echo -e " To reach it from another device, do ONE of:"
echo -e " ${CYAN}•${NC} tailscale serve / cloudflared tunnel ${DIM}(recommended)${NC}, or"
echo -e " ${CYAN}•${NC} ${CYAN}codeman web --host 0.0.0.0${NC} AND set ${CYAN}CODEMAN_PASSWORD${NC}"
echo -e " A non-loopback bind without a password still starts, but warns loudly."
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
fi
echo ""
}
@@ -336,6 +396,62 @@ get_opencode_path() {
done
}
check_codex() {
if command -v codex &>/dev/null; then
return 0
fi
for path in "${CODEX_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
return 0
fi
done
return 1
}
get_codex_path() {
if command -v codex &>/dev/null; then
command -v codex
return
fi
for path in "${CODEX_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
echo "$path"
return
fi
done
}
check_gemini() {
if command -v gemini &>/dev/null; then
return 0
fi
for path in "${GEMINI_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
return 0
fi
done
return 1
}
get_gemini_path() {
if command -v gemini &>/dev/null; then
command -v gemini
return
fi
for path in "${GEMINI_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
echo "$path"
return
fi
done
}
check_cloudflared() {
# Check ~/.local/bin first (matches tunnel-manager.ts resolution order)
if [[ -x "$HOME/.local/bin/cloudflared" ]]; then
@@ -683,17 +799,54 @@ install_cloudflared_suse() {
# Interactive Prompts
# ============================================================================
# `curl | bash` leaves stdin attached to the pipe, so a plain `read` never sees
# the keyboard even though the user is sitting at a terminal. These helpers
# prompt via /dev/tty whenever a real terminal is available, and only fall back
# to defaults when there is genuinely none (CI, truly headless pipes).
has_tty() {
[[ -t 0 ]] && return 0
{ : < /dev/tty; } 2>/dev/null
}
read_reply() {
# read_reply <varname>: read one line from the user's real terminal
if [[ -t 0 ]]; then
read -r "$1"
else
read -r "$1" < /dev/tty
fi
}
read_secret() {
# read_secret <varname>: like read_reply but without echoing (passwords)
if [[ -t 0 ]]; then
read -rs "$1"
else
read -rs "$1" < /dev/tty
fi
echo "" >&2
}
# headless_guard <action>: refuse consequential system changes (sudo package
# installs, third-party curl | bash installers) when nobody can consent, i.e.
# no terminal AND no explicit CODEMAN_NONINTERACTIVE=1 opt-in. Interactive
# runs fall through to their normal prompt; opted-in automation proceeds with
# the prompt defaults as before.
headless_guard() {
local action="$1"
if [[ "$NONINTERACTIVE" == "1" ]] || has_tty; then
return 0
fi
error "No interactive terminal, but the installer would need to: $action."
error "Re-run from a terminal to be prompted, or set CODEMAN_NONINTERACTIVE=1 to approve such steps in automation."
exit 1
}
prompt_yes_no() {
local prompt="$1"
local default="${2:-y}"
if [[ "$NONINTERACTIVE" == "1" ]]; then
[[ "$default" == "y" ]]
return
fi
# Check if stdin is a terminal
if [[ ! -t 0 ]]; then
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
# Non-interactive, use default
[[ "$default" == "y" ]]
return
@@ -708,7 +861,7 @@ prompt_yes_no() {
while true; do
echo -en "${CYAN}$prompt${NC} $yn_hint " >&2
read -r answer
read_reply answer || answer="$default"
answer="${answer:-$default}"
case "$answer" in
[Yy]|[Yy][Ee][Ss]) return 0 ;;
@@ -819,10 +972,135 @@ setup_sc_alias() {
info "Added 'sc' alias for tmux-chooser"
}
# ============================================================================
# Network Binding
# ============================================================================
# Best-effort LAN IP for "open this URL from your phone" hints.
detect_lan_ip() {
local ip=""
if [[ "$(uname -s)" == "Darwin" ]]; then
ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true)
else
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
fi
echo "${ip:-<your-ip>}"
}
# Escape a value for a quoted systemd Environment="KEY=value" assignment.
systemd_env_escape() {
printf '%s' "$1" | sed 's/[\\"]/\\&/g'
}
# Escape a value for embedding in a launchd plist <string>.
xml_escape() {
printf '%s' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g'
}
# Ask how the dashboard should be reachable and set BIND_HOST/BIND_PASSWORD/
# BIND_ACK. Interactive default is network access (0.0.0.0) because that is
# what most installs need; loopback is offered as the safer alternative.
# Non-interactive runs keep the safe loopback default unless CODEMAN_HOST is
# preset. The server binary itself still defaults to 127.0.0.1 either way.
choose_network_binding() {
# Preset via environment: honor it and skip the prompt entirely.
if [[ -n "${CODEMAN_HOST:-}" ]]; then
BIND_HOST="$CODEMAN_HOST"
BIND_PASSWORD="${CODEMAN_PASSWORD:-}"
if [[ "$BIND_HOST" != "127.0.0.1" && -z "$BIND_PASSWORD" ]]; then
BIND_ACK="1"
fi
info "Network binding preset via CODEMAN_HOST: $BIND_HOST"
return 0
fi
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
BIND_HOST="127.0.0.1"
info "Non-interactive install: binding 127.0.0.1 (preset CODEMAN_HOST=0.0.0.0 to override)"
return 0
fi
echo -e " ${BOLD}Network access${NC}"
echo ""
echo -e " How should the Codeman dashboard be reachable?"
echo ""
echo -e " ${CYAN}1)${NC} ${BOLD}Any device on your network${NC} ${DIM}(0.0.0.0)${NC} [default]"
echo -e " Open it straight from your phone or laptop."
echo -e " ${YELLOW}Less safe: set a password so only you control your agents.${NC}"
echo -e " ${CYAN}2)${NC} ${BOLD}This machine only${NC} ${DIM}(127.0.0.1)${NC}"
echo -e " Safest. Reach it remotely via Tailscale or a tunnel."
echo ""
local bind_choice=""
while true; do
echo -en "${CYAN}Choose [1/2] (default 1):${NC} " >&2
read_reply bind_choice || bind_choice="1"
bind_choice="${bind_choice:-1}"
case "$bind_choice" in
1|2) break ;;
*) echo "Please enter 1 or 2." >&2 ;;
esac
done
if [[ "$bind_choice" == "2" ]]; then
BIND_HOST="127.0.0.1"
success "Binding 127.0.0.1 (this machine only)"
return 0
fi
BIND_HOST="0.0.0.0"
if [[ -n "${CODEMAN_PASSWORD:-}" ]]; then
BIND_PASSWORD="$CODEMAN_PASSWORD"
info "Using CODEMAN_PASSWORD from the environment"
return 0
fi
echo ""
local pw="" pw2=""
while true; do
echo -en "${CYAN}Set a dashboard password (recommended; Enter to skip):${NC} " >&2
read_secret pw || pw=""
if [[ -z "$pw" ]]; then
echo ""
warn "Without a password, EVERY device on your network gets full access"
warn "to your agents (they run commands as $USER)."
if prompt_yes_no "Continue WITHOUT a password?" "n"; then
BIND_ACK="1"
break
fi
continue
fi
echo -en "${CYAN}Confirm password:${NC} " >&2
read_secret pw2 || pw2=""
if [[ "$pw" == "$pw2" ]]; then
BIND_PASSWORD="$pw"
success "Password set (login user: admin)"
break
fi
echo "Passwords do not match, try again." >&2
done
return 0
}
# ============================================================================
# Service Setup (Linux systemd / macOS launchd)
# ============================================================================
# Wait briefly for codeman-web.service to report active. A bad node path or a
# busy port makes the unit crash within the first seconds (then sit in
# activating/auto-restart), so a blind "started!" message would be a lie.
verify_systemd_active() {
local attempt
for attempt in 1 2 3; do
sleep 2
if systemctl --user is-active --quiet codeman-web.service 2>/dev/null; then
return 0
fi
done
return 1
}
setup_launchd_service() {
local plist_label="com.codeman.web"
local agent_dir="$HOME/Library/LaunchAgents"
@@ -855,6 +1133,21 @@ setup_launchd_service() {
local node_path
node_path=$(command -v node)
# Binding chosen during install (empty on paths that never asked)
local bind_plist=""
if [[ -n "$BIND_HOST" ]]; then
bind_plist=" <key>CODEMAN_HOST</key>
<string>$BIND_HOST</string>"
if [[ -n "$BIND_PASSWORD" ]]; then
bind_plist+=$'\n'" <key>CODEMAN_PASSWORD</key>
<string>$(xml_escape "$BIND_PASSWORD")</string>"
fi
if [[ "$BIND_ACK" == "1" ]]; then
bind_plist+=$'\n'" <key>CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK</key>
<string>1</string>"
fi
fi
cat > "$agent_plist" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -876,6 +1169,7 @@ setup_launchd_service() {
<string>$HOME</string>
<key>LANG</key>
<string>en_US.UTF-8</string>
$bind_plist
</dict>
<key>WorkingDirectory</key>
<string>$HOME</string>
@@ -895,7 +1189,15 @@ EOF
launchctl load "$agent_plist" 2>/dev/null || true
success "LaunchAgent installed and started"
# launchctl load is silent about many failures: confirm the agent is loaded
sleep 2
if launchctl list "$plist_label" &>/dev/null; then
success "LaunchAgent installed and started"
return 0
fi
warn "LaunchAgent did not load."
warn "Inspect: launchctl list | grep codeman ; tail -20 /tmp/codeman.log"
return 1
}
setup_systemd_service() {
@@ -910,6 +1212,18 @@ setup_systemd_service() {
local node_path
node_path=$(command -v node)
# Binding chosen during install (empty on paths that never asked)
local bind_env=""
if [[ -n "$BIND_HOST" ]]; then
bind_env="Environment=CODEMAN_HOST=$BIND_HOST"
if [[ -n "$BIND_PASSWORD" ]]; then
bind_env+=$'\n'"Environment=\"CODEMAN_PASSWORD=$(systemd_env_escape "$BIND_PASSWORD")\""
fi
if [[ "$BIND_ACK" == "1" ]]; then
bind_env+=$'\n'"Environment=CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1"
fi
fi
# Create service file
cat > "$service_file" << EOF
[Unit]
@@ -924,13 +1238,21 @@ Restart=always
RestartSec=10
Environment=NODE_ENV=production
Environment=PATH=$PATH
$bind_env
[Install]
WantedBy=default.target
EOF
# Reload systemd
systemctl --user daemon-reload
# Reload systemd. A user D-Bus session is required for systemctl --user
# (missing under bare `ssh host 'curl | bash'` provisioning), so detect
# that up front instead of dying mid-setup with a cryptic trap message.
if ! systemctl --user daemon-reload 2>/dev/null; then
warn "systemctl --user is unavailable (no user D-Bus session?); cannot manage user services here."
warn "Unit written to $service_file. From a normal login shell, enable it with:"
warn " systemctl --user daemon-reload && systemctl --user enable --now codeman-web"
return 1
fi
# Enable service
systemctl --user enable codeman-web.service 2>/dev/null || true
@@ -940,10 +1262,17 @@ EOF
loginctl enable-linger "$USER" 2>/dev/null || true
fi
# Start the service immediately
systemctl --user start codeman-web.service 2>/dev/null || true
# (Re)start the service. restart, not start: on a re-run over an existing
# running service, start would be a no-op and leave the OLD build running.
systemctl --user restart codeman-web.service 2>/dev/null || true
success "Systemd service installed and started"
if verify_systemd_active; then
success "Systemd service installed and started"
return 0
fi
warn "codeman-web.service did not become active."
warn "Inspect: systemctl --user status codeman-web ; journalctl --user -u codeman-web -e"
return 1
}
setup_tunnel_service() {
@@ -1027,6 +1356,7 @@ main() {
# Git
info "Checking Git..."
if ! check_git; then
headless_guard "install Git (system package via sudo)"
if prompt_yes_no "Git is not installed. Install it now?"; then
install_dependency "git" "$os" "$distro"
else
@@ -1045,6 +1375,7 @@ main() {
warn "Node.js $node_version is installed but version $MIN_NODE_VERSION+ is required."
fi
headless_guard "install Node.js v$TARGET_NODE_VERSION (system package via sudo)"
if prompt_yes_no "Install Node.js v$TARGET_NODE_VERSION?"; then
install_dependency "node" "$os" "$distro"
@@ -1069,6 +1400,7 @@ main() {
if check_tmux; then
success "tmux is installed"
else
headless_guard "install tmux (system package via sudo)"
if prompt_yes_no "tmux is not installed. Install it now?"; then
install_dependency "tmux" "$os" "$distro"
else
@@ -1076,9 +1408,11 @@ main() {
fi
fi
# AI CLI (at least one required: Claude Code or OpenCode)
# AI CLI (Codeman drives one of: Claude Code, OpenCode, Codex, Gemini)
local has_claude=false
local has_opencode=false
local has_codex=false
local has_gemini=false
info "Checking AI CLI tools..."
if check_claude; then
@@ -1089,28 +1423,39 @@ main() {
has_opencode=true
success "OpenCode found at $(get_opencode_path)"
fi
if check_codex; then
has_codex=true
success "Codex found at $(get_codex_path)"
fi
if check_gemini; then
has_gemini=true
success "Gemini CLI found at $(get_gemini_path)"
fi
if [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then
if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" ]]; then
echo ""
warn "No AI CLI found. Codeman requires at least one: Claude Code or OpenCode."
warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, or Gemini."
headless_guard "install an AI CLI (curl | bash from its vendor)"
echo ""
echo -e " ${BOLD}Which AI CLI would you like to install?${NC}"
echo -e " ${CYAN}1)${NC} Claude Code (Anthropic)"
echo -e " ${CYAN}2)${NC} OpenCode (open-source)"
echo -e " ${CYAN}3)${NC} Both"
echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex or Gemini)"
echo ""
local cli_choice=""
if [[ "$NONINTERACTIVE" == "1" ]] || [[ ! -t 0 ]]; then
# Non-interactive: default to Claude Code
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
# Explicit automation opt-in: default to Claude Code
cli_choice="1"
info "CODEMAN_NONINTERACTIVE=1: defaulting to Claude Code"
else
while true; do
echo -en "${CYAN}Choose [1/2/3]:${NC} " >&2
read -r cli_choice
echo -en "${CYAN}Choose [1/2/3/4]:${NC} " >&2
read_reply cli_choice || { cli_choice="1"; break; }
case "$cli_choice" in
1|2|3) break ;;
*) echo "Please enter 1, 2, or 3." >&2 ;;
1|2|3|4) break ;;
*) echo "Please enter 1, 2, 3, or 4." >&2 ;;
esac
done
fi
@@ -1139,8 +1484,12 @@ main() {
fi
fi
if [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then
die "At least one AI CLI is required. Install manually and re-run the installer."
if [[ "$cli_choice" == "4" ]]; then
warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive."
info "Install one later, e.g.: npm install -g @openai/codex (Codex)"
info " or: npm install -g @google/gemini-cli (Gemini)"
elif [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then
die "The selected AI CLI failed to install. Install one manually and re-run the installer."
fi
fi
@@ -1236,6 +1585,16 @@ main() {
fi
fi
# ========================================================================
# Mark install complete
# ========================================================================
# The dispatcher at the bottom only routes a bare re-run to the quiet
# update path when this marker exists, so an aborted first install
# (failed npm install/build, Ctrl+C) re-runs the full setup flow
# (symlinks, PATH, launch menu) instead of silently "updating".
date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete"
# ========================================================================
# Launch Options
# ========================================================================
@@ -1246,6 +1605,11 @@ main() {
echo -e "${GREEN}${BOLD}============================================================${NC}"
echo ""
# Ask how the dashboard should be reachable BEFORE the launch menu, so the
# service files and the run-now path all inherit the choice.
choose_network_binding
echo ""
local launch_choice=""
local has_service=false
local service_type=""
@@ -1269,12 +1633,13 @@ main() {
echo -e " ${CYAN}3)${NC} Don't start — I'll run it later"
echo ""
if [[ "$NONINTERACTIVE" == "1" ]] || [[ ! -t 0 ]]; then
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
launch_choice="3"
info "No interactive terminal detected: not starting (run 'codeman web' when ready)"
else
while true; do
echo -en "${CYAN}Choose [1/2/3]:${NC} " >&2
read -r launch_choice
read_reply launch_choice || { launch_choice="3"; break; }
case "$launch_choice" in
1|2|3) break ;;
*) echo "Please enter 1, 2, or 3." >&2 ;;
@@ -1289,12 +1654,13 @@ main() {
echo -e " ${CYAN}2)${NC} Don't start — I'll run it later"
echo ""
if [[ "$NONINTERACTIVE" == "1" ]] || [[ ! -t 0 ]]; then
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
launch_choice="2"
info "No interactive terminal detected: not starting (run 'codeman web' when ready)"
else
while true; do
echo -en "${CYAN}Choose [1/2]:${NC} " >&2
read -r launch_choice
read_reply launch_choice || { launch_choice="2"; break; }
case "$launch_choice" in
1) break ;;
2) break ;;
@@ -1310,14 +1676,16 @@ main() {
# Handle service setup
if [[ "$launch_choice" == "2" ]]; then
local service_ok=true
if [[ "$service_type" == "launchd" ]]; then
setup_launchd_service
setup_launchd_service || service_ok=false
else
setup_systemd_service
setup_systemd_service || service_ok=false
fi
# Offer tunnel service if cloudflared is available (Linux only — systemd tunnel service)
if [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
# Offer tunnel service if cloudflared is available (Linux only: systemd tunnel service).
# Skipped when service setup failed: it needs the same systemctl --user access.
if [[ "$service_ok" == "true" ]] && [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
echo ""
if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then
setup_tunnel_service
@@ -1325,10 +1693,20 @@ main() {
fi
echo ""
echo -e " ${GREEN}${BOLD}Codeman is running now!${NC}"
echo ""
echo -e " ${CYAN}# Open in browser${NC}"
echo -e " http://localhost:3000"
if [[ "$service_ok" == "true" ]]; then
echo -e " ${GREEN}${BOLD}Codeman is running now!${NC}"
echo ""
echo -e " ${CYAN}# Open in browser${NC}"
if [[ "$BIND_HOST" == "0.0.0.0" ]]; then
echo -e " http://$(detect_lan_ip):3000 ${DIM}(any device on your network)${NC}"
echo -e " http://localhost:3000 ${DIM}(this machine)${NC}"
else
echo -e " http://localhost:3000"
fi
else
echo -e " ${YELLOW}${BOLD}The service was set up but is not running yet${NC} (see warnings above)."
echo -e " ${DIM}You can always run it directly:${NC} ${CYAN}codeman web${NC}"
fi
echo ""
echo -e " ${BOLD}Manage the service:${NC}"
echo ""
@@ -1349,11 +1727,23 @@ main() {
if [[ "$launch_choice" != "2" ]]; then
echo -e " ${BOLD}Quick Start:${NC}"
echo ""
echo -e " ${CYAN}codeman web${NC} # Start the web server"
echo -e " ${CYAN}codeman web --https${NC} # With HTTPS (for remote access)"
echo ""
echo -e " ${CYAN}# Open in browser${NC}"
echo -e " http://localhost:3000"
if [[ "$BIND_HOST" == "0.0.0.0" ]]; then
if [[ -n "$BIND_PASSWORD" ]]; then
echo -e " ${CYAN}CODEMAN_HOST=0.0.0.0 CODEMAN_PASSWORD='<your-password>' codeman web${NC}"
else
echo -e " ${CYAN}CODEMAN_HOST=0.0.0.0 codeman web${NC}"
fi
echo -e " ${DIM}(a bare 'codeman web' binds 127.0.0.1, this machine only)${NC}"
echo ""
echo -e " ${CYAN}# Open in browser${NC}"
echo -e " http://$(detect_lan_ip):3000 ${DIM}(any device on your network)${NC}"
else
echo -e " ${CYAN}codeman web${NC} # Start the web server"
echo -e " ${CYAN}codeman web --https${NC} # With HTTPS (for remote access)"
echo ""
echo -e " ${CYAN}# Open in browser${NC}"
echo -e " http://localhost:3000"
fi
echo ""
fi
@@ -1377,10 +1767,12 @@ main() {
echo -e " https://github.com/Ark0N/Codeman"
echo ""
if ! check_claude && ! check_opencode; then
if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini; then
echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:"
echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code"
echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode"
echo -e " ${CYAN}npm install -g @openai/codex${NC} # Codex"
echo -e " ${CYAN}npm install -g @google/gemini-cli${NC} # Gemini"
echo ""
fi
@@ -1400,6 +1792,11 @@ main() {
# Source profile to pick up PATH changes, then exec codeman
# shellcheck disable=SC1090
source "$profile" 2>/dev/null || true
if [[ -n "$BIND_HOST" ]]; then
export CODEMAN_HOST="$BIND_HOST"
[[ -n "$BIND_PASSWORD" ]] && export CODEMAN_PASSWORD="$BIND_PASSWORD"
[[ "$BIND_ACK" == "1" ]] && export CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1
fi
exec node "$INSTALL_DIR/dist/index.js" web
fi
}
@@ -1412,10 +1809,26 @@ update() {
info "Updating Codeman..."
cd "$INSTALL_DIR"
git remote set-url origin "$REPO_URL" 2>/dev/null || true
# Never blow away local changes silently (this used to be an unconditional
# reset --hard). Interactive users get a choice; headless runs auto-stash
# so the changes stay recoverable, the same policy as scripts/self-update.sh.
if ! git diff --quiet 2>/dev/null || ! git diff --staged --quiet 2>/dev/null; then
warn "Local changes detected in $INSTALL_DIR"
if prompt_yes_no "Stash local changes and update? (recover with: git stash pop)"; then
git stash push --quiet -m "codeman-installer auto-stash $(date -u +%Y-%m-%dT%H:%M:%SZ)"
info "Local changes stashed (see 'git stash list' in $INSTALL_DIR)"
else
info "Keeping local changes; update skipped."
return 0
fi
fi
git fetch --quiet origin
git reset --hard "origin/$BRANCH" --quiet
npm install --quiet --no-fund --no-audit 2>/dev/null || npm install --no-fund --no-audit
npm run build --quiet 2>/dev/null || npm run build
date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete"
success "Updated to $(node -e "console.log(require('./package.json').version)")"
echo ""
@@ -1423,8 +1836,13 @@ update() {
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then
info "Restarting codeman-web service..."
systemctl --user restart codeman-web.service
success "codeman-web service restarted"
systemctl --user restart codeman-web.service 2>/dev/null || true
if verify_systemd_active; then
success "codeman-web service restarted"
else
warn "codeman-web.service did not come back up."
warn "Inspect: systemctl --user status codeman-web ; journalctl --user -u codeman-web -e"
fi
elif [[ -f "$agent_plist" ]]; then
info "Restarting LaunchAgent..."
launchctl unload "$agent_plist" 2>/dev/null || true
@@ -1493,6 +1911,9 @@ uninstall() {
rm -rf "$INSTALL_DIR"
success "Removed $INSTALL_DIR"
else
# Clear the marker so a future installer run does full setup again
# (the symlinks and services being removed here need recreating).
rm -f "$INSTALL_DIR/.install-complete"
info "Kept $INSTALL_DIR"
fi
fi
@@ -1522,7 +1943,10 @@ case "${1:-}" in
update) update ;;
uninstall) uninstall ;;
*)
if [[ -z "${1:-}" && -d "$INSTALL_DIR/.git" ]]; then
# Only a COMPLETED install re-runs as a quiet update. A partial one
# (clone succeeded but build/menu never finished) lacks the marker and
# re-runs the full flow, so a failed first attempt can actually finish.
if [[ -z "${1:-}" && -d "$INSTALL_DIR/.git" && -f "$INSTALL_DIR/.install-complete" ]]; then
print_banner
update
else
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.5.0",
"version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.5.0",
"version": "1.8.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.5.0",
"version": "1.8.0",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+2
View File
@@ -67,6 +67,7 @@ appendFileSync(
// 4. Minify frontend assets
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
@@ -86,6 +87,7 @@ console.log('\n[build] content-hash cache busting');
'styles.css',
'mobile.css',
'constants.js',
'i18n.js',
'mobile-handlers.js',
'voice-input.js',
'notification-manager.js',
+57 -8
View File
@@ -105,6 +105,45 @@ export function parseLoadedImageRef(loadOutput: string): string | null {
return null;
}
/**
* Validate an imported bundle's manifest BEFORE any of its fields are trusted.
* A bundle is cross-machine input (potentially authored by someone else), and its
* fields flow into stored host/case config that the schema layer never sees:
* `engine` becomes the probe/launch binary selector, `image`/`containerWorkdir`
* reach the shellescaped launch string, `network` is a create arg. Mirror the
* DockerHostSchema/DockerCaseLinkSchema constraints here (throwing, since this is
* not a web-layer module). Exported for unit tests.
*/
export function validateImportManifest(manifest: DockerExportManifest): void {
const fail = (msg: string): never => {
throw new Error(`invalid bundle manifest: ${msg}`);
};
if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) {
fail(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`);
}
if (manifest.mode !== 'full' && manifest.mode !== 'workspace') fail(`unknown mode ${String(manifest.mode)}`);
if (manifest.engine !== 'docker' && manifest.engine !== 'podman') fail(`unknown engine ${String(manifest.engine)}`);
if (typeof manifest.caseName !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(manifest.caseName)) fail('bad caseName');
if (
typeof manifest.image !== 'string' ||
manifest.image.length > 512 ||
!/^[a-zA-Z0-9][\w./:@-]*$/.test(manifest.image)
) {
fail('bad image reference');
}
if (
typeof manifest.containerWorkdir !== 'string' ||
manifest.containerWorkdir.length > 2000 ||
!manifest.containerWorkdir.startsWith('/') ||
// comma: --mount specs are comma-delimited CSV; shell escaping cannot protect it
/[`$\\"'\n\r;&|<>,]/.test(manifest.containerWorkdir)
) {
fail('bad containerWorkdir');
}
if (!['bridge', 'none', 'custom'].includes(manifest.network)) fail(`unknown network ${String(manifest.network)}`);
if (typeof manifest.checksums !== 'object' || manifest.checksums === null) fail('missing checksums');
}
// ========== IO helpers ==========
function run(
@@ -338,25 +377,34 @@ export async function importDockerBundle(params: {
destWorkspace: string;
engine: DockerEngine;
timestamp: number;
/** Schema-validated destination case name; the quarantine tag derives from THIS,
* never from the (attacker-authored) manifest.caseName. */
newCaseName: string;
}): Promise<ImportResult> {
const { bundlePath, destWorkspace, engine, timestamp } = params;
const { bundlePath, destWorkspace, engine, timestamp, newCaseName } = params;
const argv: string[] = [engine === 'podman' ? 'podman' : 'docker'];
if (IS_TEST_MODE) {
const raw = await fs.readFile(bundlePath, 'utf-8').catch(() => '{}');
return { manifest: JSON.parse(raw) as DockerExportManifest, workspacePath: destWorkspace };
const manifest = JSON.parse(raw) as DockerExportManifest;
validateImportManifest(manifest);
return { manifest, workspacePath: destWorkspace };
}
const stageDir = `${destWorkspace}.import-stage-${timestamp}`;
mkdirSync(stageDir, { recursive: true });
try {
await run('tar', ['-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 });
// Outer-bundle traversal guard (defense in depth: GNU/bsd tar already refuse
// `..`/absolute members by default, but the bundle is cross-machine input).
const { stdout: bundleMembers } = await run('tar', ['-tzf', bundlePath], { timeout: 60_000 });
for (const member of bundleMembers.split('\n').filter(Boolean)) {
if (!isSafeTarMember(member)) throw new Error(`unsafe path in bundle archive: ${member}`);
}
await run('tar', ['--no-same-owner', '-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 });
const manifestRaw = await fs.readFile(join(stageDir, 'manifest.json'), 'utf-8');
const manifest = JSON.parse(manifestRaw) as DockerExportManifest;
if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) {
throw new Error(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`);
}
validateImportManifest(manifest);
// Integrity: verify checksums before trusting any member.
const workspaceTar = join(stageDir, 'workspace.tar');
@@ -385,8 +433,9 @@ export async function importDockerBundle(params: {
const { stdout } = await run(argv[0], [...argv.slice(1), 'load', '-i', imageTar], { timeout: 300_000 });
const loadedRef = parseLoadedImageRef(stdout);
if (!loadedRef) throw new Error('could not determine loaded image ref');
// Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original tag.
importedImage = importedImageTag(manifest.caseName, timestamp);
// Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original
// tag; the tag name derives from the caller's schema-validated newCaseName.
importedImage = importedImageTag(newCaseName, timestamp);
await run(argv[0], [...argv.slice(1), 'tag', loadedRef, importedImage], { timeout: 60_000 });
}
+120 -21
View File
@@ -109,6 +109,24 @@ export async function writeDockerCases(configDir: string, cases: DockerCase[]):
await writeJsonArray(configDir, dockerCasesPath(configDir), cases);
}
/**
* Persist the case's last Claude conversation id (the `--resume` seed for the
* container-recreated relaunch, docs/docker-cases-plan.md two-layer durability).
* Keyed by container name so callers that only hold a SessionDocker can update it.
* No-op when the id is unchanged or the case is gone.
*/
export async function persistDockerCaseClaudeSessionId(
configDir: string,
containerName: string,
claudeSessionId: string
): Promise<void> {
const cases = await readDockerCases(configDir);
const idx = cases.findIndex((c) => (c.container ?? dockerContainerName(c.name)) === containerName);
if (idx === -1 || cases[idx].lastClaudeSessionId === claudeSessionId) return;
cases[idx] = { ...cases[idx], lastClaudeSessionId: claudeSessionId };
await writeDockerCases(configDir, cases);
}
// ========== Naming / display / defaults ==========
/** Per-case container name. Mirrors how remote derives a stable name from the case. */
@@ -624,6 +642,73 @@ export function resolveDockerCredentialArtifacts(home: string = homedir()): Dock
// ========== Daemon probes (IO; no-op under VITEST) ==========
/**
* UNESCAPED argv prefix for execFile-based probes. The shellescaped
* buildDockerBaseArgs variant is for interpolation into the `bash -c` launch
* string; argv arrays must NOT carry literal quotes (mirror of docker-export's
* dockerArgv).
*/
function dockerEngineArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
if (docker.context) argv.push('--context', docker.context);
if (docker.daemonHost) argv.push('-H', docker.daemonHost);
return argv;
}
export interface DockerDriftStatus {
/** Container exists (daemon reachable AND a container with this name is present). */
exists: boolean;
running: boolean;
/** The desired configHash no longer matches the container's codeman.confighash label. */
drifted: boolean;
currentHash?: string;
}
/**
* Drift check (docs/docker-cases-plan.md §4): compare the DESIRED configHash
* against the existing container's `codeman.confighash` label so docker-host
* config edits actually take effect instead of being silently ignored by the
* idempotent inspect-or-create launch chain. `exists:false` (no container /
* daemon down) means there is nothing to drift. No-op under VITEST.
*/
export async function checkDockerConfigDrift(
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName' | 'configHash'>
): Promise<DockerDriftStatus> {
if (IS_TEST_MODE) return { exists: false, running: false, drifted: false };
const argv = dockerEngineArgv(docker);
try {
const { stdout } = await execFileAsync(
argv[0],
[
...argv.slice(1),
'inspect',
'-f',
'{{.State.Running}}\t{{index .Config.Labels "codeman.confighash"}}',
docker.containerName,
],
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
);
const [running = '', hash = ''] = stdout.trim().split('\t');
return { exists: true, running: running === 'true', drifted: hash !== docker.configHash, currentHash: hash };
} catch {
return { exists: false, running: false, drifted: false };
}
}
/**
* `docker rm -f` the case container (the recreate-on-drift confirm action; the
* launch chain recreates it with the new config on next start). Workspace +
* transcripts ride bind mounts and survive; the conversation resumes via the
* case's lastClaudeSessionId. No-op under VITEST.
*/
export async function removeDockerContainer(
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName'>
): Promise<void> {
if (IS_TEST_MODE) return;
const argv = dockerEngineArgv(docker);
await execFileAsync(argv[0], [...argv.slice(1), 'rm', '-f', docker.containerName], { timeout: 30_000 });
}
export interface DockerAvailability {
ok: boolean;
engine: DockerEngine;
@@ -702,11 +787,16 @@ export async function checkDockerAvailable(engine?: DockerEngine): Promise<Docke
};
}
/** Is the base image present locally? (never triggers an auto-pull). */
export async function checkDockerImagePresent(engine: DockerEngine, image: string): Promise<boolean> {
/** Is the base image present on the host's daemon? (never triggers an auto-pull).
* Honors context/daemonHost so a remote-daemon host is probed on the RIGHT daemon. */
export async function checkDockerImagePresent(
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>,
image: string
): Promise<boolean> {
if (IS_TEST_MODE) return true;
const argv = dockerEngineArgv(docker);
try {
await execFileAsync(engine, ['image', 'inspect', '--format', '{{.Id}}', image], {
await execFileAsync(argv[0], [...argv.slice(1), 'image', 'inspect', '--format', '{{.Id}}', image], {
timeout: DOCKER_PROBE_TIMEOUT_MS,
});
return true;
@@ -748,12 +838,12 @@ function resolveAgentDockerfile(): { dockerfile: string; contextDir: string } |
* lines for SSE surfacing.
*/
export async function ensureAgentBaseImage(
engine: DockerEngine,
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>,
image: string,
opts: { onProgress?: (line: string) => void; noCache?: boolean } = {}
): Promise<EnsureImageResult> {
if (IS_TEST_MODE) return { ok: true, built: false, alreadyPresent: true };
if (await checkDockerImagePresent(engine, image)) {
if (await checkDockerImagePresent(docker, image)) {
return { ok: true, built: false, alreadyPresent: true };
}
if (image !== DEFAULT_AGENT_IMAGE) {
@@ -764,16 +854,16 @@ export async function ensureAgentBaseImage(
error: `image ${image} is not present and only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull ${image} yourself.`,
};
}
const key = `${engine}:${image}`;
const key = `${docker.engine}:${image}`;
const existing = inFlightImageBuilds.get(key);
if (existing) return existing;
const build = buildAgentImage(engine, image, opts).finally(() => inFlightImageBuilds.delete(key));
const build = buildAgentImage(docker, image, opts).finally(() => inFlightImageBuilds.delete(key));
inFlightImageBuilds.set(key, build);
return build;
}
function buildAgentImage(
engine: DockerEngine,
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>,
image: string,
opts: { onProgress?: (line: string) => void; noCache?: boolean }
): Promise<EnsureImageResult> {
@@ -786,10 +876,14 @@ function buildAgentImage(
error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`,
});
}
const args = agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache);
const argv = dockerEngineArgv(docker);
const args = [
...argv.slice(1),
...agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache),
];
return new Promise<EnsureImageResult>((resolve) => {
// async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop.
const child = spawn(engine, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const child = spawn(argv[0], args, { stdio: ['ignore', 'pipe', 'pipe'] });
const forward = (buf: Buffer) => {
for (const line of buf.toString('utf-8').split('\n')) {
const trimmed = line.trimEnd();
@@ -803,12 +897,12 @@ function buildAgentImage(
ok: false,
built: false,
alreadyPresent: false,
error: `could not spawn ${engine} build: ${err.message}`,
error: `could not spawn ${argv[0]} build: ${err.message}`,
});
});
child.on('exit', (code) => {
if (code === 0) resolve({ ok: true, built: true, alreadyPresent: false });
else resolve({ ok: false, built: false, alreadyPresent: false, error: `${engine} build failed (exit ${code})` });
else resolve({ ok: false, built: false, alreadyPresent: false, error: `${argv[0]} build failed (exit ${code})` });
});
});
}
@@ -827,21 +921,21 @@ export interface DockerTmuxCheckResult {
* (`--pull=never`). No-op under VITEST. Mirror of checkRemoteTmuxAvailable.
*/
export async function checkDockerTmuxAvailable(
docker: Pick<SessionDocker, 'engine' | 'image'>
docker: Pick<SessionDocker, 'engine' | 'image' | 'context' | 'daemonHost'>
): Promise<DockerTmuxCheckResult> {
if (IS_TEST_MODE) return { ok: true, tmuxPath: '/usr/bin/tmux' };
const engine = docker.engine;
if (!(await checkDockerImagePresent(engine, docker.image))) {
if (!(await checkDockerImagePresent(docker, docker.image))) {
return {
ok: false,
imageMissing: true,
error: `image ${docker.image} not present (the default image is auto-built on first use; a custom image must be built or pulled first)`,
};
}
const argv = dockerEngineArgv(docker);
try {
const { stdout } = await execFileAsync(
engine,
['run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'],
argv[0],
[...argv.slice(1), 'run', '--rm', '--pull=never', docker.image, 'sh', '-lc', 'command -v tmux'],
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
);
const tmuxPath = stdout.trim();
@@ -938,16 +1032,21 @@ export async function reapOrphanedDockerContainers(
* Returns undefined on any failure. No-op under VITEST.
*/
export async function probeDockerCliVersion(
docker: Pick<SessionDocker, 'engine' | 'containerName'>,
docker: Pick<SessionDocker, 'engine' | 'containerName' | 'context' | 'daemonHost'>,
mode: SessionMode
): Promise<string | undefined> {
if (IS_TEST_MODE) return undefined;
const bin = mode === 'shell' ? null : mode;
if (!bin) return undefined;
const argv = dockerEngineArgv(docker);
try {
const { stdout } = await execFileAsync(docker.engine, ['exec', docker.containerName, bin, '--version'], {
timeout: DOCKER_PROBE_TIMEOUT_MS,
});
const { stdout } = await execFileAsync(
argv[0],
[...argv.slice(1), 'exec', docker.containerName, bin, '--version'],
{
timeout: DOCKER_PROBE_TIMEOUT_MS,
}
);
const match = stdout.trim().match(/\d+\.\d+\.\d+/);
return match ? match[0] : stdout.trim() || undefined;
} catch {
+151
View File
@@ -8,6 +8,7 @@ import type {
RemoteCase,
RemoteCommandMode,
RemoteHost,
RemoteSessionInfo,
RemoteSshOptions,
SessionMode,
SessionRemote,
@@ -173,6 +174,14 @@ export interface RemoteTmuxCheckResult {
export async function checkRemoteTmuxAvailable(
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
): Promise<RemoteTmuxCheckResult> {
// Under vitest, never open a real ssh connection — mirrors TmuxManager's
// no-op-shell-under-VITEST (IS_TEST_MODE). Without this, remote-case
// create-path tests hit a real ~10s ssh timeout. The command construction is
// covered by buildRemoteTmuxCheckCommand unit tests; only the live probe is
// short-circuited here.
if (process.env.VITEST) {
return { ok: true, tmuxPath: '(test-mode)' };
}
const command = buildRemoteTmuxCheckCommand(host);
try {
const { stdout } = await execAsync(command, { timeout: 15_000 });
@@ -202,6 +211,109 @@ export async function checkRemoteTmuxAvailable(
}
}
/**
* COD-105 — build the SSH command that lists `codeman-*` tmux sessions on a
* remote host's canonical `-L codeman` socket.
*
* `list-sessions` exits NON-ZERO with empty output when no sessions exist (and
* the server isn't running), so `2>/dev/null` swallows tmux's "no server
* running" stderr; the caller treats a non-zero exit / empty output as "no
* sessions" rather than an error.
*
* COD-107 — connection options come from the shared `buildSshConnectionArgs`, so
* discovery connects with the SAME port/identity/proxy/jump-host as the launch
* and the tmux prereq probe.
*/
export function buildRemoteListSessionsCommand(
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
): string {
const [ssh, ...connectionArgs] = buildSshConnectionArgs(host);
const parts = [ssh, connectionArgs[0], '-o ConnectTimeout=10', ...connectionArgs.slice(1)];
// The tmux list-sessions invocation is passed as ONE shell-quoted argument so
// the remote login shell runs it verbatim. The `-F` format uses literal `\t`
// separators (tmux expands them); `2>/dev/null` is inside the quoted command.
const remoteCmd =
'tmux -L codeman list-sessions -F "#{session_name}\\t#{session_attached}\\t#{session_created}\\t#{session_windows}" 2>/dev/null';
parts.push(remoteSshTarget(host), shellescape(remoteCmd));
return parts.join(' ');
}
/**
* COD-105 — pure parser for the `tmux list-sessions -F` output emitted by
* `buildRemoteListSessionsCommand`. Factored out so the parse is unit-testable
* without opening a real ssh connection.
*
* - Splits each non-empty line into [name, attached, created, windows] on the
* field separator. IMPORTANT: the remote tmux's `-F "…\t…"` format does NOT
* expand `\t` to a real tab — it emits the LITERAL two-character sequence
* `\t` (verified on aa-desktop / tmux next-3.7). So we split on the literal
* backslash-t sequence; we also tolerate a real tab in case a tmux build
* does expand it. (A real TAB is the regex `\t`; a literal backslash-t is the
* regex `\\t`.)
* - Keeps ONLY sessions whose name starts with `codeman-` (ignores foreign tmux
* sessions that happen to share the socket).
* - Coerces: `attached` → boolean (`'1'`), `created`/`windows` → finite ints.
* - Skips malformed lines (wrong column count or non-numeric created/windows)
* rather than emitting garbage.
*/
export function parseRemoteSessionList(stdout: string): RemoteSessionInfo[] {
const out: RemoteSessionInfo[] = [];
for (const rawLine of stdout.split('\n')) {
const line = rawLine.trim();
if (!line) continue;
// Split on a literal `\t` (backslash + t, what the remote tmux emits) OR a
// real tab character. `/\\t|\t/` = the two-char sequence, or a TAB.
const cols = line.split(/\\t|\t/);
if (cols.length !== 4) continue;
const [name, attachedStr, createdStr, windowsStr] = cols;
if (!name.startsWith('codeman-')) continue;
const created = Number(createdStr);
const windows = Number(windowsStr);
if (!Number.isFinite(created) || !Number.isFinite(windows)) continue;
// COD-106 — `session_attached` is the CLIENT COUNT (not a 0/1 flag); >1 = shared.
const attachedNum = Number(attachedStr.trim());
const attachedClients = Number.isFinite(attachedNum) ? Math.max(0, Math.trunc(attachedNum)) : 0;
out.push({
name,
attached: attachedClients > 0,
attachedClients,
created: Math.trunc(created),
windows: Math.trunc(windows),
});
}
return out;
}
/**
* COD-105 — discover `codeman-*` tmux sessions already running on a remote host
* (created by the remote's own Codeman, another instance, or this one), so the
* operator can attach to one this Codeman didn't launch.
*
* NEVER throws: returns `[]` on unreachable host / no tmux / no sessions
* (`list-sessions` exits non-zero with empty output when there are none).
*
* VITEST guard — like `checkRemoteTmuxAvailable`, returns `[]` under test so a
* real ssh never runs in a request path (which would make route tests hit a
* ~10s timeout). The command construction is covered by
* `buildRemoteListSessionsCommand` and the parse by `parseRemoteSessionList`.
*/
export async function listRemoteCodemanSessions(
remote: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
): Promise<RemoteSessionInfo[]> {
if (process.env.VITEST) {
return [];
}
const command = buildRemoteListSessionsCommand(remote);
try {
const { stdout } = await execAsync(command, { timeout: 15_000 });
return parseRemoteSessionList(stdout);
} catch {
// Unreachable host, no tmux server, or no sessions (non-zero exit). All map
// to "nothing to attach to" — never surface as an error to the caller.
return [];
}
}
export function remoteDisplayPath(
remote: Pick<SessionRemote, 'username' | 'host' | 'remotePath'> | { username: string; host: string; path: string }
): string {
@@ -218,6 +330,10 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi
port: host.port,
remotePath: remoteCase.remotePath,
commands: host.commands,
// COD-105 — the COD-104 launch path creates the remote session, so we own it
// (an explicit kill may propagate a remote kill-session). Discovered+attached
// sessions go through `toAttachedSessionRemote` with `owned: false`.
owned: true,
// COD-107 — carry the advanced SSH options from host config into the session
// so the launch/prereq commands connect the same way the operator configured.
identityFile: host.identityFile,
@@ -226,3 +342,38 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi
extraSshOptions: host.extraSshOptions,
};
}
/**
* COD-105 — build a NON-owned `SessionRemote` for ATTACHING to a `codeman-*`
* session already running on a remote host (discovered via
* `listRemoteCodemanSessions`). The resulting session's pane runs
* `tmux -L codeman attach -t <remoteSessionName>` (see
* `buildRemoteAttachCommand`), and because we did NOT create the remote session,
* `owned: false` means closing the tab DETACHES rather than killing it.
*
* `remotePath` is informational here (the attached remote session keeps its own
* cwd); we record the host's nominal path so display helpers still show
* `user@host:path`.
*/
export function toAttachedSessionRemote(
host: RemoteHost,
remoteSessionName: string,
remotePath: string
): SessionRemote {
return {
hostId: host.id,
label: host.label,
host: host.host,
username: host.username,
port: host.port,
remotePath,
commands: host.commands,
// Discovered + attached — another Codeman created it. Detach-not-kill.
owned: false,
remoteSessionName,
identityFile: host.identityFile,
socksProxy: host.socksProxy,
jumpHost: host.jumpHost,
extraSshOptions: host.extraSshOptions,
};
}
+184
View File
@@ -0,0 +1,184 @@
/**
* @fileoverview Pure logic for the remote-session auto-reconnect watcher (COD-108).
*
* COD-104 made remote tmux sessions durable + idempotently reattachable, but a
* reconnect only fired at explicit trigger points. COD-108 adds a continuous
* watcher (in `TmuxManager`) that detects a dead remote pane and emits
* `remoteSessionDropped`; `SessionManager`/server then reassembles the respawn
* options and reattaches (re-running the idempotent remote command).
*
* This module holds the SIDE-EFFECT-FREE pieces so they can be unit-tested
* without real tmux:
* - the bounded exponential **backoff schedule** (attempt → delay, capped),
* - the per-session **reconnect state** shape,
* - the **eligibility decision** (`decideReconnect`) given a session + its
* reconnect state + the current time + the guard set.
*
* The watcher in `tmux-manager.ts` owns the live `isPaneDead` probe and the
* timers; everything here is pure and deterministic (time is injected).
*
* @module remote-reconnect
*/
/**
* Bounded exponential backoff delays (ms) between reconnect attempts.
* Attempt N (1-based) waits `BACKOFF_SCHEDULE_MS[N-1]` from the previous emit
* before the next emit is eligible. After the last entry the session is
* considered `reconnect-exhausted` and the watcher stops emitting for it.
*
* 5s, 15s, 45s, 2m, 5m, 5m → ~6 attempts spanning ~13 minutes.
*/
export const BACKOFF_SCHEDULE_MS: readonly number[] = [5_000, 15_000, 45_000, 120_000, 300_000, 300_000];
/** Maximum number of reconnect attempts before exhaustion. */
export const MAX_RECONNECT_ATTEMPTS = BACKOFF_SCHEDULE_MS.length;
/**
* Delay (ms) to wait AFTER emitting attempt `attempt` (1-based) before the next
* attempt is eligible. `attempt <= 0` returns the first delay; an attempt at or
* beyond the cap returns the last delay (callers should check exhaustion via
* {@link isExhausted} rather than relying on this for the stop decision).
*
* Pure — no clock, no I/O.
*/
export function reconnectDelayForAttempt(attempt: number): number {
if (!Number.isFinite(attempt) || attempt <= 1) return BACKOFF_SCHEDULE_MS[0];
const idx = Math.min(Math.floor(attempt) - 1, BACKOFF_SCHEDULE_MS.length - 1);
return BACKOFF_SCHEDULE_MS[idx];
}
/** Whether `attempts` reconnect emits have reached/exceeded the cap. Pure. */
export function isExhausted(attempts: number): boolean {
return attempts >= MAX_RECONNECT_ATTEMPTS;
}
/**
* Per-session reconnect bookkeeping held by the watcher. All time values are
* epoch ms. `inFlight` guards against stacking respawns when a tick fires while
* a previous reattach is still running. `exhaustedEmitted` ensures the
* `remoteReconnectExhausted` event fires at most once per session.
*/
export interface RemoteReconnectState {
/** Number of `remoteSessionDropped` emits so far (advances per emit). */
attempts: number;
/** Earliest time (epoch ms) the next emit is eligible. 0 = eligible now. */
nextEligibleAt: number;
/** A reattach triggered by a prior emit is currently running. */
inFlight: boolean;
/** Cap reached — stop auto-retrying for this session. */
exhausted: boolean;
/** The `remoteReconnectExhausted` SSE event has already been emitted. */
exhaustedEmitted: boolean;
}
/** A fresh reconnect state (no attempts, immediately eligible). Pure. */
export function freshReconnectState(): RemoteReconnectState {
return { attempts: 0, nextEligibleAt: 0, inFlight: false, exhausted: false, exhaustedEmitted: false };
}
/**
* Advance the backoff after an emit at time `now`. Increments `attempts` and
* schedules `nextEligibleAt = now + delay`. Returns a NEW state object (does
* not mutate the input). Pure.
*
* NOTE: this does NOT set `exhausted`. Exhaustion is a decision the watcher
* makes on the FOLLOWING tick (via {@link decideReconnect} → `exhaust`), so the
* `remoteReconnectExhausted` event fires exactly once after the final attempt's
* backoff window elapses — not pre-emptively on the last emit.
*/
export function advanceBackoff(state: RemoteReconnectState, now: number): RemoteReconnectState {
const attempts = state.attempts + 1;
const delay = reconnectDelayForAttempt(attempts);
return {
...state,
attempts,
nextEligibleAt: now + delay,
};
}
/** Reset after a successful reattach — back to a fresh, eligible state. Pure. */
export function resetReconnectState(): RemoteReconnectState {
return freshReconnectState();
}
/** Minimal session view the decision needs (avoids importing MuxSession here). */
export interface ReconnectSessionView {
sessionId: string;
/** Truthy when this is a remote (SSH-wrapped) session. */
isRemote: boolean;
/** Result of `isPaneDead(muxName)` for this session. */
paneDead: boolean;
}
/**
* Decision outcomes for a single watcher tick on one session.
* - `emit` → emit `remoteSessionDropped { sessionId, attempt }`, then
* advance backoff (attempt = the returned `attempt`).
* - `exhaust` → cap reached this tick; emit `remoteReconnectExhausted` once.
* - `skip` → do nothing (not remote / pane alive / guarded / in-flight /
* not yet due / already exhausted).
*/
export type ReconnectAction =
| { kind: 'emit'; attempt: number }
| { kind: 'exhaust' }
| { kind: 'skip'; reason: ReconnectSkipReason };
export type ReconnectSkipReason =
| 'not-remote'
| 'pane-alive'
| 'guarded'
| 'in-flight'
| 'not-due'
| 'exhausted'
| 'disabled';
export interface DecideReconnectInput {
session: ReconnectSessionView;
state: RemoteReconnectState | undefined;
/** Session is in the intentional-teardown guard set (killed/detached/stopping). */
guarded: boolean;
/** Kill-switch: `remoteAutoReconnect` setting. When false, never reconnect. */
enabled: boolean;
now: number;
}
/**
* PURE eligibility decision for one session on one tick. No clock, no I/O — all
* inputs are passed in. The watcher translates the result into emits + state
* transitions.
*
* Order of guards (most-decisive first):
* 1. kill-switch off → skip:disabled
* 2. not a remote session → skip:not-remote
* 3. pane is alive → skip:pane-alive
* 4. intentional teardown guard → skip:guarded (NEVER revive a killed tab)
* 5. a reattach already running → skip:in-flight (no stacked respawns)
* 6. already exhausted → skip:exhausted (one exhaust emit, then quiet)
* 7. cap reached this tick → exhaust
* 8. not yet due (backoff) → skip:not-due
* 9. otherwise → emit (attempt = attempts + 1)
*/
export function decideReconnect(input: DecideReconnectInput): ReconnectAction {
const { session, state, guarded, enabled, now } = input;
if (!enabled) return { kind: 'skip', reason: 'disabled' };
if (!session.isRemote) return { kind: 'skip', reason: 'not-remote' };
if (!session.paneDead) return { kind: 'skip', reason: 'pane-alive' };
// Intentional kill / detach must NEVER be auto-revived.
if (guarded) return { kind: 'skip', reason: 'guarded' };
const s = state ?? freshReconnectState();
// Only one reconnect in flight per session — don't stack respawns.
if (s.inFlight) return { kind: 'skip', reason: 'in-flight' };
if (s.exhausted) return { kind: 'skip', reason: 'exhausted' };
// Cap reached: surface exhaustion once, then go quiet.
if (isExhausted(s.attempts)) return { kind: 'exhaust' };
// Backoff gate — only emit when due.
if (now < s.nextEligibleAt) return { kind: 'skip', reason: 'not-due' };
return { kind: 'emit', attempt: s.attempts + 1 };
}
+94 -3
View File
@@ -28,9 +28,15 @@ export type UnifiedSessionItem = {
lastActivityAt?: number;
claudeSessionId?: string;
firstPrompt?: string;
/** Most recent user prompt from the transcript (COD-145), parallel to firstPrompt. */
lastPrompt?: string;
sizeBytes?: number;
projectKey?: string;
remote?: boolean;
/** Pinned to the top of the session manager list (COD-139). */
pinned?: boolean;
/** When the session was pinned (epoch ms) — orders the pinned group desc. */
pinnedAt?: number;
sources: string[];
stats?: { memoryMB: number; cpuPercent: number };
};
@@ -46,6 +52,8 @@ export type LiveSessionInput = {
createdAt?: number;
lastActivityAt?: number;
claudeSessionId?: string;
pinned?: boolean;
pinnedAt?: number;
};
/** Persisted session view (subset of `SessionState`). */
@@ -59,6 +67,8 @@ export type PersistedSessionInput = {
lastActivityAt?: number;
/** Claude conversation ID this session resumes (`SessionState.resumeSessionId`). */
claudeSessionId?: string;
pinned?: boolean;
pinnedAt?: number;
};
/** Lifecycle audit-log view. Entries are expected NEWEST-first (the order `SessionLifecycleLog.query()` returns). */
@@ -77,6 +87,8 @@ export type HistoryInput = {
sizeBytes: number;
lastModified: string;
firstPrompt?: string;
/** Most recent user prompt from the transcript (COD-145). */
lastPrompt?: string;
projectKey?: string;
};
@@ -149,6 +161,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
overwrite(item, 'workingDir', h.workingDir);
overwrite(item, 'sizeBytes', h.sizeBytes);
overwrite(item, 'firstPrompt', h.firstPrompt);
overwrite(item, 'lastPrompt', h.lastPrompt);
overwrite(item, 'projectKey', h.projectKey);
const ms = Date.parse(h.lastModified);
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
@@ -175,6 +188,8 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
overwrite(item, 'workingDir', p.workingDir);
overwrite(item, 'createdAt', p.createdAt);
overwrite(item, 'lastActivityAt', p.lastActivityAt);
overwrite(item, 'pinned', p.pinned);
overwrite(item, 'pinnedAt', p.pinnedAt);
}
// 4) live (highest precedence)
@@ -189,6 +204,8 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
overwrite(item, 'createdAt', v.createdAt);
overwrite(item, 'lastActivityAt', v.lastActivityAt);
overwrite(item, 'claudeSessionId', v.claudeSessionId);
overwrite(item, 'pinned', v.pinned);
overwrite(item, 'pinnedAt', v.pinnedAt);
}
// 5) mux stats + remote flag (create item if mux-only)
@@ -200,6 +217,64 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
if (m.remote !== undefined) item.remote = m.remote;
}
// firstPrompt backfill (COD-140): the only source that sets firstPrompt is the
// transcript-history view, keyed by the Claude transcript file's UUID. A live/persisted
// row keyed by its Codeman id only inherits firstPrompt when that id happens to equal an
// on-disk transcript UUID. When it doesn't (stale/wrong claudeSessionId, post-/clear new
// uuid, resumed/attached/worktree session, transcript not yet flushed), the row shows
// "(no prompt captured)" even though a real transcript for that working dir exists under a
// different UUID. Backfill from the already-passed history: first try the claudeSessionId
// join, then the newest transcript in the same workingDir. Never overwrite a non-empty
// firstPrompt (so rows keyed to their own transcript are untouched).
const firstPromptByUuid = new Map<string, string>();
const firstPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
// COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite).
const lastPromptByUuid = new Map<string, string>();
const lastPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
for (const h of sources.history ?? []) {
const ms = Date.parse(h.lastModified);
const ts = Number.isNaN(ms) ? -Infinity : ms;
if (h.firstPrompt) {
firstPromptByUuid.set(h.sessionId, h.firstPrompt);
if (h.workingDir) {
const existing = firstPromptByWorkingDir.get(h.workingDir);
if (!existing || ts > existing.ms) {
firstPromptByWorkingDir.set(h.workingDir, { prompt: h.firstPrompt, ms: ts });
}
}
}
if (h.lastPrompt) {
lastPromptByUuid.set(h.sessionId, h.lastPrompt);
if (h.workingDir) {
const existing = lastPromptByWorkingDir.get(h.workingDir);
if (!existing || ts > existing.ms) {
lastPromptByWorkingDir.set(h.workingDir, { prompt: h.lastPrompt, ms: ts });
}
}
}
}
for (const item of map.values()) {
if (!item.firstPrompt) {
// never overwrite an existing non-empty prompt
const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined;
if (byUuid) {
item.firstPrompt = byUuid;
} else if (item.workingDir) {
const byDir = firstPromptByWorkingDir.get(item.workingDir);
if (byDir) item.firstPrompt = byDir.prompt;
}
}
if (!item.lastPrompt) {
const byUuid = item.claudeSessionId ? lastPromptByUuid.get(item.claudeSessionId) : undefined;
if (byUuid) {
item.lastPrompt = byUuid;
} else if (item.workingDir) {
const byDir = lastPromptByWorkingDir.get(item.workingDir);
if (byDir) item.lastPrompt = byDir.prompt;
}
}
}
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.
const kept: UnifiedSessionItem[] = [];
for (const item of map.values()) {
@@ -211,8 +286,24 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
if (isReal) kept.push(item);
}
// Stable sort: lastActivityAt desc (undefined last), createdAt desc, sessionId asc.
// Stable sort (COD-139): pinned group first (pinnedAt desc, most-recently-pinned
// first), then unpinned by lastActivityAt desc (undefined last), createdAt desc,
// sessionId asc.
kept.sort((a, b) => {
const pa = a.pinned === true;
const pb = b.pinned === true;
if (pa !== pb) return pa ? -1 : 1; // pinned floats above unpinned
if (pa && pb) {
// Both pinned: most-recently-pinned first (undefined pinnedAt sorts last).
const ta = a.pinnedAt;
const tb = b.pinnedAt;
if (ta !== tb) {
if (ta === undefined) return 1;
if (tb === undefined) return -1;
return tb - ta;
}
// tie-break falls through to the activity/createdAt/id rules below.
}
const la = a.lastActivityAt;
const lb = b.lastActivityAt;
if (la !== lb) {
@@ -234,7 +325,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
}
/**
* Case-insensitive substring filter (name + firstPrompt + workingDir + sessionId)
* Case-insensitive substring filter (name + firstPrompt + lastPrompt + workingDir + sessionId)
* with offset/limit paging. `total` is the filtered count BEFORE paging.
*/
export function filterAndPaginate(
@@ -244,7 +335,7 @@ export function filterAndPaginate(
const q = (opts.q ?? '').trim().toLowerCase();
const filtered = q
? items.filter((it) => {
const hay = [it.name, it.firstPrompt, it.workingDir, it.sessionId]
const hay = [it.name, it.firstPrompt, it.lastPrompt, it.workingDir, it.sessionId]
.filter((v): v is string => typeof v === 'string')
.join(' ')
.toLowerCase();
+68
View File
@@ -0,0 +1,68 @@
/**
* @fileoverview Pure helpers for the global session tab-order (COD-131).
*
* Tab order (drag-and-drop reorder + Ctrl+Shift+{/}) is persisted server-side
* so it follows the user across devices. The server is authoritative; the
* browser's localStorage (`codeman-session-order`) is the offline fallback.
*
* These helpers are pure (no IO) so they can be unit-tested in isolation and
* reused by both the PUT /api/session-order route and the StateStore accessor.
*
* - `normalizeSessionOrder` coerces arbitrary input into a clean string[]
* (non-empty strings only, deduped with first occurrence winning).
* - `mergeSessionOrder` lets the pushing device's order win, while preserving
* any server-only ids the pushing device didn't know about — they fall to the
* END in their existing relative order, never dropped.
*/
/**
* Coerce arbitrary input into a clean ordered list of session ids:
* keep only non-empty strings and dedup (first occurrence wins).
*
* @param order - unknown input (expected to be a string[], but defensive)
* @returns a normalized string[] (empty array for non-array / all-junk input)
*/
export function normalizeSessionOrder(order: unknown): string[] {
if (!Array.isArray(order)) {
return [];
}
const seen = new Set<string>();
const result: string[] = [];
for (const entry of order) {
if (typeof entry !== 'string' || entry.length === 0) {
continue;
}
if (seen.has(entry)) {
continue;
}
seen.add(entry);
result.push(entry);
}
return result;
}
/**
* Merge an incoming order from a pushing device with the existing server order.
*
* The incoming order wins; any ids present in `existing` but NOT in `incoming`
* are appended at the END, preserving their relative order. This is the
* "server-only ids the pushing device didn't know about fall to the end, never
* dropped" rule.
*
* Both arguments are normalized first, so callers may pass raw input safely.
*
* @param incoming - the order the pushing device wants
* @param existing - the current server-side order
* @returns the merged, normalized order
*/
export function mergeSessionOrder(incoming: string[], existing: string[]): string[] {
const normalizedIncoming = normalizeSessionOrder(incoming);
const incomingSet = new Set(normalizedIncoming);
const merged = [...normalizedIncoming];
for (const id of normalizeSessionOrder(existing)) {
if (!incomingSet.has(id)) {
merged.push(id);
}
}
return merged;
}
+93 -19
View File
@@ -342,6 +342,11 @@ export class Session extends EventEmitter {
// Image watcher setting (per-session toggle)
private _imageWatcherEnabled: boolean = false;
// Pin state (COD-139) — pinned sessions float to the top of the session
// manager list, ordered by pinnedAt descending (most-recently-pinned first).
private _pinned: boolean = false;
private _pinnedAt: number | null = null;
// Flicker filter setting (per-session toggle, applied on frontend)
private _flickerFilterEnabled: boolean = false;
@@ -992,6 +997,26 @@ export class Session extends EventEmitter {
this._imageWatcherEnabled = enabled;
}
/** Whether this session is pinned to the top of the session manager (COD-139). */
get pinned(): boolean {
return this._pinned;
}
/** When the session was pinned (epoch ms), or null when unpinned. */
get pinnedAt(): number | null {
return this._pinnedAt;
}
/**
* Set pin state (COD-139). Pinning stamps pinnedAt with now so the pinned
* group orders most-recently-pinned first; unpinning clears it. Idempotent:
* re-pinning an already-pinned session refreshes its pinnedAt.
*/
setPinned(pinned: boolean): void {
this._pinned = pinned;
this._pinnedAt = pinned ? Date.now() : null;
}
get flickerFilterEnabled(): boolean {
return this._flickerFilterEnabled;
}
@@ -1058,6 +1083,8 @@ export class Session extends EventEmitter {
autoResumeEnabled: this._autoOps.autoResumeEnabled,
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
imageWatcherEnabled: this._imageWatcherEnabled,
pinned: this._pinned || undefined,
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
totalCost: this._totalCost,
inputTokens: this._totalInputTokens,
outputTokens: this._totalOutputTokens,
@@ -1248,6 +1275,70 @@ export class Session extends EventEmitter {
return { isRestored };
}
/**
* COD-108 — re-establish a dropped REMOTE session. Triggered by the
* `TmuxManager` remote-reconnect watcher (via `remoteSessionDropped`): the
* watcher detects a dead remote pane, the session owner reassembles the SAME
* `RespawnPaneOptions` used for Claude-idle respawns and calls
* `respawnPane()` directly. For a remote session that re-runs
* `buildRemoteSessionCommand` (owned → `new-session -A`, non-owned →
* `attach`), which idempotently REATTACHES the still-running durable remote
* tmux session — scrollback + agent intact (proven COD-104/105).
*
* Deliberately does NOT route through the Claude-idle respawn-controller —
* this is a transport re-establish, not a `/clear`/`/compact` cycle.
*
* @returns true if the pane was respawned (reattach issued), false otherwise.
*/
async reattachRemote(): Promise<boolean> {
if (!this._remote) return false; // not a remote session
if (!this._useMux || !this._mux || !this._muxSession) return false;
const mux = this._mux;
// If tmux lost the whole session (not just a dead pane), there is nothing to
// respawn into — a genuine death, leave it for normal recovery/reconcile.
if (!mux.muxSessionExists(this._muxSession.muxName)) {
console.log('[Session] reattachRemote: mux session gone, skipping:', this._muxSession.muxName);
return false;
}
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
if (!newPid) {
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
return false;
}
console.log('[Session] reattachRemote: reattached remote session', this._muxSession.muxName, 'pid', newPid);
return true;
}
/**
* Assemble the {@link RespawnPaneOptions} for this session. Single source of
* truth shared by interactive start, shell start (via their inline copies),
* and {@link reattachRemote} so the remote reattach path can never drift from
* the spawn path.
*/
private _buildRespawnPaneOptions(): import('./mux-interface.js').RespawnPaneOptions {
return {
sessionId: this.id,
workingDir: this.workingDir,
mode: this.mode,
niceConfig: this._niceConfig,
model: this._model,
claudeMode: this._claudeMode,
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
geminiConfig: this._geminiConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
owner: this._owner,
};
}
private _handleTerminalOutput(data: string): void {
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
@@ -1396,25 +1487,8 @@ export class Session extends EventEmitter {
if (this._useMux && this._mux) {
try {
const { isRestored } = await this._setupOrAttachMuxSession({
respawnPaneOptions: {
sessionId: this.id,
workingDir: this.workingDir,
mode: this.mode,
niceConfig: this._niceConfig,
model: this._model,
claudeMode: this._claudeMode,
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
geminiConfig: this._geminiConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
owner: this._owner,
},
// Single source of truth shared with reattachRemote() (COD-108).
respawnPaneOptions: this._buildRespawnPaneOptions(),
createSessionOptions: {
sessionId: this.id,
workingDir: this.workingDir,
+34
View File
@@ -278,6 +278,9 @@ export class StateStore {
if (this.state.cronJobRuns) {
parts.push(`"cronJobRuns":${JSON.stringify(this.state.cronJobRuns)}`);
}
if (this.state.sessionOrder) {
parts.push(`"sessionOrder":${JSON.stringify(this.state.sessionOrder)}`);
}
return `{${parts.join(',')}}`;
}
@@ -485,6 +488,25 @@ export class StateStore {
this.save();
}
/**
* COD-142: Remove a session's persisted record on kill UNLESS it is pinned.
* A pinned session is demoted to a lightweight `stopped` record (pin retained)
* so it stays visible in the session-manager pinned group and survives restart.
* Unpinned sessions are fully removed (unchanged behavior).
* @returns 'preserved' if demoted to stopped+pinned, 'removed' if deleted, 'absent' if no record existed.
*/
demoteOrRemoveSession(id: string): 'preserved' | 'removed' | 'absent' {
const existing = this.state.sessions[id];
if (!existing) return 'absent';
if (existing.pinned === true) {
// Demote in place: keep identity/resume fields + pin, mark stopped, clear live runtime.
this.setSession(id, { ...existing, status: 'stopped', pid: null });
return 'preserved';
}
this.removeSession(id);
return 'removed';
}
/**
* Cleans up stale sessions from state that don't have corresponding active sessions.
* @param activeSessionIds - Set of currently active session IDs
@@ -499,6 +521,7 @@ export class StateStore {
for (const sessionId of allSessionIds) {
if (!activeSessionIds.has(sessionId)) {
if (this.state.sessions[sessionId]?.pinned === true) continue; // COD-142: pinned records persist even with no live session
const name = this.state.sessions[sessionId]?.name;
cleaned.push({ id: sessionId, name });
delete this.state.sessions[sessionId];
@@ -630,6 +653,17 @@ export class StateStore {
this.save();
}
/** Returns the global tab order (ordered sessionIds), [] if unset. COD-131. */
getSessionOrder(): string[] {
return this.state.sessionOrder ?? [];
}
/** Persists the global tab order (ordered sessionIds) and triggers a debounced save. COD-131. */
setSessionOrder(order: string[]): void {
this.state.sessionOrder = order;
this.save();
}
/** Resets all state to initial values and saves immediately. */
reset(): void {
this.state = createInitialState();
+280 -8
View File
@@ -78,6 +78,13 @@ import type {
RespawnPaneOptions,
PaneCaptureOptions,
} from './mux-interface.js';
import {
decideReconnect,
advanceBackoff,
freshReconnectState,
resetReconnectState,
type RemoteReconnectState,
} from './remote-reconnect.js';
// ============================================================================
// Timing Constants
@@ -110,6 +117,9 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
/** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000;
/** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */
const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000;
/** Stable cwd for tmux server/pane launch; actual session cwd is reached inside the pane. */
const TMUX_LAUNCH_CWD = '/tmp';
@@ -134,6 +144,20 @@ const IS_TEST_MODE = !!process.env.VITEST;
/** Path to persisted mux session metadata */
const MUX_SESSIONS_FILE = dataPath('mux-sessions.json');
/**
* COD-108 kill-switch: `remoteAutoReconnect` app setting (default ON). Read at
* call time (like headroom routing) so a settings change takes effect without a
* restart. Absent/non-boolean ⇒ true (feature on).
*/
function isRemoteAutoReconnectEnabled(): boolean {
try {
const s = JSON.parse(readFileSync(dataPath('settings.json'), 'utf8')) as Record<string, unknown>;
return typeof s.remoteAutoReconnect === 'boolean' ? s.remoteAutoReconnect : true;
} catch {
return true;
}
}
/** Regex to validate tmux session names (only allow safe characters) */
const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/;
@@ -812,6 +836,13 @@ export function buildRemoteLaunchCommand(options: {
`set -t ${remoteName} mouse off`,
`set -t ${remoteName} prefix C-q`,
'set -s escape-time 0',
// COD-106 — shared/collaborative sessions: tmux defaults to sizing a window
// to the SMALLEST attached client, so two Codemans at different viewports
// would fight (clamp to the smaller). `window-size latest` sizes to the
// most-recently-active client instead, so concurrent clients coexist.
// Per-session scoped (`set -t <name>`, matching #145's hardening) so a shared
// remote tmux server's other sessions keep their own sizing behavior.
`set -t ${remoteName} window-size latest`,
].join(' \\; ');
// ssh runs its trailing args through the remote login shell, so the entire
@@ -873,15 +904,15 @@ export function dockerTmuxSessionName(sessionId: string): string {
const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/;
/**
* Append the CLI-specific resume flag to a pane command. Only fires when the
* in-container tmux is RE-CREATED (`new-session -A` makes the flag inert on a
* live reattach), i.e. exactly when the previous live agent was lost and we want
* to resume the conversation from the bind-mounted transcript.
* Append the CLI-specific resume flag to a pane command (codex/gemini). Only fires
* when the in-container tmux is RE-CREATED (`new-session -A` makes the flag inert
* on a live reattach), i.e. exactly when the previous live agent was lost and we
* want to resume the conversation from the bind-mounted transcript. Claude mode
* uses claudeDockerPaneCommand instead.
*/
function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string {
if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand;
switch (mode) {
case 'claude':
case 'gemini':
return `${modeCommand} --resume ${resumeId}`;
case 'codex':
@@ -891,6 +922,30 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
}
}
/**
* Claude-mode pane command with a DETERMINISTIC conversation id (the docker analog
* of buildSpawnCommand's --resume/--session-id logic). A fresh launch passes
* `--session-id <sessionId>`, so the in-container conversation id is knowable
* host-side (resume-id capture + subagent/workflow correlation) WITHOUT relying on
* hook reachability. When the in-container tmux was re-created after a container
* stop/reboot, the same command re-runs against the surviving transcript:
* `--session-id` exits 1 ("already in use") and the `||` fallback RESUMES that
* conversation (verified CLI behavior). An explicit resumeId gets the local
* builder's shape — resume first, session-id fallback — so a stale id never
* dead-panes. The leading `exec ` is stripped: an exec'd first branch could never
* fall back.
*/
function claudeDockerPaneCommand(modeCommand: string, sessionId: string, resumeId?: string): string {
if (!RESUME_ID_SAFE.test(sessionId)) return modeCommand; // defensive — ids are server-minted uuids
const cmd = modeCommand.replace(/^exec\s+/, '');
const rid = resumeId && RESUME_ID_SAFE.test(resumeId) ? resumeId : undefined;
if (rid && rid !== sessionId) {
return `${cmd} --resume ${rid} || ${cmd} --session-id ${sessionId}`;
}
const cid = rid ?? sessionId;
return `${cmd} --session-id ${cid} || ${cmd} --resume ${cid}`;
}
/** Fully-resolved inputs for buildDockerLaunchCommand (pure). */
export interface DockerLaunchOptions {
mode: SessionMode;
@@ -930,7 +985,11 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
const sid = sessionId.slice(0, 8);
let modeCommand = docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode);
if (resumeSessionId) modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
if (mode === 'claude') {
modeCommand = claudeDockerPaneCommand(modeCommand, sessionId, resumeSessionId);
} else if (resumeSessionId) {
modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
}
// Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
// pane PID the agent itself.
const paneCommand = `cd ${workdir} && ${modeCommand}`;
@@ -1104,6 +1163,56 @@ export function resolveDockerLaunchOptions(
return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies };
}
/**
* COD-105 — build the SSH command that ATTACHES to an EXISTING `codeman-*` tmux
* session on the remote host (one this Codeman didn't create — discovered via
* `listRemoteCodemanSessions`). Sibling of `buildRemoteLaunchCommand`.
*
* Emits:
* ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
* 'tmux -L codeman attach -t <session>'
*
* - `attach` (NOT `new-session -A`) so we only join an existing session; the
* remote session keeps running independent of us, which is exactly why the
* resulting Codeman session is NON-OWNED (see `SessionRemote.owned`): closing
* the local tab must detach, never `kill-session` the remote.
* - The remote session name is shell-escaped so a value with metachars stays a
* single token inside the quoted tmux invocation.
* - COD-107 — connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
* arbitrary `-o`) come from the shared `buildSshConnectionArgs`, so attach
* connects identically to launch / discovery / the prereq probe. `-t` sits
* right after `ssh -o BatchMode=yes` (a PTY is required for interactive tmux).
*/
export function buildRemoteAttachCommand(remote: SessionRemote, remoteSessionName: string): string {
const tmuxInvocation = `tmux -L codeman attach -t ${shellescape(remoteSessionName)}`;
const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote);
const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)];
return sshParts.join(' ');
}
/**
* COD-105 — choose the right remote ssh command for a session's ownership:
* - NON-owned (`remote.owned === false`): ATTACH to a discovered remote tmux
* session by its EXISTING name (`remote.remoteSessionName`, falling back to
* this session's deterministic name). We only join — never create.
* - owned (default): LAUNCH/attach-or-create via `buildRemoteLaunchCommand`
* (COD-104), which we then own and may explicitly kill.
*/
function buildRemoteSessionCommand(options: {
mode: SessionMode;
remote: SessionRemote;
sessionId: string;
claudeMode?: ClaudeMode;
allowedTools?: string;
}): string {
const { remote, sessionId } = options;
if (remote.owned === false) {
const target = remote.remoteSessionName || remoteTmuxSessionName(sessionId);
return buildRemoteAttachCommand(remote, target);
}
return buildRemoteLaunchCommand(options);
}
/**
* Set sensitive environment variables on a tmux session via setenv.
* These are inherited by panes but not visible in ps output or tmux history.
@@ -1257,6 +1366,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
private lastPaneCount: Map<string, number> = new Map();
// ── COD-108 remote-reconnect watcher state ────────────────────────────────
/** Periodic watcher that re-establishes dropped remote sessions. */
private remoteReconnectInterval: NodeJS.Timeout | null = null;
/** Per-session backoff/attempt bookkeeping (sessionId → state). */
private reconnectState: Map<string, RemoteReconnectState> = new Map();
/**
* Sessions excluded from auto-reconnect because they are being intentionally
* torn down (killed/detached/stopping). A guarded session is NEVER revived.
*/
private reconnectGuard: Set<string> = new Set();
private trueColorConfigured = false;
constructor() {
@@ -1572,7 +1692,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const fullCmd = docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteLaunchCommand({ mode, remote, sessionId, claudeMode, allowedTools })
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
: localFullCmd;
// Create tmux session in three steps to handle cold-start (no server running)
@@ -1827,7 +1947,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const fullCmd = docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteLaunchCommand({ mode, remote, sessionId, claudeMode, allowedTools })
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
: localFullCmd;
try {
@@ -1943,9 +2063,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return false;
}
// COD-108: an intentional kill/detach must NEVER be auto-revived by the
// remote-reconnect watcher. Guard BEFORE any teardown so a tick that fires
// mid-kill (especially the non-owned DETACH early-return below, where the
// dead local pane would otherwise look reconnectable) sees the guard.
this.guardRemoteReconnect(sessionId);
// TEST MODE: Remove from memory only — NEVER touch real tmux sessions
if (IS_TEST_MODE) {
this.sessions.delete(sessionId);
this.clearRemoteReconnectState(sessionId);
this.emit('sessionKilled', { sessionId });
return true;
}
@@ -1957,6 +2084,40 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return false;
}
// COD-105 — DETACH-NOT-KILL for NON-owned remote sessions.
//
// When this session was created by ATTACHING a remote tmux session another
// Codeman owns (`remote.owned === false`), closing the tab must NOT propagate
// a remote `tmux kill-session` — that would nuke work the remote's own
// Codeman (or another instance) still relies on. We tear down ONLY the LOCAL
// pane that holds the ssh client: killing the local ssh sends SIGHUP to its
// remote `tmux attach`, which DETACHES (the durable remote session survives).
//
// This early return is the structural guarantee: no code below this point
// (now or in future for owned sessions) can ever issue a remote kill-session
// for a non-owned session. The only `kill-session` we run is on OUR LOCAL
// socket (`this.tmux()` = `tmux -L codeman` on THIS host), which kills the
// local pane — it does NOT reach the REMOTE socket.
if (session.remote && session.remote.owned === false) {
console.log(`[TmuxManager] DETACH (non-owned remote): tearing down local pane only for ${session.muxName}`);
if (isValidMuxName(session.muxName)) {
try {
// Local socket only — detaches the remote session by killing the local ssh pane.
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
// Local pane may already be gone.
}
}
this.lastPaneCount.delete(session.muxName);
this.sessions.delete(sessionId);
this.clearRemoteReconnectState(sessionId);
this.saveSessions();
this.emit('sessionKilled', { sessionId });
return true;
}
// Get current PID (may have changed)
const currentPid = this.getPanePid(session.muxName) || session.pid;
@@ -2061,6 +2222,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.lastPaneCount.delete(session.muxName);
this.sessions.delete(sessionId);
this.clearRemoteReconnectState(sessionId);
this.saveSessions();
this.emit('sessionKilled', { sessionId });
@@ -2127,6 +2289,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} else {
dead.push(sessionId);
this.sessions.delete(sessionId);
this.clearRemoteReconnectState(sessionId);
this.emit('sessionDied', { sessionId });
}
}
@@ -2398,9 +2561,118 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.lastPaneCount.clear();
}
// ── COD-108 remote-session auto-reconnect watcher ─────────────────────────
/**
* Start the remote-reconnect watcher (COD-108). Each tick, for every tracked
* session with `session.remote` whose local pane is DEAD, not intentionally
* guarded, and within its backoff budget, emit `remoteSessionDropped` so the
* session owner reattaches (re-running the idempotent remote command rejoins
* the durable remote tmux session). After the attempt cap, emit
* `remoteReconnectExhausted` once and go quiet.
*
* No-op tick body under `IS_TEST_MODE` (mirrors `startMouseModeSync`): tests
* drive the logic deterministically via {@link runRemoteReconnectTick}.
*/
startRemoteReconnectWatcher(intervalMs: number = DEFAULT_REMOTE_RECONNECT_INTERVAL_MS): void {
if (this.remoteReconnectInterval) {
clearInterval(this.remoteReconnectInterval);
}
this.remoteReconnectInterval = setInterval(() => {
if (IS_TEST_MODE) return;
try {
this.runRemoteReconnectTick(Date.now(), isRemoteAutoReconnectEnabled());
} catch (err) {
console.error('[TmuxManager] Remote reconnect watcher error:', err);
}
}, intervalMs);
}
stopRemoteReconnectWatcher(): void {
if (this.remoteReconnectInterval) {
clearInterval(this.remoteReconnectInterval);
this.remoteReconnectInterval = null;
}
}
/**
* Run ONE watcher tick. Extracted (and given an injected `now`/`enabled`) so
* the reconnect logic is deterministically testable even though the live
* `setInterval` body no-ops under test mode. For each remote session it
* applies the pure {@link decideReconnect} decision and translates the result
* into events + backoff/state transitions. Public for tests + the watcher.
*/
runRemoteReconnectTick(now: number, enabled: boolean): void {
for (const session of this.sessions.values()) {
if (!session.remote) continue;
const sessionId = session.sessionId;
const state = this.reconnectState.get(sessionId);
const action = decideReconnect({
session: {
sessionId,
isRemote: true,
paneDead: this.isPaneDead(session.muxName),
},
state,
guarded: this.reconnectGuard.has(sessionId),
enabled,
now,
});
if (action.kind === 'emit') {
const base = state ?? freshReconnectState();
// Mark in-flight + advance backoff BEFORE emitting so a re-entrant tick
// (or a synchronous listener) can never stack a second reconnect.
this.reconnectState.set(sessionId, { ...advanceBackoff(base, now), inFlight: true });
this.emit('remoteSessionDropped', { sessionId, attempt: action.attempt });
} else if (action.kind === 'exhaust') {
const base = state ?? freshReconnectState();
if (!base.exhaustedEmitted) {
this.reconnectState.set(sessionId, { ...base, exhausted: true, exhaustedEmitted: true });
this.emit('remoteReconnectExhausted', { sessionId });
}
}
// 'skip' → nothing to do.
}
}
/**
* Tell the watcher a reattach attempt for `sessionId` finished. On success,
* reset the backoff so the session is healthy again; on failure, just clear
* the in-flight flag so the next due tick can retry under the existing
* backoff schedule. Called by the session owner after `respawnPane`.
*/
noteRemoteReconnect(sessionId: string, success: boolean): void {
if (success) {
this.reconnectState.set(sessionId, resetReconnectState());
return;
}
const state = this.reconnectState.get(sessionId);
if (state) this.reconnectState.set(sessionId, { ...state, inFlight: false });
}
/**
* Exclude a session from auto-reconnect (intentional teardown). Adds it to the
* guard set and drops any backoff state so a closed/killed tab — especially a
* non-owned remote DETACH — is never auto-revived. Idempotent.
*/
guardRemoteReconnect(sessionId: string): void {
this.reconnectGuard.add(sessionId);
this.reconnectState.delete(sessionId);
}
/** Clear all per-session reconnect + guard state (e.g. when a session is removed). */
clearRemoteReconnectState(sessionId: string): void {
this.reconnectState.delete(sessionId);
this.reconnectGuard.delete(sessionId);
}
destroy(): void {
this.stopStatsCollection();
this.stopMouseModeSync();
this.stopRemoteReconnectWatcher();
this.reconnectState.clear();
this.reconnectGuard.clear();
}
registerSession(session: MuxSession): void {
+2
View File
@@ -116,6 +116,8 @@ export interface AppState {
cronJobs?: Record<string, CronJob>;
/** Scheduled job run history, keyed by run ID. */
cronJobRuns?: Record<string, CronJobRun>;
/** Global tab order shared across devices (ordered list of sessionIds) — COD-131 */
sessionOrder?: string[];
}
// ========== Default Configuration ==========
+44 -1
View File
@@ -9,7 +9,7 @@
* - SessionOutput — captured stdout/stderr/exitCode
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend)
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools')
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId)
@@ -99,6 +99,45 @@ export interface SessionRemote extends RemoteSshOptions {
port?: number;
remotePath: string;
commands?: Partial<Record<RemoteCommandMode, string>>;
/**
* COD-105 — whether THIS Codeman created the remote tmux session.
*
* - `true` (default for COD-104 launched sessions): we own the remote session;
* an explicit "kill" may propagate a remote `tmux kill-session`.
* - `false` (discovered + attached an existing remote session another Codeman
* created): closing the local tab must DETACH only — we must NEVER issue a
* remote `kill-session`, or we'd nuke work the remote's own Codeman (or
* another instance) still relies on. See `killSession()` gate.
*
* Absent is treated as owned (legacy/COD-104 sessions persisted before this
* field existed were all launched by us).
*/
owned?: boolean;
/**
* COD-105 — for a NON-owned (discovered + attached) session, the EXISTING
* remote tmux session name to `attach -t` (e.g. `codeman-disco1`). It differs
* from this Codeman's deterministic `codeman-<id>` name because the remote
* session was created elsewhere. Only meaningful when `owned === false`.
*/
remoteSessionName?: string;
}
/**
* COD-105 — a `codeman-*` tmux session discovered on a remote host's
* `tmux -L codeman` socket (may have been created by the remote's own Codeman,
* another instance, or this one). Returned by `listRemoteCodemanSessions`.
*/
export interface RemoteSessionInfo {
/** tmux session name (always starts `codeman-`). */
name: string;
/** Whether at least one client is currently attached to the remote session. */
attached: boolean;
/** COD-106 — number of clients attached (tmux `session_attached`); >1 = shared. */
attachedClients: number;
/** tmux `session_created` epoch seconds. */
created: number;
/** Number of windows in the remote session. */
windows: number;
}
// ========== Docker cases (COD-Docker) ==========
@@ -366,6 +405,10 @@ export interface SessionState {
autoResumeEnabled?: boolean;
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
autoResumeAt?: number;
/** Pinned to the top of the session manager list (COD-139) */
pinned?: boolean;
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
pinnedAt?: number;
/** Image watcher enabled for this session */
imageWatcherEnabled?: boolean;
/** Total cost in USD */
+1 -1
View File
@@ -60,7 +60,7 @@ export function stripAnsi(text: string): string {
*/
export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/;
export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_/\-. ~]+$/;
export const SAFE_PATH_PATTERN = /^[\p{L}\p{N}_/\-. ~]+$/u;
/**
* Execute a global regex pattern against data, calling the callback for each match.
+303 -6
View File
@@ -1,8 +1,12 @@
/**
* @fileoverview Multi-user frontend: identity boot, admin Users panel, and the
* change-password flow. Self-contained (builds its own DOM) so it needs no
* index.html surgery beyond the script tag and integrates with the existing App
* Settings modal by injecting a "Users" tab (admins in multi-user mode only).
* @fileoverview Multi-user frontend: identity boot, admin Users panel, the
* change-password flow, and the full Admin Panel modal (user CRUD, per-user
* permissions, case-folder management) opened by the header Admin Panel button
* (#adminPanelBtn, revealed for admins in multi-user mode). Self-contained
* (builds its own DOM) so it needs no index.html surgery beyond the script tag
* and button; integrates with the existing App Settings modal by injecting a
* "Users" tab (admins in multi-user mode only). Live-refreshes on the SSE
* admin:usersChanged event (wired in app.js → window.codemanAdmin.onUsersChanged).
*
* @dependency app.js (window.app), settings-ui.js (App Settings modal + tab switch)
* @loadorder after settings-ui.js / ultracode-panel.js, before session-ui.js
@@ -123,7 +127,10 @@
content.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong>Users</strong>
<button class="btn btn-sm" id="adminAddUser">+ Add user</button>
<span>
<button class="btn btn-sm" id="adminOpenPanel">Open Admin Panel</button>
<button class="btn btn-sm" id="adminAddUser">+ Add user</button>
</span>
</div>
<p class="form-hint">Users share the host account; this separates workspaces, it does not sandbox
users from each other. Pair with Docker cases for isolation.</p>
@@ -133,6 +140,7 @@
// Render whenever the tab is shown (the shared switchSettingsTab toggles it).
btn.addEventListener('click', renderUsers);
content.querySelector('#adminAddUser').onclick = addUserFlow;
content.querySelector('#adminOpenPanel').onclick = openAdminPanel;
}
function esc(s) {
@@ -233,6 +241,292 @@
renderUsers();
}
// ── Admin Panel (big header-button modal) ─────────────────────────────────
let apModal = null;
let apUsersCache = [];
const apOpenDrawers = new Set(); // usernames with an expanded case-folder drawer
function fmtDate(ts) {
return ts ? new Date(ts).toLocaleString() : 'never';
}
function cssEsc(s) {
return window.CSS && window.CSS.escape ? window.CSS.escape(s) : String(s).replace(/"/g, '\\"');
}
function apSetMsg(t) {
const m = document.getElementById('apMsg');
if (m) m.textContent = t || '';
}
function buildAdminPanel() {
if (apModal) return apModal;
const el = document.createElement('div');
el.className = 'modal';
el.id = 'adminPanelModal';
el.style.zIndex = '3000';
el.innerHTML = `
<div class="modal-content" style="max-width:940px;width:min(96vw,940px)">
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;gap:12px">
<h2 style="display:flex;align-items:center;gap:8px;margin:0">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
Admin Panel</h2>
<span id="apIdentity" style="color:var(--text-muted,#888);font-size:.85em"></span>
</div>
<div class="modal-body" style="max-height:70vh;overflow-y:auto">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong>Users</strong>
<button class="btn btn-sm btn-primary" id="apAddToggle">+ Add user</button>
</div>
<div id="apAddForm" style="display:none;border:1px solid var(--border,#333);border-radius:8px;padding:10px;margin-bottom:10px">
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
<div class="form-row" style="margin:0"><label>Username</label>
<input id="apNewName" class="form-input" placeholder="lowercase a-z 0-9 _ -" style="width:170px"></div>
<div class="form-row" style="margin:0"><label>Role</label>
<select id="apNewRole" class="form-input" style="width:110px">
<option value="user">user</option>
<option value="admin">admin</option>
</select></div>
<div class="form-row" style="margin:0"><label>Password (optional)</label>
<input id="apNewPw" type="password" class="form-input" placeholder="blank = one-time pw"
style="width:170px" autocomplete="new-password"></div>
<label style="display:flex;align-items:center;gap:5px;white-space:nowrap;margin-bottom:6px">
<input type="checkbox" id="apNewBypass"> allow bypass permissions</label>
<button class="btn btn-sm btn-primary" id="apCreateUser" style="margin-bottom:2px">Create</button>
</div>
<p class="form-hint" style="margin:6px 0 0">Without a password a one-time password is generated and shown
once; the user must change it on first login. "Bypass" allows shell sessions, cron launch commands, and
skip-permissions agents.</p>
</div>
<div id="apOtp" style="display:none;border:1px solid var(--accent,#38b6f0);border-radius:8px;padding:10px;margin-bottom:10px"></div>
<div id="apTable">Loading…</div>
<p class="form-hint" style="margin-top:10px">Users share the host OS account: this separates workspaces, it
does not sandbox users from each other. Pair with Docker cases for isolation.</p>
<p id="apMsg" style="min-height:1.2em;color:var(--text-muted,#888)"></p>
</div>
<div class="modal-footer">
<button class="btn" id="apClose">Close</button>
</div>
</div>`;
document.body.appendChild(el);
el.querySelector('#apClose').onclick = () => (el.style.display = 'none');
el.addEventListener('click', (e) => {
if (e.target === el) el.style.display = 'none';
});
el.querySelector('#apAddToggle').onclick = () => {
const f = el.querySelector('#apAddForm');
f.style.display = f.style.display === 'none' ? '' : 'none';
if (f.style.display === '') f.querySelector('#apNewName').focus();
};
el.querySelector('#apCreateUser').onclick = createUserFromForm;
apModal = el;
return el;
}
function showOneTimePassword(username, otp) {
const box = document.getElementById('apOtp');
if (!box) return;
box.style.display = '';
box.innerHTML = `One-time password for <strong>${esc(username)}</strong> (shown once, copy it now):
<code style="user-select:all;font-size:1.05em;margin:0 8px">${esc(otp)}</code>
<button class="btn btn-xs" id="apOtpCopy">Copy</button>
<button class="btn btn-xs" id="apOtpDismiss">Dismiss</button>`;
box.querySelector('#apOtpCopy').onclick = () => {
if (navigator.clipboard) {
navigator.clipboard.writeText(otp).then(() => apSetMsg('Password copied to clipboard.'));
}
};
box.querySelector('#apOtpDismiss').onclick = () => {
box.style.display = 'none';
box.innerHTML = '';
};
}
async function createUserFromForm() {
const name = (document.getElementById('apNewName').value || '').trim().toLowerCase();
const role = document.getElementById('apNewRole').value;
const pw = document.getElementById('apNewPw').value;
const bypass = document.getElementById('apNewBypass').checked;
if (!name) return apSetMsg('Enter a username.');
const body = { username: name, role };
if (pw) body.password = pw;
if (bypass) body.canBypassPermissions = true;
const r = await apiSend('POST', '/api/admin/users', body);
if (!r.ok) return apSetMsg((r.body && r.body.error) || 'Create failed.');
document.getElementById('apNewName').value = '';
document.getElementById('apNewPw').value = '';
document.getElementById('apNewBypass').checked = false;
apSetMsg(`Created ${name}.`);
if (r.data && r.data.oneTimePassword) showOneTimePassword(name, r.data.oneTimePassword);
renderPanel();
}
async function renderPanel() {
const table = document.getElementById('apTable');
if (!table) return;
let users;
try {
users = await apiGet('/api/admin/users');
} catch {
table.innerHTML = 'Failed to load users.';
return;
}
apUsersCache = users;
const meName = (window.__codemanUser || {}).username;
const rows = users
.map((u) => {
const st = u.stats || {};
const you = u.username === meName ? ' <span style="color:var(--accent,#38b6f0)">(you)</span>' : '';
const role = `<span style="font-weight:600;color:${
u.role === 'admin' ? 'var(--accent,#38b6f0)' : 'var(--text-muted,#888)'
}">${u.role}</span>`;
const status = u.disabled
? '<span style="color:var(--red,#c33)">disabled</span>'
: '<span style="color:var(--accent-soft,#4b9)">enabled</span>';
const pwFlag = u.mustChangePassword ? ' · must-change-pw' : '';
return `<tr data-u="${esc(u.username)}">
<td><strong>${esc(u.username)}</strong>${you}</td>
<td>${role}</td>
<td>${status}${pwFlag}</td>
<td>${u.canBypassPermissions ? 'yes' : 'no'}</td>
<td style="white-space:nowrap">${st.liveSessions ?? 0} live · ${st.activeSessions ?? 0} logins ·
<button class="btn btn-xs" data-act="cases">${st.caseCount ?? 0} cases</button></td>
<td style="font-size:.85em;color:var(--text-muted,#888)">${fmtDate(u.lastLoginAt)}</td>
<td style="white-space:nowrap">
<button class="btn btn-xs" data-act="role">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
<button class="btn btn-xs" data-act="disabled">${u.disabled ? 'Enable' : 'Disable'}</button>
<button class="btn btn-xs" data-act="bypass">${u.canBypassPermissions ? 'Revoke bypass' : 'Grant bypass'}</button>
<button class="btn btn-xs" data-act="reset">Reset pw</button>
<button class="btn btn-xs" data-act="logout">Logout</button>
<button class="btn btn-xs" data-act="delete" style="color:var(--red,#c33)">Delete</button>
</td></tr>
<tr data-drawer="${esc(u.username)}" style="display:none"><td colspan="7"></td></tr>`;
})
.join('');
table.innerHTML = `<table style="width:100%;border-collapse:collapse" class="admin-users">
<thead><tr>
<th align="left">User</th><th align="left">Role</th><th align="left">Status</th>
<th align="left">Bypass</th><th align="left">Activity</th><th align="left">Last login</th><th></th>
</tr></thead><tbody>${rows}</tbody></table>`;
table.querySelectorAll('button[data-act]').forEach((b) => {
const username = b.closest('tr').dataset.u;
b.onclick = () => {
if (b.dataset.act === 'cases') return toggleCaseDrawer(username);
return panelAction(
username,
b.dataset.act,
apUsersCache.find((x) => x.username === username)
);
};
});
// Re-open drawers that were expanded before this refresh.
for (const name of [...apOpenDrawers]) {
if (users.some((u) => u.username === name)) void renderCaseDrawer(name);
else apOpenDrawers.delete(name);
}
}
async function panelAction(username, act, u) {
const path = `/api/admin/users/${encodeURIComponent(username)}`;
if (act === 'role') {
const r = await apiSend('PATCH', path, { role: u.role === 'admin' ? 'user' : 'admin' });
apSetMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
} else if (act === 'disabled') {
const r = await apiSend('PATCH', path, { disabled: !u.disabled });
apSetMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
} else if (act === 'bypass') {
const r = await apiSend('PATCH', path, { canBypassPermissions: !u.canBypassPermissions });
apSetMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
} else if (act === 'reset') {
if (!window.confirm(`Reset ${username}'s password? They must set a new one on next login.`)) return;
const r = await apiSend('POST', `${path}/reset-password`);
if (r.ok && r.data && r.data.oneTimePassword) showOneTimePassword(username, r.data.oneTimePassword);
else if (!r.ok) apSetMsg((r.body && r.body.error) || 'Reset failed.');
} else if (act === 'logout') {
const r = await apiSend('POST', `${path}/logout`);
apSetMsg(r.ok ? `Revoked ${(r.data && r.data.revoked) || 0} login session(s) for ${username}.` : 'Failed.');
} else if (act === 'delete') {
if (!window.confirm(`Delete user "${username}"? Their live sessions are killed and logins revoked.`)) return;
const deleteSpace = window.confirm(
`Also delete ${username}'s files (their cases/workspace folder)?\nOK = delete files too, Cancel = keep files on disk.`
);
const r = await apiSend('DELETE', path, { deleteSpace });
apSetMsg(r.ok ? `Deleted ${username}.` : (r.body && r.body.error) || 'Delete failed.');
}
renderPanel();
}
async function toggleCaseDrawer(username) {
if (apOpenDrawers.has(username)) {
apOpenDrawers.delete(username);
const row = apModal && apModal.querySelector(`tr[data-drawer="${cssEsc(username)}"]`);
if (row) row.style.display = 'none';
return;
}
apOpenDrawers.add(username);
await renderCaseDrawer(username);
}
async function renderCaseDrawer(username) {
const row = apModal && apModal.querySelector(`tr[data-drawer="${cssEsc(username)}"]`);
if (!row) return;
row.style.display = '';
const cell = row.firstElementChild;
cell.innerHTML = 'Loading folders…';
let data;
try {
data = await apiGet(`/api/admin/users/${encodeURIComponent(username)}/cases`);
} catch {
cell.innerHTML = 'Failed to load case folders.';
return;
}
const items = (data.cases || [])
.map(
(c) => `
<li style="display:flex;gap:10px;align-items:center;padding:2px 0">
<code>${esc(c.name)}</code>
<span style="color:var(--text-muted,#888);font-size:.85em">${fmtDate(c.modifiedAt)}</span>
${c.liveSessions ? `<span style="color:var(--yellow,#ca0)">${c.liveSessions} live session(s)</span>` : ''}
<button class="btn btn-xs" data-case="${esc(c.name)}"
${c.liveSessions ? 'disabled title="In use by a live session"' : ''}>Delete</button>
</li>`
)
.join('');
cell.innerHTML = `<div style="padding:6px 4px 6px 16px">
<div style="color:var(--text-muted,#888);font-size:.85em;margin-bottom:4px">${esc(data.dir || '')}</div>
${items ? `<ul style="list-style:none;margin:0;padding:0">${items}</ul>` : 'No case folders yet.'}
</div>`;
cell.querySelectorAll('button[data-case]').forEach((b) => {
b.onclick = async () => {
const name = b.dataset.case;
if (!window.confirm(`Permanently delete ${username}'s case folder "${name}" and ALL files in it?`)) return;
const r = await apiSend(
'DELETE',
`/api/admin/users/${encodeURIComponent(username)}/cases/${encodeURIComponent(name)}`
);
apSetMsg(r.ok ? `Deleted folder ${name}.` : (r.body && r.body.error) || 'Delete failed.');
renderPanel();
};
});
}
function openAdminPanel() {
const me = window.__codemanUser || {};
if (!me.multiUser || me.role !== 'admin') return;
const el = buildAdminPanel();
el.querySelector('#apIdentity').textContent = `signed in as ${me.username} (admin)`;
apSetMsg('');
el.style.display = 'flex';
renderPanel();
}
/** SSE admin:usersChanged: live-refresh whichever admin views are visible. */
function onUsersChanged() {
if (apModal && apModal.style.display === 'flex') renderPanel();
const tab = document.getElementById('settings-users');
if (tab && !tab.classList.contains('hidden')) renderUsers();
}
// ── Boot ──────────────────────────────────────────────────────────────────
async function boot() {
installInterceptor();
@@ -247,6 +541,9 @@
if (window.__codemanUser.mustChangePassword) openChangePassword(true);
if (window.__codemanUser.multiUser && window.__codemanUser.role === 'admin') {
injectUsersTab();
// Reveal the big header Admin Panel button (template ships it hidden).
const btn = document.getElementById('adminPanelBtn');
if (btn) btn.classList.remove('btn-admin-panel--hidden');
}
}
@@ -256,5 +553,5 @@
boot();
}
window.codemanAdmin = { openChangePassword, renderUsers };
window.codemanAdmin = { openChangePassword, renderUsers, openAdminPanel, onUsersChanged };
})();
+96 -4
View File
@@ -216,6 +216,10 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.MUX_DIED, '_onMuxDied'],
[SSE_EVENTS.MUX_STATS_UPDATED, '_onMuxStatsUpdated'],
// Remote auto-reconnect (COD-108)
[SSE_EVENTS.REMOTE_SESSION_RECONNECTED, '_onRemoteSessionReconnected'],
[SSE_EVENTS.REMOTE_RECONNECT_EXHAUSTED, '_onRemoteReconnectExhausted'],
// Ralph
[SSE_EVENTS.SESSION_RALPH_LOOP_UPDATE, '_onRalphLoopUpdate'],
[SSE_EVENTS.SESSION_RALPH_TODO_UPDATE, '_onRalphTodoUpdate'],
@@ -287,6 +291,9 @@ const _SSE_HANDLER_MAP = [
// Clipboard
[SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'],
// Session order (global tab order sync, COD-131)
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
];
@@ -787,6 +794,7 @@ class CodemanApp {
this.applyHeaderVisibilitySettings();
this.restorePlanUsageChip();
this.applySkin();
this.applyLocalization();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
// Remove mobile-init class now that JS has applied visibility settings.
@@ -845,6 +853,7 @@ class CodemanApp {
this.loadAppSettingsFromServer(settingsPromise).then(() => {
this.applyHeaderVisibilitySettings();
this.applySkin();
this.applyLocalization();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
// ultracodeFloatingWindows syncs from the server (non-display key), but on a
@@ -1300,7 +1309,7 @@ class CodemanApp {
if (titleEl) { titleEl.textContent = name; titleEl.style.display = ''; }
const redock = document.getElementById('soloRedockBtn');
if (redock) redock.style.display = '';
document.title = name + ' — Codeman';
document.title = name + ' — ' + (window.CodemanI18n?.displayName || 'Codeman');
if (this.notificationManager) this.notificationManager.originalTitle = document.title;
// Neutralize the dashboard-only brand click in a solo window.
const logo = document.querySelector('.header-brand .logo');
@@ -1318,7 +1327,8 @@ class CodemanApp {
+ '<p>This session has ended or is no longer available.</p>'
+ '<button class="btn-primary" onclick="window.close()">Close window</button>';
document.body.appendChild(el);
document.title = 'Session ended — Codeman';
document.title = (window.codemanT?.('Session ended') || 'Session ended')
+ ' — ' + (window.CodemanI18n?.displayName || 'Codeman');
}
connectSSE() {
@@ -1452,6 +1462,30 @@ class CodemanApp {
console.error('[SSE] docker export failed:', err);
}
});
// Import + drift-recreate completions: refresh case lists in EVERY open tab
// (the initiating tab already refreshes via its own fetch response).
addListener(SSE_EVENTS.DOCKER_IMPORT_COMPLETE, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
this.showToast(`Docker bundle imported as case "${d.name}"`, 'success');
this.loadQuickStartCases?.();
this.refreshDockerExports?.();
} catch (err) {
console.error('[SSE] docker import complete:', err);
}
});
addListener(SSE_EVENTS.DOCKER_CONTAINER_RECREATED, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
this.showToast(`Container for "${d.name}" removed — next launch recreates it with the new config`, 'info');
} catch (err) {
console.error('[SSE] docker container recreated:', err);
}
});
// Multi-user admin: live-refresh whichever admin views (panel/Users tab) are open.
addListener(SSE_EVENTS.ADMIN_USERS_CHANGED, () => {
window.codemanAdmin?.onUsersChanged?.();
});
// Base image auto-build on first Docker case (build-on-first-use). A single
// multi-minute event; surface start/finish so the Run spinner is explained.
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_STARTED, () => {
@@ -1476,6 +1510,26 @@ class CodemanApp {
console.error('[SSE] docker image build failed:', err);
}
});
// COD-139: a session:pinned event updates the local live-session pin flag (so
// a subsequent render is consistent) and re-sorts the open session manager /
// welcome list so pinned sessions float to the top.
addListener(SSE_EVENTS.SESSION_PINNED, (e) => {
let data = null;
try {
data = JSON.parse(e.data);
} catch {
/* ignore malformed payload */
}
if (data && data.id) {
const live = this.sessions.get(data.id);
if (live) {
live.pinned = data.pinned === true;
live.pinnedAt = data.pinned ? data.pinnedAt : undefined;
}
}
this._onSessionListMaybeChanged();
});
}
// ═══════════════════════════════════════════════════════════════
@@ -1868,7 +1922,9 @@ class CodemanApp {
body.innerHTML = this._renderMarkdown(lastResponse);
this._bindResponseViewerInteractions(body);
} else {
body.textContent = 'No response yet — send a message in this session first.';
body.textContent =
window.codemanT?.('No response yet — send a message in this session first.') ||
'No response yet — send a message in this session first.';
}
// Reset state for fresh open
@@ -2999,6 +3055,13 @@ class CodemanApp {
// on another device).
try { localStorage.removeItem('codeman-tab-meta'); } catch {}
// COD-131: server is authoritative for global tab order. Seed localStorage
// from the server snapshot (if present) so syncSessionOrder() reconciles
// against the cross-device order rather than this device's stale local copy.
if (Array.isArray(data.sessionOrder) && data.sessionOrder.length) {
try { localStorage.setItem('codeman-session-order', JSON.stringify(data.sessionOrder)); } catch {}
}
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
this.syncSessionOrder();
@@ -3563,13 +3626,42 @@ class CodemanApp {
}
}
// Save session order to localStorage
// Save session order to localStorage and (debounced) sync to the server so it
// follows the user across devices (COD-131). localStorage stays the offline
// fallback; the server is authoritative and echoes back via SSE.
saveSessionOrder() {
try {
localStorage.setItem('codeman-session-order', JSON.stringify(this.sessionOrder));
} catch {
// Ignore storage errors
}
const order = [...this.sessionOrder];
this._debouncedCall('saveSessionOrderServer', () => {
fetch('/api/session-order', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order }),
}).catch(() => {});
}, 400);
}
// COD-131: another device (or our own debounced push) reordered tabs. Adopt the
// server order as the new base and reconcile to our currently-open sessions.
// Guard against no-op churn so an echo of our own push doesn't flicker the tabs.
_onSessionOrderChanged(data) {
if (!data || !Array.isArray(data.order)) return;
try {
localStorage.setItem('codeman-session-order', JSON.stringify(data.order));
} catch {
// Ignore storage errors
}
const before = JSON.stringify(this.sessionOrder);
this.syncSessionOrder();
// Only re-render when the reconciled order actually changed (avoids flicker
// when the broadcast is just an echo of the order we already have).
if (JSON.stringify(this.sessionOrder) !== before) {
this._fullRenderSessionTabs();
}
}
// Set up drag-and-drop handlers on tab elements
+10
View File
@@ -322,6 +322,7 @@ const SSE_EVENTS = {
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
SESSION_RESPAWN_BREAKER_TRIPPED: 'session:respawnBreakerTripped',
SESSION_CLI_INFO: 'session:cliInfo',
SESSION_PINNED: 'session:pinned',
SESSION_MESSAGE: 'session:message',
SESSION_INTERACTIVE: 'session:interactive',
SESSION_RUNNING: 'session:running',
@@ -379,6 +380,11 @@ const SSE_EVENTS = {
MUX_DIED: 'mux:died',
MUX_STATS_UPDATED: 'mux:statsUpdated',
// Remote auto-reconnect (COD-108)
REMOTE_SESSION_DROPPED: 'remote:sessionDropped',
REMOTE_SESSION_RECONNECTED: 'remote:sessionReconnected',
REMOTE_RECONNECT_EXHAUSTED: 'remote:reconnectExhausted',
// Ralph
SESSION_RALPH_LOOP_UPDATE: 'session:ralphLoopUpdate',
SESSION_RALPH_TODO_UPDATE: 'session:ralphTodoUpdate',
@@ -484,6 +490,10 @@ const SSE_EVENTS = {
// Multi-user (admin-only / targeted)
ADMIN_USERS_CHANGED: 'admin:usersChanged',
AUTH_PASSWORD_CHANGE_REQUIRED: 'auth:passwordChangeRequired',
DOCKER_CONTAINER_RECREATED: 'docker:containerRecreated',
// Session order (global tab order sync)
SESSION_ORDER_CHANGED: 'session:orderChanged',
};
// ═══════════════════════════════════════════════════════════════
+957
View File
@@ -0,0 +1,957 @@
/**
* @fileoverview Dependency-free browser localization and user-facing branding.
*
* English remains the canonical source language. The translator covers the static
* application shell plus DOM content inserted later by the plain-JS UI modules.
* It deliberately skips terminal/file/response/user-name surfaces so user content
* is never mistaken for application copy. Missing entries fall back to English.
*
* @dependency none (loads after constants.js, before all UI modules)
* @loadorder 1.5 of 16
*/
(function initCodemanI18n(global) {
'use strict';
const DEFAULT_NAME = 'Codeman';
const SUPPORTED_LANGUAGES = new Set(['en', 'zh-CN']);
const TRANSLATABLE_ATTRIBUTES = ['title', 'aria-label', 'placeholder'];
const SKIP_SELECTOR = [
'[data-i18n-skip]',
'.xterm',
'.terminal-container',
'.terminal-output',
'.response-content',
'.response-viewer-content',
'.file-preview-content',
'.session-tab-name',
'.session-name',
'.case-name',
'.notif-item-message',
'pre',
'code',
'script',
'style',
'textarea',
].join(',');
const USER_TEXT_SELECTOR = [
'.history-item-title',
'.history-item-subtitle',
'.history-detail-prompt',
'.history-detail-path',
'.folder-history-subtitle',
].join(',');
// Exact English-source translations. Technical names, command examples, model
// names, keyboard chords, and user-authored content intentionally stay unchanged.
const ZH_CN = Object.freeze({
'Skip to terminal': '跳转到终端',
'Go to main page': '返回主页',
'Session tabs': '会话标签页',
'Admin Panel': '管理面板',
'Open admin panel': '打开管理面板',
'Re-dock to dashboard (close window)': '重新停靠到主界面(关闭窗口)',
'Tunnel status': '隧道状态',
'Decrease font size': '减小字体',
'Increase font size': '增大字体',
'Current font size': '当前字体大小',
'System resource usage': '系统资源使用情况',
'Redraw terminal': '重绘终端',
'Redraw terminal to fit current screen (Ctrl+Shift+R)': '重绘终端以适应当前屏幕(Ctrl+Shift+R)',
'View last response': '查看最近一次回复',
'Away Digest': '离开期间摘要',
'Open away digest': '打开离开期间摘要',
'Session Manager': '会话管理器',
'Session actions': '会话操作',
'Open session manager': '打开会话管理器',
Attachments: '附件',
'Open attachment history': '打开附件历史',
'File Viewer': '文件查看器',
'Open file viewer': '打开文件查看器',
'Open Codeman across all displays': '在所有显示器上打开 {name}',
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
Notifications: '通知',
'Toggle notifications': '切换通知面板',
'Session Lifecycle Log': '会话生命周期日志',
'Open session lifecycle log': '打开会话生命周期日志',
'App Settings': '应用设置',
'Open app settings': '打开应用设置',
'Total tokens across all sessions': '所有会话的 Token 总数',
'Token usage across active sessions': '活动会话的 Token 使用量',
'Instance count': '实例数量',
'No response yet': '暂无回复',
'No response yet — send a message in this session first.': '暂无回复,请先在此会话中发送一条消息。',
'Last Response': '最近一次回复',
More: '更多',
'Codeman version': '{name}版本',
Stop: '停止',
Watching: '监视中',
Orchestrator: '编排器',
Close: '关闭',
'Close window': '关闭窗口',
'Session unavailable': '会话不可用',
'This session has ended or is no longer available.': '此会话已结束或不再可用。',
// Welcome / quick start / common actions
'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。',
'Select case': '选择案例',
'Select Case': '选择案例',
'All cases': '全部案例',
'No directory': '未选择目录',
Run: '运行',
'Run Claude Code': '运行 Claude Code',
'Run OpenCode': '运行 OpenCode',
'Run Gemini': '运行 Gemini',
'Run Shell': '运行 Shell',
'Select AI backend': '选择 AI 后端',
'Create New Case': '新建案例',
'Create new case': '新建案例',
'Link Existing': '关联现有目录',
'Add Case': '添加案例',
'Open sessions': '打开会话',
'Recent Sessions': '最近会话',
'Search sessions by name, prompt, or path…': '按名称、提示词或路径搜索会话…',
'Search open sessions or start a new one': '搜索已打开会话或启动新会话',
'Find Open Session': '查找已打开会话',
'No background agents': '没有后台智能体',
'No background agents detected': '未检测到后台智能体',
'No notifications': '没有通知',
'No mux sessions': '没有 mux 会话',
'No lifecycle entries found': '未找到生命周期记录',
'No ultracode runs detected': '未检测到 Ultracode 运行',
// Global/common controls
Display: '显示',
'Claude CLI': 'Claude CLI',
'Codex CLI': 'Codex CLI',
Models: '模型',
Shortcuts: '快捷键',
Voice: '语音',
Save: '保存',
Cancel: '取消',
Apply: '应用',
Create: '创建',
Add: '添加',
Delete: '删除',
Remove: '移除',
Edit: '编辑',
Refresh: '刷新',
Back: '返回',
Next: '下一步',
Previous: '上一步',
Clear: '清除',
'Clear all': '全部清除',
'Clear All': '全部清除',
Search: '搜索',
Filter: '筛选',
Enable: '启用',
Enabled: '已启用',
Disabled: '已禁用',
Active: '活动',
'Not active': '未活动',
On: '开',
Off: '关',
Yes: '是',
No: '否',
Optional: '可选',
Default: '默认',
Custom: '自定义',
Name: '名称',
Description: '描述',
Status: '状态',
Reason: '原因',
Time: '时间',
Event: '事件',
Events: '事件',
Session: '会话',
Sessions: '会话',
Files: '文件',
History: '历史',
Summary: '摘要',
Details: '详情',
Options: '选项',
Settings: '设置',
Help: '帮助',
Loading: '正在加载',
Error: '错误',
Errors: '错误',
Warning: '警告',
Warnings: '警告',
Info: '信息',
Complete: '完成',
Completed: '已完成',
Stopped: '已停止',
Running: '运行中',
Idle: '空闲',
Working: '工作中',
Today: '今天',
Home: '主页',
Local: '本地',
Remote: '远程',
Docker: 'Docker',
Terminal: '终端',
Prompt: '提示词',
Source: '来源',
Type: '类型',
Language: '语言',
// Display settings
'Branding & Language': '品牌与语言',
'Display Name': '显示名称',
'Interface Language': '界面语言',
'Name shown in the browser UI and window title. Supports Unicode, including Chinese.':
'显示在浏览器界面和窗口标题中的名称。支持 Unicode,包括中文。',
'Language for this device. Dynamic status messages and dialogs use the same language.':
'此设备使用的界面语言。动态状态消息与对话框也会使用同一语言。',
English: 'English',
Appearance: '外观',
Skin: '皮肤',
'Visual theme for this device (not synced)': '此设备的视觉主题(不同步)',
'Daylight Blue': '日光蓝',
'Daylight Green': '日光绿',
'OG Codeman': '经典 {name}',
Performance: '性能',
'WebGL Renderer': 'WebGL 渲染器',
'Header Displays': '顶部栏显示',
'Font Controls': '字体控制',
'System Stats': '系统状态',
'Lifecycle Log': '生命周期日志',
'Response Viewer': '回复查看器',
'Attachments Button': '附件按钮',
'Multi-monitor Button': '多显示器按钮',
'Session Manager Button': '会话管理器按钮',
'Away Digest Button': '离开期间摘要按钮',
'Cron Button': '定时任务按钮',
'Redraw Terminal Button': '重绘终端按钮',
'Tab Bar': '标签栏',
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
Panels: '面板',
Monitor: '监视器',
'Project Insights': '项目洞察',
'File Browser': '文件浏览器',
Subagents: '子智能体',
'Ultracode Agents': 'Ultracode 智能体',
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
'Subagent Options': '子智能体选项',
'Enable Tracking': '启用跟踪',
'Active Tab Only': '仅活动标签页',
'Image Watcher': '图像监视器',
'Enable Globally': '全局启用',
'Remote Access': '远程访问',
'Cloudflare Tunnel': 'Cloudflare 隧道',
'Tunnel URL': '隧道地址',
'Upload URL': '上传地址',
Updates: '更新',
'Current Version': '当前版本',
'Check for Updates': '检查更新',
'Check now': '立即检查',
'Update available': '有可用更新',
'Update now': '立即更新',
'Show CPU and memory usage in header': '在顶部栏显示 CPU 与内存使用情况',
'Show session lifecycle log button in header': '在顶部栏显示会话生命周期日志按钮',
'Show the response viewer (eye) button in header': '在顶部栏显示回复查看器(眼睛)按钮',
'Show the file viewer button in header (opens the file browser panel for the active session)':
'在顶部栏显示文件查看器按钮(打开当前会话的文件浏览器面板)',
'Show the attachments button in header (opens the attachment history drawer)':
'在顶部栏显示附件按钮(打开附件历史抽屉)',
'Show the multi-monitor button in the header (opens Codeman spanned across all displays)':
'在顶部栏显示多显示器按钮(跨所有显示器打开 {name})',
'Show the session manager button in the header (opens the session manager — sessions also stay reachable via the Ctrl+K palette)':
'在顶部栏显示会话管理器按钮(也可通过 Ctrl+K 面板访问会话)',
"Show the away digest button in the header (opens the 'what happened while you were away' summary)":
'在顶部栏显示离开期间摘要按钮',
'Show the Cron button in the footer toolbar (opens the cron jobs manager)': '在底部工具栏显示定时任务按钮',
'Show a terminal redraw button in the header — refit the terminal to the current screen size (useful when switching between devices)':
'在顶部栏显示终端重绘按钮,以重新适配当前屏幕大小',
'Show folder path below tab name and allow tab bar to wrap into multiple rows':
'在标签名称下显示文件夹路径,并允许标签栏换行',
'Show Monitor panel at bottom right': '在右下角显示监视器面板',
'Show active tools and file viewers in a floating panel': '在浮动面板中显示活动工具与文件查看器',
'Show file browser panel on the right side': '在右侧显示文件浏览器面板',
'Show the Subagents panel (independent from Monitor)': '显示子智能体面板(独立于监视器)',
'Monitor Claude Code background agents in real-time': '实时监视 Claude Code 后台智能体',
'Only show subagent windows when their parent tab is selected': '仅在选中父标签页时显示子智能体窗口',
'Automatically detect and popup new images in session directories': '自动检测并弹出会话目录中的新图像',
'Expose Codeman via Cloudflare Tunnel for remote access': '通过 Cloudflare 隧道远程访问 {name}',
'Codeman version currently running': '当前运行的{name}版本',
'Check GitHub for a newer Codeman release': '检查 GitHub 上是否有新版 {name}',
// Input settings
Input: '输入',
'Local Echo': '本地回显',
'CJK Input': '中日韩输入',
'Extended Keyboard Bar': '扩展键盘栏',
'Gesture Control (beta)': '手势控制(测试版)',
'Wheel Scrolls Local History': '滚轮滚动本地历史',
'Instant typing feedback with local echo': '通过本地回显即时显示输入',
'Dedicated IME input field for CJK languages': '为中日韩语言提供专用输入法文本框',
'Extra keys: Tab, Esc, arrows, Ctrl+O': '附加按键:Tab、Esc、方向键、Ctrl+O',
// CLI / model settings
'Startup Mode': '启动模式',
'Skip Permissions (default)': '跳过权限确认(默认)',
'Auto (classifier-guarded, low prompts)': '自动(分类器保护,较少提示)',
'Normal (with prompts)': '普通(显示提示)',
'Allowed Tools Only': '仅允许指定工具',
'Allowed Tools': '允许的工具',
'Comma-separated list of tools to allow': '以逗号分隔允许使用的工具',
'Enable Ralph / Todo Tracker': '启用 Ralph / 待办跟踪器',
'Claude Permissions': 'Claude 权限',
'Agent Teams': '智能体团队',
'Claude Model': 'Claude 模型',
'1M Opus Context': 'Opus 100 万上下文',
'Remote auto-reconnect': '远程自动重连',
'Thinking Effort': '思考强度',
Low: '低',
Medium: '中',
High: '高',
Max: '最高',
'Nice Priority': 'Nice 优先级',
'Enable Nice Priority Reduction': '启用 Nice 优先级调整',
'Nice Value': 'Nice 值',
'Bypass Approvals and Sandbox': '绕过审批与沙箱',
'Default Model': '默认模型',
'Show Optimizer Recommendations': '显示优化器建议',
'Agent Type Overrides': '按智能体类型覆盖',
'Use Default': '使用默认值',
// Notifications / voice / shortcuts
'Enable Notifications': '启用通知',
'Master toggle for all notification layers': '所有通知层的总开关',
'Browser Notifications': '浏览器通知',
'Audio Alerts': '声音提醒',
'Push Notifications': '推送通知',
'Notification Levels': '通知级别',
Critical: '严重',
'Per-Event Settings': '按事件设置',
'Permission prompts': '权限提示',
'Questions from Claude': 'Claude 提问',
'Session idle': '会话空闲',
'Response complete': '回复完成',
'Respawn cycles': '重生循环',
'Task complete': '任务完成',
'Subagent activity': '子智能体活动',
Browser: '浏览器',
Audio: '声音',
Push: '推送',
'Voice Input': '语音输入',
Provider: '服务商',
'Active Provider': '当前服务商',
'API Key': 'API 密钥',
'Domain Keywords': '领域关键词',
'Input Mode': '输入模式',
'Direct to input': '直接输入',
'Compose dialog': '编辑对话框',
'Keyboard Shortcuts': '键盘快捷键',
'Customize keyboard shortcuts. Click the binding to capture a new key combination.':
'自定义键盘快捷键。点击按键组合即可录入新的组合。',
'Show Shortcuts': '显示快捷键',
'Full shortcut reference': '完整快捷键参考',
// Session/case dialogs
'Session Options': '会话选项',
'Session Name': '会话名称',
'Session Color': '会话颜色',
'Working Directory': '工作目录',
'Set working directory': '设置工作目录',
'Resume Conversation': '继续对话',
'Close Session': '关闭会话',
'Choose how to close': '选择关闭方式',
'Tmux session keeps running in background': 'Tmux 会话继续在后台运行',
'Terminate the session completely': '彻底终止会话',
'Cancel close session': '取消关闭会话',
'Case Name': '案例名称',
'Folder Path': '文件夹路径',
'Default Working Directory': '默认工作目录',
'Default directory for new sessions.': '新会话的默认目录。',
'Default CLAUDE.md Template': '默认 CLAUDE.md 模板',
'Used when creating new cases. Leave empty for built-in template.': '创建新案例时使用;留空则使用内置模板。',
'Remote Path': '远程路径',
'SSH Host/IP': 'SSH 主机/IP',
'SSH Username': 'SSH 用户名',
'SSH Port': 'SSH 端口',
'Identity File': '身份文件',
'Jump Host': '跳板主机',
'Advanced SSH': '高级 SSH',
'Discover existing sessions': '发现现有会话',
'Workspace Path': '工作区路径',
'Container settings (optional, sensible defaults)': '容器设置(可选,默认值合理)',
Template: '模板',
Network: '网络',
CPUs: 'CPU 数',
Memory: '内存',
GPUs: 'GPU',
// Cron / lifecycle / panels
'Cron Jobs': '定时任务',
'+ New Job': '+ 新建任务',
'New Cron Job': '新建定时任务',
Schedule: '计划',
'Schedule Type': '计划类型',
Once: '一次',
Interval: '间隔',
Daily: '每天',
Weekly: '每周',
'Run At': '运行时间',
'Every (minutes)': '每隔(分钟)',
Weekdays: '工作日',
"Times use the server's local timezone.": '时间使用服务器本地时区。',
'All Events': '全部事件',
Created: '已创建',
Started: '已启动',
Exit: '退出',
Deleted: '已删除',
Recovered: '已恢复',
'Stale Cleaned': '已清理过期项',
'Mux Died': 'Mux 已终止',
'Server Started': '服务器已启动',
'Server Stopped': '服务器已停止',
Extra: '附加信息',
'Token Usage Statistics': 'Token 使用统计',
'Daily Breakdown': '每日明细',
'Export JSON': '导出 JSON',
'Export MD': '导出 Markdown',
// Dynamic common status / toasts
'Settings saved': '设置已保存',
'Settings saved locally': '设置已保存到本机',
'Tunnel active': '隧道已启用',
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
'Push notifications enabled': '推送通知已启用',
'Push notifications disabled': '推送通知已禁用',
'Permission Required': '需要授权',
'Waiting for Input': '等待输入',
'Question Asked': 'Claude 正在提问',
'Response Complete': '回复完成',
'Task Completed': '任务已完成',
'Teammate Idle': '队友空闲',
'Session Error': '会话错误',
'Respawn Blocked': '重生已阻止',
'Task Complete': '任务完成',
'Copied to clipboard': '已复制到剪贴板',
'Checking…': '正在检查…',
'Starting…': '正在启动…',
'Starting update…': '正在开始更新…',
'Queued…': '已排队…',
'Preparing…': '正在准备…',
'Stashing local changes…': '正在暂存本地更改…',
'Fetching release…': '正在获取发行版…',
'Checking out release…': '正在检出发行版…',
'Installing dependencies…': '正在安装依赖…',
'Building…': '正在构建…',
'Restarting Codeman…': '正在重启 {name}…',
'Try again': '重试',
'Could not check for updates. Try again later.': '无法检查更新,请稍后重试。',
'The previous version is still running.': '先前版本仍在运行。',
// Remaining settings, wizard, case and management surfaces
'Advanced Options': '高级选项',
'Advanced container settings': '高级容器设置',
Basics: '基本设置',
Behavior: '行为',
Alerts: '提醒',
Limits: '限制',
Paths: '路径',
Notes: '备注',
Context: '上下文',
Duration: '持续时间',
Iterations: '迭代次数',
Elapsed: '已用时间',
Launch: '启动',
'Launch Command': '启动命令',
'Background Agents': '后台智能体',
'Background Tasks': '后台任务',
Tasks: '任务',
'Explore Tasks': '探索任务',
'Implement Tasks': '实现任务',
'Test Tasks': '测试任务',
'Review Tasks': '审查任务',
'Agent Type': '智能体类型',
'Implementation Plan': '实施计划',
Plan: '计划',
'Plan:': '计划:',
'Plan Usage Limits': '套餐使用限制',
'Plan Wizard Agents': '计划向导智能体',
'Fix Plan Menu': '修复计划菜单',
'View Fix Plan': '查看修复计划',
'Regenerate Plan': '重新生成计划',
'Cancel plan generation': '取消生成计划',
'Describe your task below. Claude will generate an implementation plan with testing steps.':
'请在下方描述任务,Claude 将生成包含测试步骤的实施计划。',
'What do you want to build?': '你想构建什么?',
'A brief description...': '简要描述…',
Describe: '描述',
Enhanced: '增强',
'Enhanced: parallel subagents + verification (slower but more thorough)':
'增强:并行子智能体 + 验证(速度较慢,但更全面)',
Standard: '标准',
'Single-pass generation with Opus 4.5': '使用 Opus 4.5 单轮生成',
'Initializing deep reasoning model': '正在初始化深度推理模型',
'Starting Opus 4.5...': '正在启动 Opus 4.5…',
'Auto-launch when plan completes': '计划完成后自动启动',
'Auto-accept prompts': '自动接受提示',
'Presses Enter for plan approvals and default question options': '对计划审批和默认问题选项自动按 Enter',
'Auto-accepts, auto-clears, agent completions': '自动接受、自动清理和智能体完成提醒',
'Or click Run to start': '或点击“运行”开始',
'to edit your task, or': '以编辑任务,或',
'to continue without a plan': '以不使用计划直接继续',
// Ralph / respawn
Respawn: '重生',
'Respawn loop': '重生循环',
'Enable Respawn': '启用重生',
'Stop Respawn': '停止重生',
'Auto-resume when usage limit resets': '使用限制重置后自动继续',
'Auto-restart sessions when context fills up (usually not needed)': '上下文已满时自动重启会话(通常不需要)',
'Auto-Compact': '自动压缩',
'Auto-Clear': '自动清空',
'Token Management': 'Token 管理',
'Use 1M token context window': '使用 100 万 Token 上下文窗口',
'Use 1M token context window for new sessions': '新会话使用 100 万 Token 上下文窗口',
'Full context reset at threshold (use higher than compact)': '达到阈值时完全重置上下文(阈值应高于压缩阈值)',
'Idle Threshold': '空闲阈值',
'Max Iterations': '最大迭代次数',
'Max Iterations:': '最大迭代次数:',
'Max Todos': '最大待办数',
'Todo Expiration': '待办过期时间',
'Completion Phrase': '完成短语',
'Completion Phrase:': '完成短语:',
'Phrase Claude outputs when loop is complete (without <promise> tags)':
'循环完成时 Claude 输出的短语(不含 <promise> 标签)',
'Prompt to send when idle': '空闲时发送的提示词',
'Prompt to send into the session': '发送到会话的提示词',
'Prompt Source': '提示词来源',
'Prompt File Path': '提示词文件路径',
'Prompt file path': '提示词文件路径',
'Prompt Preview': '提示词预览',
'Load Preset': '加载预设',
Presets: '预设',
'Apply preset': '应用预设',
'Save Preset': '保存预设',
'Save Respawn Preset': '保存重生预设',
'Save current config as preset': '将当前配置保存为预设',
'Preset Name': '预设名称',
'Description (optional)': '描述(可选)',
'When to use this preset': '此预设的适用场景',
'Start Loop': '启动循环',
'Start Ralph Loop': '启动 Ralph 循环',
'Start Ralph Loop →': '启动 Ralph 循环 →',
'Enable Tracker': '启用跟踪器',
'Ralph / Todo': 'Ralph / 待办',
'Ralph / Todo Tracker': 'Ralph / 待办跟踪器',
'Cycle Steps': '循环步骤',
'1. Update Prompt': '1. 更新提示词',
'2. Send /clear': '2. 发送 /clear',
'3. Send /init': '3. 发送 /init',
'4. Kickstart Prompt': '4. 启动提示词',
'Sent only when /init completes but Claude stays idle · Auto-accept presses Enter for plan approvals and default options':
'仅在 /init 完成后 Claude 仍空闲时发送;自动接受会对计划审批和默认选项按 Enter',
'One autonomous work cycle: whenever Claude goes idle, Codeman sends the update prompt, optionally runs /clear + /init, and kickstarts the next round — repeating for the chosen duration. All settings below belong to this loop; configure them, then press Enable.':
'一个自主工作循环:Claude 每次空闲时,{name}都会发送更新提示词,可选执行 /clear + /init,并启动下一轮,持续到设定时长。下方设置均属于此循环;配置后点击“启用”。',
'If Claude pauses on a usage limit ("limit reached · resets 3pm"), Codeman waits for the reset time and automatically continues the work. Independent of the respawn loop below.':
'如果 Claude 因使用限制暂停(“limit reached · resets 3pm”),{name}会等待限制重置并自动继续工作。此功能独立于下方的重生循环。',
// Search, session and panel surfaces
'Search sessions, events, files…': '搜索会话、事件和文件…',
'Search across sessions': '跨会话搜索',
'Filter by case': '按案例筛选',
'Filter by date range': '按日期范围筛选',
'Filter by session status': '按会话状态筛选',
'Filter files...': '筛选文件…',
'Any status': '任意状态',
'Any time': '任意时间',
'Last hour': '最近一小时',
'Last 7 Days': '最近 7 天',
'Past 24h': '过去 24 小时',
'Past 7 days': '过去 7 天',
'Past 30 days': '过去 30 天',
'Since last visit': '自上次访问以来',
Since: '开始时间',
Until: '结束时间',
'Away digest range': '离开期间摘要范围',
'Open the digest to load recent activity': '打开摘要以加载最近活动',
'Refresh away digest': '刷新离开期间摘要',
'Refresh summary': '刷新摘要',
'Select a session to view files': '选择会话以查看文件',
'Select a session to view summary': '选择会话以查看摘要',
'Select an agent to view details': '选择智能体以查看详情',
'Select a run to view its agents': '选择一次运行以查看其智能体',
'Source type filter': '来源类型筛选',
'Copy content': '复制内容',
'Export as JSON': '导出为 JSON',
'Export as Markdown': '导出为 Markdown',
'Mark all read': '全部标为已读',
'Clear search': '清除搜索',
'Clear all tracked subagents': '清除所有已跟踪的子智能体',
'Kill All Sessions': '终止所有会话',
'Kill all sessions and their tmux processes': '终止所有会话及其 tmux 进程',
'Kill All Claude + Tmux': '终止全部 Claude + Tmux',
'Kill Tmux & Claude Code': '终止 Tmux 与 Claude Code',
'Terminate everything completely': '彻底终止所有内容',
'Tmux Sessions': 'Tmux 会话',
'Tmux sessions keep running in background': 'Tmux 会话继续在后台运行',
'Refresh tmux sessions': '刷新 Tmux 会话',
'Restore Terminal Size': '恢复终端大小',
'Clear Terminal': '清空终端',
'Stop current run': '停止当前运行',
'Stop respawn': '停止重生',
'Stop (Ctrl+C)': '停止(Ctrl+C)',
// Case, remote and Docker details
Case: '案例',
'Case:': '案例:',
'Case settings': '案例设置',
'Create New': '新建',
'Auto (directory name)': '自动(目录名)',
'Custom name shown in the tab (right-click tab to rename inline)':
'标签页中显示的自定义名称(右键标签可直接重命名)',
'Name to identify this case in Codeman': '用于在{name}中标识此案例的名称',
'Name to identify this remote case in Codeman': '用于在{name}中标识此远程案例的名称',
'Absolute path on the remote host. Codeman will not create or delete it.':
'远程主机上的绝对路径;{name}不会创建或删除该目录。',
'Absolute path to an existing project folder, e.g. /home/you/my-project':
'现有项目文件夹的绝对路径,例如 /home/you/my-project',
'Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/':
'仅允许字母、数字、连字符和下划线;将在 ~/codeman-cases/ 中创建。',
'Docker exports': 'Docker 导出',
'No exports yet. Export a docker case from its tab.': '暂无导出;请从 Docker 案例标签页导出。',
'Runs inside an isolated container. Multiple sessions can share the same container.':
'在隔离容器内运行;多个会话可以共享同一容器。',
'Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.':
'在加固的隔离容器中运行此案例。首次使用时会自动构建基础镜像;必须安装 Docker/Podman。',
'Run in an isolated Docker container': '在隔离的 Docker 容器中运行',
'Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.':
'绑定挂载到容器中的主机绝对目录;{name}会在其中生成 CLAUDE.md 和 hooks。',
'A reusable docker host profile. Reuse the same ID across cases to share settings.':
'可复用的 Docker 主机配置;多个案例使用同一 ID 可共享设置。',
'Mount host credentials (~/.claude etc.)': '挂载主机凭据(~/.claude 等)',
'On: your existing login just works (creds stay on the host, never in exports). Off: sealed sandbox, log in inside the container.':
'开启:直接使用现有登录(凭据保留在主机且不会进入导出);关闭:使用密封沙箱,需要在容器内登录。',
'Disk is elastic: storage grows automatically as data flows in (no fixed cap).':
'磁盘为弹性容量:会随数据自动增长(无固定上限)。',
'Needs the NVIDIA container toolkit on the host.': '主机需要安装 NVIDIA Container Toolkit。',
'GPU — 8 GB RAM, 4 CPU, all GPUs': 'GPU — 8 GB 内存、4 CPU、全部 GPU',
'Large — 8 GB RAM, 4 CPU': '大型 — 8 GB 内存、4 CPU',
'Medium — 4 GB RAM, 2 CPU (default)': '中型 — 4 GB 内存、2 CPU(默认)',
'Small — 2 GB RAM, 1 CPU': '小型 — 2 GB 内存、1 CPU',
'bridge (internet on, default)': '桥接(可联网,默认)',
'bridge (internet on)': '桥接(可联网)',
'none (fully isolated, no network)': '无(完全隔离,不联网)',
'none (fully isolated)': '无(完全隔离)',
'Resume last conversation on relaunch': '重新启动时继续最近一次对话',
'Extra -o Options': '附加 -o 选项',
'SOCKS Proxy': 'SOCKS 代理',
'Host ID': '主机 ID',
'Optional. Leave blank for the default port 22.': '可选;留空使用默认端口 22。',
'Optional. Path to a private key on this machine (passed to ssh -i). Never the key contents.':
'可选;本机私钥文件路径(传给 ssh -i),请勿填写密钥内容。',
'Optional. [user@]host[:port] for ssh -J (jump/bastion host).':
'可选;ssh -J 使用的 [user@]host[:port](跳板机)。',
'Optional. One KEY=VALUE per line; each becomes an ssh -o option.':
'可选;每行一个 KEY=VALUE,每项都会成为 ssh -o 选项。',
// Settings descriptions and remaining common controls
'Use the GPU-accelerated WebGL terminal renderer (desktop only). Turn off to force the DOM renderer if you hit GPU glitches. Codeman also auto-falls-back to the DOM renderer after repeated GPU stalls.':
'使用 GPU 加速的 WebGL 终端渲染器(仅桌面端)。如遇 GPU 显示问题,可关闭以强制使用 DOM 渲染器;多次 GPU 卡顿后{name}也会自动回退。',
'Show A-/A+ font size buttons in header': '在顶部栏显示 A-/A+ 字体大小按钮',
'Show Claude plan usage limits (5-hour & weekly) in the header. Applies to newly created sessions.':
'在顶部栏显示 Claude 套餐使用限制(5 小时和每周);适用于新建会话。',
'Show ultracode / Workflow runs as a master-detail tab (tasks on the left, agents with tokens + tool calls on the right)':
'以主从标签页显示 Ultracode / Workflow 运行(左侧任务,右侧智能体 Token 与工具调用)',
'Pop a floating window for each active ultracode / Workflow run, connected by a line to its session tab (additional to the Ultracode Agents panel)':
'为每个活动的 Ultracode / Workflow 运行弹出浮动窗口,并用连线连接到其会话标签页',
'Shows typed characters instantly via overlay while forwarding keystrokes to the server in the background. Enables Tab completion, preserves input across tab switches, and protects against session crashes. Recommended for mobile and high-latency connections.':
'通过覆盖层即时显示输入,同时在后台把按键转发到服务器。支持 Tab 补全、切换标签时保留输入并防止会话崩溃丢字;推荐移动端和高延迟连接使用。',
"Show a dedicated input field below the terminal for CJK (Chinese/Japanese/Korean) IME composition. Recommended for mobile devices with Chinese input methods where xterm's native input handling may drop characters.":
'在终端下方显示中日韩输入法专用文本框。推荐在可能因 xterm 原生输入而丢字的移动端中文输入法中使用。',
'Show additional buttons (Tab, Shift+Tab, Ctrl+O, Esc, Alt+Enter, left/right arrows) in the mobile keyboard accessory bar.':
'在移动端键盘工具栏显示附加按键(Tab、Shift+Tab、Ctrl+O、Esc、Alt+Enter、左右方向键)。',
'Scroll local history (when mouse passthrough is active)': '滚动本地历史(鼠标直通启用时)',
'Plain wheel/trackpad pages the terminal scrollback': '使用普通滚轮/触控板翻阅终端历史',
'Camera hand-tracking overlay (applied on reload)': '摄像头手势跟踪覆盖层(重新加载后生效)',
'Enable the camera hand-tracking gesture overlay (applied on reload). The instance must run with CODEMAN_GESTURE=1.':
'启用摄像头手势跟踪覆盖层(重新加载后生效);实例必须以 CODEMAN_GESTURE=1 运行。',
'How Claude CLI is started in screen sessions. Auto Mode runs without routine prompts behind a background safety classifier (needs Claude Code 2.1.207+ and Opus 4.6+/Sonnet 4.6+/Fable 5)':
'设置 Claude CLI 在会话中的启动方式。自动模式由后台安全分类器保护,无需常规确认(需要 Claude Code 2.1.207+ 和 Opus 4.6+/Sonnet 4.6+/Fable 5)。',
'Auto-enable for new sessions (otherwise auto-enables on Ralph pattern detection)':
'为新会话自动启用(否则检测到 Ralph 模式时自动启用)',
'Enable experimental Agent Teams for all new Claude sessions (disabled by default)':
'为所有新 Claude 会话启用实验性智能体团队(默认关闭)',
'Automatically re-establish remote (SSH) sessions when the connection drops, reattaching to the durable remote tmux session (on by default; bounded backoff)':
'连接断开时自动重建远程 SSH 会话,并重新附加到持久化远程 tmux 会话(默认开启,有限退避)',
'Default effort for new Claude sessions — soft default, switchable anytime in-session via /effort (e.g. /effort ultracode)':
'新 Claude 会话的默认思考强度;这是软默认值,可随时在会话中通过 /effort 切换。',
'Lower priority of Claude sessions (reduces system impact, only affects new sessions)':
'降低 Claude 会话的进程优先级(减少系统影响,仅影响新会话)',
'Process priority (-20 to 19, higher = lower priority, default: 10)':
'进程优先级(-20 到 19;数值越大优先级越低;默认 10)',
'Start new Codex sessions with --dangerously-bypass-approvals-and-sandbox':
'使用 --dangerously-bypass-approvals-and-sandbox 启动新的 Codex 会话',
'Model used for execution tasks. Optimizer suggestions are advisory only.':
'执行任务使用的模型;优化器建议仅供参考。',
"Show what the optimizer recommends (doesn't override your choice)": '显示优化器建议(不会覆盖你的选择)',
'Optionally set specific models for each task type. Leave as "Use Default" to use your default model.':
'可为每种任务类型指定模型;保留“使用默认值”即可使用默认模型。',
'Request browser notification permission': '请求浏览器通知权限',
'Show OS-level notifications when tab is hidden': '标签页隐藏时显示系统级通知',
'OS-level push notifications — works even when tab is closed': '系统级推送通知,即使标签页关闭也可接收',
'Play a short beep for critical events': '严重事件发生时播放短提示音',
'Completions, budget warnings, stuck sessions': '完成提醒、预算警告和会话卡住提醒',
'Errors, crashes, agent failures': '错误、崩溃和智能体失败',
'Notify when a session is idle longer than this': '会话空闲超过此时长时通知',
'Stored locally only, never sent to server. Get a key at': '仅存储在本机,绝不会发送到服务器。可在此获取密钥:',
'Comma-separated terms to boost recognition accuracy': '以逗号分隔可提高识别准确率的术语',
'Start voice input': '开始语音输入',
'Voice input': '语音输入',
'Voice input (Ctrl+Shift+V)': '语音输入(Ctrl+Shift+V)',
'Insert Newline': '插入换行',
'Close Panels': '关闭面板',
'Previous / Next Session': '上一个 / 下一个会话',
'Next Session': '下一个会话',
'Switch to Tab N': '切换到第 N 个标签页',
'Move Active Tab Left': '向左移动当前标签页',
'Move Active Tab Right': '向右移动当前标签页',
'Focus First Tab': '聚焦第一个标签页',
'Focus Last Tab': '聚焦最后一个标签页',
'Focus Next Tab': '聚焦下一个标签页',
'Focus Previous Tab': '聚焦上一个标签页',
'Activate Focused Tab': '激活聚焦的标签页',
'Remove Tab': '移除标签页',
'Remove All Tabs': '移除所有标签页',
'Use arrows to reorder. Changes are saved automatically.': '使用方向键重新排序;更改会自动保存。',
});
const ZH_CN_LOWER = new Map(Object.entries(ZH_CN).map(([key, value]) => [key.toLocaleLowerCase('en'), value]));
const textState = new WeakMap();
const attributeState = new WeakMap();
let language = normalizeLanguage(global.__codemanLanguage);
let displayName = DEFAULT_NAME;
let observer = null;
let applying = false;
function normalizeLanguage(value) {
return SUPPORTED_LANGUAGES.has(value) ? value : 'en';
}
function normalizeDisplayName(value) {
if (typeof value !== 'string') return DEFAULT_NAME;
const normalized = value
.normalize('NFC')
.replace(/[\u0000-\u001f\u007f]/g, '')
.trim();
return normalized ? Array.from(normalized).slice(0, 40).join('') : DEFAULT_NAME;
}
function interpolate(value, variables) {
return value.replace(/\{([a-zA-Z][\w]*)\}/g, (_match, key) => String(variables[key] ?? ''));
}
function translateDynamic(source) {
const patterns = [
[/^(\d+) tokens?$/, (_m, count) => `${count} 个 Token`],
[/^(\d+) sessions?$/, (_m, count) => `${count} 个会话`],
[/^(\d+) tasks?$/, (_m, count) => `${count} 个任务`],
[/^(\d+) running$/, (_m, count) => `${count} 个运行中`],
[/^(\d+) active$/, (_m, count) => `${count} 个活动`],
[/^Show (\d+) more$/, (_m, count) => `再显示 ${count} 项`],
[/^Show (\d+) more \((\d+) remaining\)$/, (_m, count, remaining) => `再显示 ${count} 项(剩余 ${remaining} 项)`],
[/^Lifetime: (\d+) sessions created$/, (_m, count) => `累计已创建 ${count} 个会话`],
[/^Tunnel active: (.+)$/, (_m, url) => `隧道已启用:${url}`],
[/^Tunnel error: (.+)$/, (_m, error) => `隧道错误:${error}`],
[/^Update to v(.+)$/, (_m, version) => `更新到 v${version}`],
[/^You're up to date \(v(.+)\)\.$/, (_m, version) => `已是最新版本(v${version})。`],
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
];
for (const [pattern, replacement] of patterns) {
const match = source.match(pattern);
if (match) return replacement(...match);
}
const actionMatch = source.match(
/^(Open|Close|Show|Hide|Enable|Disable|Start|Stop|Refresh|Save|Cancel|Clear|Select|View|Export|Import|Remove|Kill|Toggle|Increase|Decrease) (.+)$/i
);
if (actionMatch) {
const action = {
open: '打开',
close: '关闭',
show: '显示',
hide: '隐藏',
enable: '启用',
disable: '禁用',
start: '启动',
stop: '停止',
refresh: '刷新',
save: '保存',
cancel: '取消',
clear: '清除',
select: '选择',
view: '查看',
export: '导出',
import: '导入',
remove: '移除',
kill: '终止',
toggle: '切换',
increase: '增大',
decrease: '减小',
}[actionMatch[1].toLowerCase()];
const object = ZH_CN[actionMatch[2]] || ZH_CN_LOWER.get(actionMatch[2].toLocaleLowerCase('en'));
if (action && object) return `${action}${object}`;
}
return null;
}
function brand(source) {
if (!source || displayName === DEFAULT_NAME) return source;
return source.replace(/Codeman/g, displayName).replace(/codeman(?=:)/g, displayName);
}
function t(source, variables = {}) {
if (typeof source !== 'string' || !source) return source;
const vars = { name: displayName, ...variables };
if (language === 'zh-CN') {
const translated = ZH_CN[source] || ZH_CN_LOWER.get(source.toLocaleLowerCase('en')) || translateDynamic(source);
if (translated) return brand(interpolate(translated, vars));
}
return brand(interpolate(source, vars));
}
function shouldSkip(node) {
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return !element || Boolean(element.closest(SKIP_SELECTOR));
}
function shouldSkipText(node) {
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return shouldSkip(node) || Boolean(element?.closest(USER_TEXT_SELECTOR));
}
function preserveWhitespace(source, translated) {
const leading = source.match(/^\s*/)?.[0] || '';
const trailing = source.match(/\s*$/)?.[0] || '';
return leading + translated + trailing;
}
function translateTextNode(node) {
let state = textState.get(node);
if (shouldSkipText(node) || (!state && !/[A-Za-z]/.test(node.nodeValue || ''))) return;
if (!state || node.nodeValue !== state.applied) {
state = { source: node.nodeValue, applied: node.nodeValue };
}
const trimmed = state.source.trim();
if (!trimmed) return;
const next = preserveWhitespace(state.source, t(trimmed));
state.applied = next;
textState.set(node, state);
if (node.nodeValue !== next) node.nodeValue = next;
}
function translateAttributes(element) {
if (shouldSkip(element) || element.matches('.history-item[title]')) return;
let states = attributeState.get(element);
if (!states) states = new Map();
for (const attribute of TRANSLATABLE_ATTRIBUTES) {
if (!element.hasAttribute(attribute)) continue;
const current = element.getAttribute(attribute) || '';
let state = states.get(attribute);
if (!state || current !== state.applied) state = { source: current, applied: current };
const next = t(state.source);
state.applied = next;
states.set(attribute, state);
if (current !== next) element.setAttribute(attribute, next);
}
attributeState.set(element, states);
}
function translateNode(root) {
if (!root || applying) return;
applying = true;
try {
if (root.nodeType === Node.TEXT_NODE) {
translateTextNode(root);
return;
}
if (root.nodeType !== Node.ELEMENT_NODE && root.nodeType !== Node.DOCUMENT_NODE) return;
if (root.nodeType === Node.ELEMENT_NODE) translateAttributes(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node.nodeType === Node.TEXT_NODE) translateTextNode(node);
else translateAttributes(node);
}
} finally {
applying = false;
}
}
function refreshDocumentTitle() {
const current = document.title || '';
const titleState = document.documentElement.dataset.i18nTitleSource || current;
document.documentElement.dataset.i18nTitleSource = titleState;
document.title = brand(titleState);
}
function configure(options = {}) {
const previousDisplayName = displayName;
language = normalizeLanguage(options.language ?? language);
displayName = normalizeDisplayName(options.displayName ?? displayName);
global.__codemanLanguage = language;
global.__codemanDisplayName = displayName;
document.documentElement.lang = language;
document.documentElement.dataset.language = language;
if (previousDisplayName !== displayName) {
const source = document.documentElement.dataset.i18nTitleSource || document.title || '';
if (previousDisplayName !== DEFAULT_NAME && source.includes(previousDisplayName)) {
document.documentElement.dataset.i18nTitleSource = source.replaceAll(previousDisplayName, displayName);
}
}
translateNode(document.body);
refreshDocumentTitle();
return { language, displayName };
}
function start() {
translateNode(document.body);
refreshDocumentTitle();
if (observer) return;
observer = new MutationObserver((mutations) => {
if (applying) return;
for (const mutation of mutations) {
if (mutation.type === 'characterData') translateNode(mutation.target);
if (mutation.type === 'attributes') translateAttributes(mutation.target);
for (const added of mutation.addedNodes) translateNode(added);
}
});
observer.observe(document.body, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: TRANSLATABLE_ATTRIBUTES,
});
}
const api = Object.freeze({
t,
configure,
start,
translateNode,
normalizeDisplayName,
normalizeLanguage,
get language() {
return language;
},
get displayName() {
return displayName;
},
});
global.CodemanI18n = api;
global.codemanT = t;
const nativeConfirm = typeof global.confirm === 'function' ? global.confirm.bind(global) : null;
const nativeAlert = typeof global.alert === 'function' ? global.alert.bind(global) : null;
if (nativeConfirm) global.confirm = (message) => nativeConfirm(t(String(message)));
if (nativeAlert) global.alert = (message) => nativeAlert(t(String(message)));
document.addEventListener('DOMContentLoaded', start, { once: true });
})(window);
+48 -4
View File
@@ -46,6 +46,10 @@
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Synchronous skin selection — runs before first paint to prevent theme flash -->
<script>try{var s=localStorage.getItem('codeman:skin');if(s!=='og'&&s!=='daylight-green'&&s!=='daylight-blue')s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
<!-- Apply the saved per-device language before first paint. The full translation
layer loads below; setting lang/dir here prevents an English accessibility
tree from flashing while the deferred scripts start. -->
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var l=JSON.parse(localStorage.getItem(k)||'{}').language;l=l==='zh-CN'?'zh-CN':'en';document.documentElement.lang=l;window.__codemanLanguage=l;}catch(e){document.documentElement.lang='en';window.__codemanLanguage='en';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#11151c}
@@ -76,7 +80,9 @@
<!-- Compact Header with Session Tabs -->
<header class="header">
<div class="header-brand">
<span class="logo" onclick="app.goHome()" title="Go to main page">Codeman</span>
<span class="logo" onclick="app.goHome()" title="Go to main page"
><span class="logo-text">Codeman</span><span class="logo-compact" aria-hidden="true">C</span></span
>
</div>
<!-- Session Tabs -->
@@ -87,6 +93,10 @@
<div class="solo-session-title" id="soloSessionTitle" style="display: none;" aria-live="polite"></div>
<div class="header-right" id="headerRight">
<button class="btn-admin-panel btn-admin-panel--hidden" id="adminPanelBtn" onclick="window.codemanAdmin.openAdminPanel()" title="Admin Panel (multi-user administration)" aria-label="Open admin panel">
<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="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span>Admin Panel</span>
</button>
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">&#x229E;</button>
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
<span class="tunnel-dot"></span>
@@ -558,7 +568,7 @@
<div class="toolbar-right">
<!-- Orchestrator button hidden until feature is ready -->
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">&#x2699; Orchestrator</button> -->
<button class="btn-toolbar btn-sm btn-cron" onclick="app.openCron()" title="Cron Jobs">&#x23F0; Cron</button>
<button class="btn-toolbar btn-sm btn-cron btn-cron--hidden" onclick="app.openCron()" title="Cron Jobs">&#x23F0; Cron</button>
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
</div>
</footer>
@@ -1161,6 +1171,20 @@
<!-- Display Tab -->
<div class="modal-tab-content" id="settings-display">
<div class="settings-grid">
<!-- Branding & Language Section -->
<div class="settings-section-header">Branding &amp; Language</div>
<div class="settings-item" title="Name shown in the browser UI and window title. Supports Unicode, including Chinese.">
<span class="settings-item-label">Display Name</span>
<input type="text" id="appSettingsDisplayName" class="settings-inline-input" maxlength="40" placeholder="Codeman" autocomplete="off">
</div>
<div class="settings-item" title="Language for this device. Dynamic status messages and dialogs use the same language.">
<span class="settings-item-label">Interface Language</span>
<select id="appSettingsLanguage" class="form-select settings-inline-select">
<option value="en">English</option>
<option value="zh-CN">简体中文</option>
</select>
</div>
<!-- Appearance Section -->
<div class="settings-section-header">Appearance</div>
<div class="settings-item settings-item-skin" title="Visual theme for this device (not synced)">
@@ -1453,7 +1477,7 @@
<option value="normal">Normal (with prompts)</option>
<option value="allowedTools">Allowed Tools Only</option>
</select>
<span class="form-hint">How Claude CLI is started in screen sessions</span>
<span class="form-hint">How Claude CLI is started in screen sessions. Auto Mode runs without routine prompts behind a background safety classifier (needs Claude Code 2.1.207+ and Opus 4.6+/Sonnet 4.6+/Fable 5)</span>
</div>
<div class="form-row" id="allowedToolsRow" style="display: none;">
<label>Allowed Tools</label>
@@ -1501,6 +1525,14 @@
</label>
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions — ignored when a Claude Model is selected above</span>
</div>
<div class="form-row form-row-switch">
<label>Remote auto-reconnect</label>
<label class="switch">
<input type="checkbox" id="appSettingsRemoteAutoReconnect">
<span class="slider"></span>
</label>
<span class="form-hint">Automatically re-establish remote (SSH) sessions when the connection drops, reattaching to the durable remote tmux session (on by default; bounded backoff)</span>
</div>
<div class="form-row">
<label>Thinking Effort</label>
<select id="appSettingsThinkingEffort" class="form-select">
@@ -1875,7 +1907,7 @@
</div>
<div class="form-row docker-quick-row">
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use.</span>
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
</div>
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
<summary>Container settings (optional, sensible defaults)</summary>
@@ -1993,6 +2025,17 @@
</div>
</div>
</details>
<!-- COD-105 — discover + attach existing remote tmux sessions this Codeman didn't create. -->
<details class="advanced-options" id="remoteDiscoverSection">
<summary>Discover existing sessions</summary>
<div class="advanced-options-content">
<span class="form-hint">Find <code>codeman-*</code> tmux sessions already running on this host (started by the remote's own Codeman or another instance) and attach to one. Attaching shares the session; closing the tab detaches it — it is never killed.</span>
<div class="form-row" style="margin-top: 8px;">
<button type="button" class="btn-toolbar" id="remoteDiscoverBtn" onclick="app.discoverRemoteSessions()">Discover existing sessions</button>
</div>
<div id="remoteDiscoverResults" class="remote-discover-results"></div>
</div>
</details>
</div>
<!-- Docker Tab -->
<div class="modal-tab-content hidden" id="case-docker">
@@ -2479,6 +2522,7 @@
</svg>
<script defer src="constants.js"></script>
<script defer src="i18n.js"></script>
<script defer src="mobile-handlers.js"></script>
<script defer src="voice-input.js"></script>
<script defer src="notification-manager.js"></script>
+29
View File
@@ -43,6 +43,35 @@ const MobileDetection = {
);
},
/**
* Check whether this browser belongs to a handheld device.
*
* Unlike getDeviceType(), this classification must remain stable when a
* foldable changes posture. An unfolded phone can expose a desktop-width
* viewport, but it still needs the same per-device settings that were saved
* while folded. User-Agent Client Hints are preferred where available; the
* legacy token fallback covers Android WebView and iPhone browsers.
*/
isHandheldDevice() {
if (!this.isTouchDevice()) return false;
const userAgent = navigator.userAgent || '';
// Prefer explicit UA form-factor signals. Besides matching real browsers,
// this avoids Chromium emulation reporting userAgentData.mobile=true for
// an iPad/tablet context created with isMobile=true.
if (/iPad|Tablet|Silk|PlayBook|Kindle|Windows NT|CrOS|Macintosh/i.test(userAgent)) {
return false;
}
if (/Android/i.test(userAgent) && !/Mobile/i.test(userAgent)) return false;
if (/Mobi|iPhone|iPod/i.test(userAgent)) return true;
const uaDataMobile = navigator.userAgentData?.mobile;
if (typeof uaDataMobile === 'boolean') return uaDataMobile;
return false;
},
/** Check if device is iOS (iPhone, iPad, iPod) */
isIOS() {
return (
+25 -7
View File
@@ -350,7 +350,8 @@ html.mobile-init .file-browser-panel {
Phone Breakpoint (<430px)
============================================================================ */
@media (max-width: 430px) {
/* Compact header brand on phones — acts as home button */
/* Phone brand collapses to a single "C" home button: hide the wordmark,
keep the tap target */
.header-brand {
padding-right: 0.25rem;
margin-right: 0.2rem;
@@ -358,7 +359,15 @@ html.mobile-init .file-browser-panel {
}
.header-brand .logo {
font-size: 0.7rem;
font-size: 0.85rem;
}
.header-brand .logo .logo-text {
display: none;
}
.header-brand .logo .logo-compact {
display: inline;
}
/* Font controls - compact on phones, visibility controlled by JS */
@@ -459,16 +468,25 @@ html.mobile-init .file-browser-panel {
height: 12px;
}
/* Hide header settings gear, lifecycle log, away digest, and session manager on
mobile - settings moved to toolbar; away digest and the session manager are
secondary controls that don't belong on the cramped phone header (the session
manager stays reachable via the Ctrl+K palette's "Browse all sessions" item).
/* Hide header settings gear, lifecycle log, away digest, session manager, and
file viewer on mobile - settings moved to toolbar; the others are secondary /
desktop-oriented controls that don't belong on the cramped phone header (the
session manager stays reachable via the Ctrl+K palette's "Browse all sessions"
item; the file viewer button is opt-in but its panel is desktop-sized).
(The attachments button is opt-in / default-hidden everywhere via its own
--hidden marker, so it needs no mobile-specific rule here.) */
.btn-icon-header.btn-settings,
.btn-icon-header.btn-lifecycle-log,
.btn-icon-header.btn-away-digest,
.btn-icon-header.btn-session-manager {
.btn-icon-header.btn-session-manager,
.btn-icon-header.btn-file-viewer {
display: none !important;
}
/* The big labeled Admin Panel button is desktop-only (admin-gated, revealed by
admin-ui.js). On phones admins still reach user management via App Settings →
Users, so the cramped header stays minimal. */
.btn-admin-panel {
display: none !important;
}
+4 -2
View File
@@ -330,8 +330,10 @@ class NotificationManager {
if (now - this.lastBrowserNotifTime < BROWSER_NOTIF_RATE_LIMIT_MS) return;
this.lastBrowserNotifTime = now;
const notif = new Notification(`${this.originalTitle}: ${title}`, {
body,
const localizedTitle = window.codemanT?.(title) || title;
const localizedBody = window.codemanT?.(body) || body;
const notif = new Notification(`${this.originalTitle}: ${localizedTitle}`, {
body: localizedBody,
tag, // Groups same-tag notifications
icon: '/favicon.ico',
silent: true, // We handle audio ourselves
+27
View File
@@ -82,6 +82,33 @@ Object.assign(CodemanApp.prototype, {
}
},
// Remote auto-reconnect (COD-108)
_onRemoteSessionReconnected(data) {
const id = this.getShortId(data.sessionId);
this.showToast(`Remote session ${id} reconnected`, 'success');
},
_onRemoteReconnectExhausted(data) {
const sessionId = data.sessionId;
const id = this.getShortId(sessionId);
// Auto-reconnect gave up after the bounded backoff. Surface a manual
// "Reconnect" affordance that re-triggers the attach path (force-reload the
// session, which re-runs the create/attach flow against the durable remote).
this.showToast(`Remote session ${id} dropped — auto-reconnect gave up`, 'error', {
duration: 15000,
action: {
label: 'Reconnect',
onClick: () => {
if (this.sessions && this.sessions.has(sessionId)) {
this.selectSession(sessionId, { forceReload: true });
} else {
this.showToast('Session no longer available', 'warning');
}
},
},
});
},
// Bash tools
_onBashToolStart(data) {
+251 -16
View File
@@ -350,19 +350,60 @@ Object.assign(CodemanApp.prototype, {
return this.run();
},
/** Ensure a newly-created session is visible without waiting for the SSE event.
* The POST response and session:created can arrive in either order, so the
* normal idempotent SSE handler remains the single state-upsert path. */
async _ensureCreatedSessionVisible(sessionId, sessionSnapshot) {
if (!sessionId) return;
let session = sessionSnapshot;
if (!session && !this.sessions?.has(sessionId)) {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`);
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to load the new session');
session = data.data?.session || data.data;
}
if (session?.id) this._onSessionCreated(session);
// session:created normally uses the debounced renderer. The direct POST path
// needs the tab in the DOM before selectSession() marks it active.
this._renderSessionTabsImmediate?.();
},
/** Run using the selected mode (Claude Code, OpenCode, Codex, or Gemini) */
async run() {
const mode = this._runMode || 'claude';
if (mode === 'opencode') {
return this.runOpenCode();
if (this._runInFlight) return;
const startedAt = Date.now();
const minLockMs = Number.isFinite(this._runMinLockMs) ? this._runMinLockMs : 500;
const runBtn = document.getElementById('runBtn');
this._runInFlight = true;
if (runBtn) {
runBtn.disabled = true;
runBtn.setAttribute('aria-busy', 'true');
}
if (mode === 'codex') {
return this.runCodex();
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();
}
return await this.runClaude();
} finally {
const remaining = minLockMs - (Date.now() - startedAt);
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
this._runInFlight = false;
if (runBtn) {
runBtn.disabled = false;
runBtn.removeAttribute('aria-busy');
}
}
if (mode === 'gemini') {
return this.runGemini();
}
return this.runClaude();
},
// Note: `runMode` is an accessor defined via Object.defineProperty at the bottom of
@@ -438,7 +479,7 @@ Object.assign(CodemanApp.prototype, {
btn.append(dirSpan, metaSpan);
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.resumeHistorySession(s.sessionId, s.workingDir);
this.resumeHistorySession(s.sessionId, s.workingDir, s.name);
});
container.appendChild(btn);
}
@@ -551,15 +592,52 @@ Object.assign(CodemanApp.prototype, {
// Name remote/docker tabs with the same w<n>-<case> convention as local
// sessions (quick-start would otherwise auto-generate codeman-<id>).
const startNumber = this._nextCaseSessionStartNumber(caseName);
// Docker (NOT remote): the App Settings Claude Model choice applies — the
// workspace is a real host dir, so quick-start writes it to the case's
// .claude/settings.local.json and the in-container claude reads it.
// Remote quick-starts REJECT modelOverride (the file would land on the
// wrong machine), so never send it there.
let dockerModelOverride;
if (caseData.location === 'docker') {
const dockerGlobalSettings = this.loadAppSettingsFromStorage();
const dockerCaseSettings = this.getCaseSettings(caseName);
const dockerUseOpus1m = dockerCaseSettings.opusContext1m || dockerGlobalSettings.opusContext1mEnabled;
dockerModelOverride = dockerGlobalSettings.claudeModel || (dockerUseOpus1m ? 'opus[1m]' : '');
}
const remoteIds = [];
let driftHandled = false;
for (let i = 0; i < tabCount; i++) {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}` })
const quickStartBody = JSON.stringify({
caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`,
...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {})
});
const data = await res.json();
const doQuickStart = async () => {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: quickStartBody
});
return res.json();
};
let data = await doQuickStart();
// Docker config drift: the host config changed since the container was
// created (CONFLICT from quick-start). Confirm once, recreate, retry.
if (!data.success && data.errorCode === 'CONFLICT' && caseData.location === 'docker' && !driftHandled) {
driftHandled = true;
const recreate = confirm(
`Container config for "${caseName}" changed since its container was created.\n\n` +
'Recreate the container to apply the new config? Workspace files and the ' +
'conversation survive (the conversation resumes on launch).'
);
if (recreate) {
const recRes = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/recreate`, { method: 'POST' });
const recData = await recRes.json();
if (!recData.success) throw new Error(recData.error || 'Failed to recreate container');
data = await doQuickStart();
}
}
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
remoteIds.push(data.data.sessionId);
}
this.terminal.writeln(`\x1b[90m All ${tabCount} remote session(s) ready\x1b[0m`);
@@ -623,6 +701,7 @@ Object.assign(CodemanApp.prototype, {
const sessionIds = [];
for (const result of createResults) {
if (!result.success) throw new Error(result.error);
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
sessionIds.push(result.data.session.id);
}
firstSessionId = sessionIds[0];
@@ -738,6 +817,7 @@ Object.assign(CodemanApp.prototype, {
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
remoteIds.push(data.data.sessionId);
}
if (remoteIds[0]) {
@@ -771,6 +851,7 @@ Object.assign(CodemanApp.prototype, {
const sessionIds = [];
for (const result of createResults) {
if (!result.success) throw new Error(result.error);
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
sessionIds.push(result.data.session.id);
}
@@ -848,6 +929,7 @@ Object.assign(CodemanApp.prototype, {
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start OpenCode');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
// Switch to the new session (don't pre-set activeSessionId — selectSession
// early-returns when IDs match, skipping buffer load and sendResize)
@@ -904,6 +986,7 @@ Object.assign(CodemanApp.prototype, {
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start Codex');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
// Switch to the new session (don't pre-set activeSessionId — selectSession
// early-returns when IDs match, skipping buffer load and sendResize)
@@ -956,6 +1039,7 @@ Object.assign(CodemanApp.prototype, {
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start Gemini');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
if (data.data.sessionId) {
await this.selectSession(data.data.sessionId);
@@ -1958,7 +2042,8 @@ Object.assign(CodemanApp.prototype, {
listEl.innerHTML = exports
.map(e => {
const mb = (e.sizeBytes / 1e6).toFixed(1);
const nm = this.escapeHtml ? this.escapeHtml(e.name) : e.name;
// escapeHtml is the free function from constants.js (never a method on `this`)
const nm = escapeHtml(e.name);
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
<span style="flex-shrink:0;">
@@ -2031,6 +2116,156 @@ Object.assign(CodemanApp.prototype, {
}
},
// ═══════════════════════════════════════════════════════════════
// COD-105 — Discover + attach existing remote tmux sessions
// ═══════════════════════════════════════════════════════════════
/** Read the remote-host fields from the remote-case form into a host payload. */
_readRemoteHostFromForm() {
const hostId = document.getElementById('remoteHostId').value.trim();
const host = document.getElementById('remoteHostAddress').value.trim();
const username = document.getElementById('remoteHostUsername').value.trim();
const portRaw = document.getElementById('remoteHostPort').value.trim();
const identityFile = document.getElementById('remoteHostIdentityFile').value.trim();
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
let port;
if (portRaw) {
const n = Number(portRaw);
if (Number.isInteger(n) && n >= 1 && n <= 65535) port = n;
}
return {
id: hostId,
label: hostId,
host,
username,
...(port ? { port } : {}),
...(identityFile ? { identityFile } : {}),
...(socksProxy ? { socksProxy } : {}),
...(jumpHost ? { jumpHost } : {}),
...(extraSshOptions.length ? { extraSshOptions } : {}),
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
};
},
/**
* Explicit Discover action (Decision A — never auto-runs on host select).
* Saves the host config (idempotent), then queries the host for `codeman-*`
* tmux sessions it didn't create and renders an Attach action per session.
*/
async discoverRemoteSessions() {
const results = document.getElementById('remoteDiscoverResults');
const btn = document.getElementById('remoteDiscoverBtn');
const hostPayload = this._readRemoteHostFromForm();
if (!hostPayload.id || !hostPayload.host || !hostPayload.username) {
this.showToast('Fill in Host ID, address, and username first', 'error');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(hostPayload.id)) {
this.showToast('Invalid Host ID. Use letters, numbers, hyphens, underscores.', 'error');
return;
}
if (btn) btn.disabled = true;
if (results) results.innerHTML = '<div class="form-hint">Discovering…</div>';
try {
// Persist the host so the discovery endpoint can resolve it by id (idempotent).
const hostRes = await fetch('/api/remote-hosts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hostPayload)
});
const hostData = await hostRes.json();
if (!hostData.success && hostData.errorCode !== 'ALREADY_EXISTS') {
throw new Error(hostData.error || 'Failed to save remote host');
}
const res = await fetch(`/api/remote-hosts/${encodeURIComponent(hostPayload.id)}/sessions`);
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Discovery failed');
this._renderDiscoveredSessions(hostPayload.id, data.data.sessions || []);
} catch (err) {
console.error('Discover remote sessions failed:', err);
if (results) results.innerHTML = `<div class="form-hint" style="color: var(--error, #e06c75);">${escapeHtml(err.message)}</div>`;
} finally {
if (btn) btn.disabled = false;
}
},
/** Render the discovered remote sessions with an Attach action each. */
_renderDiscoveredSessions(hostId, sessions) {
const results = document.getElementById('remoteDiscoverResults');
if (!results) return;
if (!sessions.length) {
results.innerHTML = '<div class="form-hint">No <code>codeman-*</code> sessions running on this host (or it is unreachable).</div>';
return;
}
const now = Math.floor(Date.now() / 1000);
const rows = sessions.map(s => {
const ageSecs = Math.max(0, now - (s.created || 0));
const age = ageSecs < 3600 ? `${Math.floor(ageSecs / 60)}m` : ageSecs < 86400 ? `${Math.floor(ageSecs / 3600)}h` : `${Math.floor(ageSecs / 86400)}d`;
// COD-106 — show "shared · N clients" when more than one client is attached
// (genuinely collaborative), else a plain "attached" badge for a single client.
const clients = s.attachedClients != null ? s.attachedClients : s.attached ? 1 : 0;
const attachedBadge =
clients > 1
? `<span class="case-location-badge" style="background: var(--warning, #e5c07b); color: #000;">shared · ${clients} clients</span>`
: clients === 1
? '<span class="case-location-badge" style="background: var(--accent, #61afef);">attached</span>'
: '';
return `
<div class="remote-discover-item">
<div class="remote-discover-info">
<span class="remote-discover-name">${escapeHtml(s.name)} ${attachedBadge}</span>
<span class="form-hint">age ${age} · ${s.windows || 1} window(s)</span>
</div>
<button type="button" class="btn-toolbar" onclick="app.attachDiscoveredSession('${escapeHtml(hostId)}', '${escapeHtml(s.name)}')">Attach</button>
</div>`;
}).join('');
results.innerHTML = rows;
},
/**
* Create a NON-owned session that attaches to a discovered remote tmux session.
* Closing this tab detaches — it never kills the remote session.
*/
async attachDiscoveredSession(hostId, remoteSessionName) {
try {
const createRes = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: 'shell',
name: remoteSessionName,
attachRemoteSession: { hostId, remoteSessionName },
})
});
const createData = await createRes.json();
if (!createData.success) throw new Error(createData.error || 'Failed to create session');
const id = createData.data.session.id;
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
const dims = this.getTerminalDimensions();
if (dims) {
await fetch(`/api/sessions/${id}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dims)
});
}
this.closeCreateCaseModal();
this.showToast(`Attached to ${remoteSessionName} (detach on close)`, 'success');
this.activeSessionId = id;
await this.selectSession(id);
if (this.terminal && typeof this.terminal.focus === 'function') this.terminal.focus();
} catch (err) {
console.error('Attach discovered session failed:', err);
this.showToast('Failed to attach: ' + err.message, 'error');
}
},
// ═══════════════════════════════════════════════════════════════
// Case Management (reorder + delete)
// ═══════════════════════════════════════════════════════════════
+42 -10
View File
@@ -297,6 +297,8 @@ Object.assign(CodemanApp.prototype, {
openAppSettings() {
// Load current settings
const settings = this.loadAppSettingsFromStorage();
document.getElementById('appSettingsDisplayName').value = settings.displayName || 'Codeman';
document.getElementById('appSettingsLanguage').value = settings.language === 'zh-CN' ? 'zh-CN' : 'en';
document.getElementById('appSettingsClaudeMdPath').value = settings.defaultClaudeMdPath || '';
document.getElementById('appSettingsDefaultDir').value = settings.defaultWorkingDir || '';
// Use device-aware defaults for display settings (mobile has different defaults)
@@ -328,7 +330,7 @@ Object.assign(CodemanApp.prototype, {
// Session Manager + Away Digest buttons default OFF; Cron button defaults ON.
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? true;
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
@@ -364,6 +366,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? '';
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? '';
// CPU Priority settings
const niceSettings = settings.nice || {};
@@ -1419,6 +1422,12 @@ Object.assign(CodemanApp.prototype, {
// as "previously off" — used below to detect a real OFF→ON flip.
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
const settings = {
displayName: window.CodemanI18n?.normalizeDisplayName(
document.getElementById('appSettingsDisplayName').value
) || 'Codeman',
language: window.CodemanI18n?.normalizeLanguage(
document.getElementById('appSettingsLanguage').value
) || 'en',
defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(),
defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(),
ralphTrackerEnabled: document.getElementById('appSettingsRalphEnabled').checked,
@@ -1462,6 +1471,7 @@ Object.assign(CodemanApp.prototype, {
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
claudeModel: document.getElementById('appSettingsClaudeModel').value,
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
remoteAutoReconnect: document.getElementById('appSettingsRemoteAutoReconnect').checked,
thinkingEffort: document.getElementById('appSettingsThinkingEffort').value,
// CPU Priority settings
nice: {
@@ -1590,6 +1600,7 @@ Object.assign(CodemanApp.prototype, {
// Apply header visibility immediately
this.applyHeaderVisibilitySettings();
this.applySkin();
this.applyLocalization();
this.applyTabWrapSettings();
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
this.applyMonitorVisibility();
@@ -1618,6 +1629,7 @@ Object.assign(CodemanApp.prototype, {
cjkInputEnabled: _cjk,
extendedKeyboardBar: _ekb,
skin: _skin,
language: _language,
showPlanUsageLimits: _pul,
showAttachmentsButton: _ahb,
showFileViewerButton: _fvb,
@@ -1756,17 +1768,21 @@ Object.assign(CodemanApp.prototype, {
return settings.ralphTrackerEnabled ?? false;
},
// Get the settings storage key based on device type (mobile vs desktop)
// Keep the settings namespace stable across foldable posture changes. Layout
// still follows viewport width, but an unfolded phone remains the same
// handheld device and must not silently switch to desktop preferences.
getSettingsStorageKey() {
const isMobile = MobileDetection.getDeviceType() === 'mobile';
return isMobile ? 'codeman-app-settings-mobile' : 'codeman-app-settings';
const isHandheld =
MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile';
return isHandheld ? 'codeman-app-settings-mobile' : 'codeman-app-settings';
},
// Get default settings based on device type
// Note: Notification prefs are handled separately by NotificationManager
getDefaultSettings() {
const isMobile = MobileDetection.getDeviceType() === 'mobile';
if (isMobile) {
const isHandheld =
MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile';
if (isHandheld) {
// Mobile defaults: minimal UI for small screens
return {
// Header visibility - hide everything on mobile
@@ -1788,7 +1804,9 @@ Object.assign(CodemanApp.prototype, {
showRedrawButton: false,
showSessionButton: false,
showAwayDigestButton: false,
showCronButton: true,
showCronButton: false,
// Remote auto-reconnect (COD-108) — on by default
remoteAutoReconnect: true,
// Input
gestureControlEnabled: false,
// Feature toggles - keep tracking on even on mobile
@@ -1856,6 +1874,19 @@ Object.assign(CodemanApp.prototype, {
if (typeof this.applyTerminalSkin === 'function') this.applyTerminalSkin(skin);
},
// Apply the per-device language and the synced user-facing product name.
// The i18n layer updates both existing static nodes and future dynamic DOM.
applyLocalization() {
const settings = this.loadAppSettingsFromStorage();
const result = window.CodemanI18n?.configure({
language: settings.language,
displayName: settings.displayName,
});
if (result && this.notificationManager) {
this.notificationManager.originalTitle = document.title;
}
},
applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
@@ -1960,8 +1991,8 @@ Object.assign(CodemanApp.prototype, {
awayDigestBtn.classList.toggle('btn-away-digest--hidden', !showAwayDigestButton);
}
// Cron button (footer toolbar) — shown by default; hide when disabled.
const showCronButton = settings.showCronButton ?? defaults.showCronButton ?? true;
// Cron button (footer toolbar) — opt-in, hidden by default. Same marker pattern.
const showCronButton = settings.showCronButton ?? defaults.showCronButton ?? false;
const cronBtn = document.querySelector('.btn-cron');
if (cronBtn) {
cronBtn.classList.toggle('btn-cron--hidden', !showCronButton);
@@ -2176,7 +2207,7 @@ Object.assign(CodemanApp.prototype, {
// so mobile defaults to OFF; the desktop blob is untouched and keeps its value.
try {
if (
MobileDetection.getDeviceType() === 'mobile' &&
(MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile') &&
!localStorage.getItem('codeman:planUsagePerDeviceMigrated')
) {
const s = this.loadAppSettingsFromStorage();
@@ -2204,6 +2235,7 @@ Object.assign(CodemanApp.prototype, {
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'language',
'terminalWheelLocalScrollback',
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
]);
+124 -3
View File
@@ -289,6 +289,11 @@ body {
transition: color var(--transition-smooth), text-shadow var(--transition-smooth), filter var(--transition-smooth);
}
/* Single-letter brand shown only on phones (see mobile.css phone breakpoint) */
.logo .logo-compact {
display: none;
}
.logo:hover {
color: #93c5fd;
text-shadow: 0 0 12px rgba(96, 165, 250, 0.5), 0 0 24px rgba(96, 165, 250, 0.2);
@@ -1002,6 +1007,43 @@ body {
transform: rotate(45deg);
}
/* Admin Panel header button: only shown to admins in multi-user mode (marker
class removed by admin-ui.js after identity boot). Deliberately BIG and
prominent: it is the entry point to user/permission management. */
.btn-admin-panel {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
color: var(--accent-ink);
border: none;
border-radius: var(--btn-radius);
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
box-shadow: 0 0 10px color-mix(in srgb, var(--accent) 45%, transparent);
transition: all var(--transition-smooth);
}
.btn-admin-panel:hover {
filter: brightness(1.1);
box-shadow: 0 0 16px color-mix(in srgb, var(--accent) 65%, transparent);
}
.btn-admin-panel--hidden {
display: none !important;
}
/* Admin user tables (Admin Panel modal + the App Settings Users tab). */
.admin-users th,
.admin-users td {
padding: 5px 12px 5px 0;
vertical-align: top;
border-bottom: 1px solid color-mix(in srgb, var(--text-muted) 20%, transparent);
}
/* Multi-monitor header button: hidden by default (opt-in via App Settings →
Display → "Header Displays"). The server strips this class at render when the
setting is on; the client toggles it live on save. */
@@ -2794,6 +2836,25 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
background: rgba(255, 255, 255, 0.05);
}
/* COD-139: pinned sessions float to the top with a subtle accent highlight. */
.history-item.is-pinned {
border-color: rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.08);
box-shadow: inset 3px 0 0 rgba(245, 158, 11, 0.7);
}
.history-item.is-pinned:hover {
border-color: rgba(245, 158, 11, 0.5);
background: rgba(245, 158, 11, 0.12);
}
.history-item-pin {
margin-right: 0.35rem;
font-size: 0.75rem;
line-height: 1;
vertical-align: baseline;
}
.history-item-main {
display: flex;
align-items: center;
@@ -3091,6 +3152,17 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
transform: translateX(-50%);
}
/* Below ~1500px the absolutely-centered group can land on top of the case
picker (the left group widens with the case name), so fall back to normal
flex flow where overlap is impossible */
@media (max-width: 1500px) {
.toolbar-center {
position: static;
transform: none;
margin-left: 0.4rem;
}
}
.version-display {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.6rem;
@@ -3933,6 +4005,37 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
transition: background var(--transition-smooth);
}
/* COD-105 — discovered remote tmux sessions list (remote-case flow). */
.remote-discover-results {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.remote-discover-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
}
.remote-discover-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.remote-discover-name {
font-weight: 600;
font-size: 0.85rem;
}
.case-manage-item:hover {
background: rgba(255, 255, 255, 0.06);
}
@@ -4443,6 +4546,24 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
text-overflow: ellipsis;
}
.settings-inline-input,
.settings-inline-select {
width: min(190px, 52%);
min-width: 0;
padding: 0.3rem 0.45rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-input);
color: var(--text);
font: inherit;
}
.settings-inline-input:focus,
.settings-inline-select:focus {
outline: none;
border-color: var(--accent);
}
/* Skin picker: full-width row (not squeezed into a half-width grid cell) with an
auto-width select, so the "Skin" label keeps its space instead of truncating. */
.settings-item-skin {
@@ -9519,9 +9640,9 @@ kbd {
display: none !important;
}
/* "Cron" footer-toolbar button — shown by default (App Settings → Display can
hide it). Toolbar button, not a header icon, so only the hide marker is
needed; out-specify any base .btn-toolbar display. */
/* "Cron" footer-toolbar button — opt-in, hidden by default (enable under App
Settings → Display → Header Displays). Toolbar button, not a header icon, so
only the hide marker is needed; out-specify any base .btn-toolbar display. */
.btn-toolbar.btn-cron--hidden {
display: none !important;
}
+94 -17
View File
@@ -1239,8 +1239,10 @@ Object.assign(CodemanApp.prototype, {
const isLive = Array.isArray(s.sources) && s.sources.includes('live');
const isPinned = s.pinned === true;
const item = document.createElement('div');
item.className = 'history-item';
item.className = 'history-item' + (isPinned ? ' is-pinned' : '');
item.title = s.workingDir || '';
// Main row: clickable surface. A caller-supplied onActivate wins (the
@@ -1258,7 +1260,7 @@ Object.assign(CodemanApp.prototype, {
if (isLive && this.sessions.has(s.sessionId)) {
this.selectSession(s.sessionId);
} else {
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '');
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name);
}
})
);
@@ -1268,7 +1270,16 @@ Object.assign(CodemanApp.prototype, {
const titleSpan = document.createElement('span');
titleSpan.className = 'history-item-title';
titleSpan.textContent = s.name || s.firstPrompt || shortDir;
if (isPinned) {
// Filled pin glyph indicating the session is pinned to the top (COD-139).
const pin = document.createElement('span');
pin.className = 'history-item-pin';
pin.textContent = '📌';
pin.setAttribute('aria-label', 'Pinned');
pin.title = 'Pinned';
titleSpan.appendChild(pin);
}
titleSpan.appendChild(document.createTextNode(s.name || s.firstPrompt || shortDir));
// Badge row: mode (claude/codex/opencode/gemini/shell) + a LIVE pill.
const badgeRow = document.createElement('div');
@@ -1327,6 +1338,21 @@ Object.assign(CodemanApp.prototype, {
promptText.textContent = s.firstPrompt || '(no prompt captured)';
promptRow.append(promptLabel, promptText);
// COD-145: show the most recent user prompt too, but collapse single-prompt
// sessions (omit when there's no last prompt or it duplicates the first).
let lastPromptRow = null;
if (s.lastPrompt && s.lastPrompt !== s.firstPrompt) {
lastPromptRow = document.createElement('div');
lastPromptRow.className = 'history-detail-row';
const lastPromptLabel = document.createElement('span');
lastPromptLabel.className = 'history-detail-label';
lastPromptLabel.textContent = 'Last prompt';
const lastPromptText = document.createElement('span');
lastPromptText.className = 'history-detail-value history-detail-prompt';
lastPromptText.textContent = s.lastPrompt;
lastPromptRow.append(lastPromptLabel, lastPromptText);
}
const pathRow = document.createElement('div');
pathRow.className = 'history-detail-row';
const pathLabel = document.createElement('span');
@@ -1345,7 +1371,9 @@ Object.assign(CodemanApp.prototype, {
metaParts.push(s.sessionId.slice(0, 8));
metaRow.textContent = metaParts.join(' · ');
detail.append(promptRow, pathRow, metaRow);
detail.append(promptRow);
if (lastPromptRow) detail.append(lastPromptRow);
detail.append(pathRow, metaRow);
if (showViewAll && s.projectKey) {
const actionRow = document.createElement('div');
@@ -1463,13 +1491,27 @@ Object.assign(CodemanApp.prototype, {
} else {
// Resume by the Claude conversation UUID when present (resumed sessions
// carry theirs separately from their Codeman id).
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '');
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name);
}
this.closeSessionManager?.();
closeMenu();
}
);
// Pin / Unpin (COD-139) — floats the session to the top of the list.
const isPinned = s.pinned === true;
addItem(isPinned ? 'Unpin session' : 'Pin to top', async () => {
const ok = await this._setSessionPinned(s.sessionId, !isPinned);
if (ok) {
// Optimistic local flip so a re-render before the SSE event is consistent.
s.pinned = !isPinned;
this.showToast(!isPinned ? 'Pinned to top' : 'Unpinned', 'success');
} else {
this.showToast('Pin failed', 'error');
}
closeMenu();
});
// Open folder (only for a live+open session — file browser is session-scoped).
if (isLiveOpen) {
addItem('Open folder', () => {
@@ -1536,6 +1578,32 @@ Object.assign(CodemanApp.prototype, {
this._openRowMenuClose = closeMenu;
},
/**
* COD-139: Toggle a session's pin via POST /api/sessions/:id/pin.
* Pinned sessions float to the top of the session manager list. Returns true
* on success. The live re-sort happens when the session:pinned SSE event
* fires (handled in app.js), so callers don't need to re-render themselves.
* @param {string} sessionId
* @param {boolean} pinned explicit desired pin state (idempotent)
* @returns {Promise<boolean>}
*/
async _setSessionPinned(sessionId, pinned) {
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/pin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ pinned }),
});
if (!res.ok) return false;
const data = await res.json();
return data?.success === true;
} catch (err) {
console.error('[_setSessionPinned]', err);
return false;
}
},
/** Number of history items shown before "Show More" */
_HISTORY_INITIAL_COUNT: 4,
@@ -1721,7 +1789,24 @@ Object.assign(CodemanApp.prototype, {
this._folderHistoryState = null;
},
async resumeHistorySession(sessionId, workingDir) {
// Choose the name for a resumed session: keep the session's own name when it
// has one, otherwise synthesize a fresh w<N>-<dir> name (next free w-number
// across open sessions). COD-143 — resume used to always generate a new name.
_resolveResumeName(existingName, workingDir) {
if (typeof existingName === 'string' && existingName.trim()) return existingName;
const dirName = (workingDir || '').split('/').pop() || 'session';
let startNumber = 1;
for (const [, session] of this.sessions) {
const match = session.name && session.name.match(/^w(\d+)-/);
if (match) {
const num = parseInt(match[1]);
if (num >= startNumber) startNumber = num + 1;
}
}
return `w${startNumber}-${dirName}`;
},
async resumeHistorySession(sessionId, workingDir, existingName) {
// Close the run mode menu if open
document.getElementById('runModeMenu')?.classList.remove('active');
// Close folder history modal if open
@@ -1730,17 +1815,9 @@ Object.assign(CodemanApp.prototype, {
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
// Generate a session name from the working dir
const dirName = workingDir.split('/').pop() || 'session';
let startNumber = 1;
for (const [, session] of this.sessions) {
const match = session.name && session.name.match(/^w(\d+)-/);
if (match) {
const num = parseInt(match[1]);
if (num >= startNumber) startNumber = num + 1;
}
}
const name = `w${startNumber}-${dirName}`;
// Keep the session's own name when resuming; only synthesize a w<N>-<dir>
// name when the source row had none (COD-143).
const name = this._resolveResumeName(existingName, workingDir);
// Create session with resumeSessionId — include envOverrides so resumed
// conversations inherit current UI settings (effort, agent teams, etc.).
+95 -1
View File
@@ -9,13 +9,16 @@
* POST /api/admin/users/:username/reset-password
* POST /api/admin/users/:username/logout
* DELETE /api/admin/users/:username
* GET /api/admin/users/:username/cases (list the user's case folders)
* DELETE /api/admin/users/:username/cases/:caseName (delete one case folder)
*
* Self-service GET /api/me + POST /api/me/password live in me-routes.ts.
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { readdirSync } from 'node:fs';
import { readdirSync, promises as fsp } from 'node:fs';
import { isAbsolute, join, relative } from 'node:path';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { isMultiUserMode, userCasesDir } from '../../config/multiuser.js';
import {
@@ -24,6 +27,7 @@ import {
deleteUserSpace,
findUser,
generateOneTimePassword,
normalizeUsername,
readUsers,
setPassword,
toPublicUser,
@@ -50,6 +54,10 @@ const UpdateUserSchema = z.object({
});
const DeleteUserSchema = z.object({ deleteSpace: z.boolean().optional() });
// Case-folder names: a single path segment, no separators, no leading dot (hidden
// dirs like .claude are infrastructure, not cases), so '.'/'..' are excluded too.
const SAFE_CASE_NAME = /^[^./\\][^/\\]{0,127}$/;
/** Map a UserStoreError's code onto the API error code + status. */
function storeError(reply: FastifyReply, err: unknown): ReturnType<typeof createErrorResponse> {
if (err instanceof UserStoreError) {
@@ -201,4 +209,90 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
return storeError(reply, err);
}
});
// Count live sessions whose workingDir sits inside `dir` (any owner: a folder
// in use by ANYONE must not be deleted out from under a running agent).
const liveSessionsInside = (dir: string): number => {
let n = 0;
for (const s of ctx.sessions.values()) {
const rel = relative(dir, s.workingDir || '');
if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) n++;
}
return n;
};
app.get('/api/admin/users/:username/cases', async (req, reply) => {
if (!gate(req, reply)) return;
const username = normalizeUsername((req.params as { username: string }).username);
if (!(await findUser(username))) {
reply.code(404);
return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'No such user');
}
const dir = userCasesDir(username);
let cases: { name: string; modifiedAt: number; liveSessions: number }[] = [];
try {
const entries = await fsp.readdir(dir, { withFileTypes: true });
cases = await Promise.all(
entries
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
.map(async (e) => {
const p = join(dir, e.name);
const st = await fsp.stat(p).catch(() => null);
return { name: e.name, modifiedAt: st ? Math.floor(st.mtimeMs) : 0, liveSessions: liveSessionsInside(p) };
})
);
} catch {
/* no cases dir yet */
}
cases.sort((a, b) => b.modifiedAt - a.modifiedAt);
return { success: true, data: { dir, cases } };
});
app.delete('/api/admin/users/:username/cases/:caseName', async (req, reply) => {
if (!gate(req, reply)) return;
const params = req.params as { username: string; caseName: string };
const username = normalizeUsername(params.username);
if (!(await findUser(username))) {
reply.code(404);
return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'No such user');
}
if (!SAFE_CASE_NAME.test(params.caseName)) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
// Same guard rails as deleteUserSpace: never follow a symlink, and the
// realpath must stay strictly inside the user's cases dir.
const root = userCasesDir(username);
const target = join(root, params.caseName);
let lst;
try {
lst = await fsp.lstat(target);
} catch {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'No such case folder');
}
if (lst.isSymbolicLink() || !lst.isDirectory()) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Refusing to delete: not a plain directory');
}
const realRoot = await fsp.realpath(root).catch(() => root);
const realTarget = await fsp.realpath(target);
const rel = relative(realRoot, realTarget);
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Case folder escapes the user space');
}
const inUse = liveSessionsInside(target) || liveSessionsInside(realTarget);
if (inUse > 0) {
reply.code(409);
return createErrorResponse(
ApiErrorCode.CONFLICT,
`Case folder is in use by ${inUse} live session(s), close them first`
);
}
await fsp.rm(realTarget, { recursive: true, force: true });
audit(req, 'user.case-delete', username, { caseName: params.caseName });
ctx.broadcast(SseEvent.AdminUsersChanged, {});
return { success: true, data: { username, caseName: params.caseName } };
});
}
+95 -22
View File
@@ -11,7 +11,7 @@ import fs from 'node:fs/promises';
import { join, resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import type { ApiResponse, CaseInfo, DockerHost, SessionDocker } from '../../types.js';
import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocker } from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import {
CreateCaseSchema,
@@ -43,7 +43,7 @@ import {
import { isMultiUserMode } from '../../config/multiuser.js';
import type { AuthUser } from '../../types.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort } from '../ports/index.js';
import type { EventPort, ConfigPort, SessionPort } from '../ports/index.js';
import type { FastifyRequest } from 'fastify';
import { dataPath, getDataDir } from '../../config/instance.js';
import {
@@ -56,6 +56,7 @@ import {
dockerDisplayPath,
readDockerCases,
readDockerHosts,
removeDockerContainer,
toSessionDocker,
writeDockerCases,
writeDockerHosts,
@@ -63,6 +64,7 @@ import {
import { buildDockerRemoveCommand } from '../../tmux-manager.js';
import {
checkRemoteTmuxAvailable,
listRemoteCodemanSessions,
readRemoteCases,
readRemoteHosts,
remoteDisplayPath,
@@ -118,7 +120,7 @@ async function ensureCaseImage(
sessionDocker: SessionDocker,
name: string
): Promise<{ ok: true; imageBuilding: boolean } | { ok: false; error: string }> {
if (await checkDockerImagePresent(sessionDocker.engine, sessionDocker.image)) {
if (await checkDockerImagePresent(sessionDocker, sessionDocker.image)) {
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
if (!tmuxCheck.ok) return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' };
return { ok: true, imageBuilding: false };
@@ -130,7 +132,7 @@ async function ensureCaseImage(
};
}
broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image });
void ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
void ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }),
})
.then((r) =>
@@ -146,7 +148,7 @@ async function ensureCaseImage(
return { ok: true, imageBuilding: true };
}
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort & SessionPort): void {
// ═══════════════════════════════════════════════════════════════
// Case CRUD (list, create, link, detail, fix-plan)
// ═══════════════════════════════════════════════════════════════
@@ -311,6 +313,26 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
? null
: (reply.code(403), createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'));
// COD-105 — discover `codeman-*` tmux sessions already running on a remote
// host (created by the remote's own Codeman, another instance, or this one)
// so the operator can attach to one this Codeman didn't launch. Explicit
// trigger only (Decision A): the frontend calls this on a "Discover" click,
// never automatically on host select. listRemoteCodemanSessions never throws
// (returns [] on unreachable/no-tmux/no-sessions) and is ssh-guarded under test.
// Hosts are admin-only infra in multi-user mode, so discovery is too.
app.get(
'/api/remote-hosts/:hostId/sessions',
async (req, reply): Promise<ApiResponse<{ sessions: RemoteSessionInfo[] }>> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
const { hostId } = req.params as { hostId: string };
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
const sessions = await listRemoteCodemanSessions(host);
return { success: true, data: { sessions } };
}
);
app.post('/api/remote-hosts', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
const denied = adminOnly(req, reply);
if (denied) return denied;
@@ -727,29 +749,39 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const timestamp = Date.now();
let result;
try {
result = await importDockerBundle({ bundlePath, destWorkspace: destWorkspacePath, engine: 'docker', timestamp });
result = await importDockerBundle({
bundlePath,
destWorkspace: destWorkspacePath,
engine: 'docker',
timestamp,
newCaseName,
});
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Import failed: ${getErrorMessage(err)}`);
}
// Create a dedicated docker host pointing at the quarantined imported image
// (full mode) or the manifest's base image (workspace-only).
// Create (or REFRESH) the dedicated docker host pointing at the quarantined
// imported image (full mode) or the manifest's base image (workspace-only).
// Refresh matters: after a case-delete + re-import of the same name, a stale
// `imported-<name>` host would silently pin the PREVIOUS import's image tag.
const hostId = `imported-${newCaseName}`;
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
if (!hosts.some((h) => h.id === hostId)) {
await writeDockerHosts(CODEMAN_CONFIG_DIR, [
...hosts,
{
id: hostId,
label: `Imported: ${newCaseName}`,
engine: result.manifest.engine,
image: result.importedImage ?? result.manifest.image,
network: (['bridge', 'none', 'custom'].includes(result.manifest.network)
? result.manifest.network
: 'bridge') as 'bridge' | 'none' | 'custom',
},
]);
}
const importedHost = {
id: hostId,
label: `Imported: ${newCaseName}`,
engine: result.manifest.engine,
image: result.importedImage ?? result.manifest.image,
network: (['bridge', 'none', 'custom'].includes(result.manifest.network) ? result.manifest.network : 'bridge') as
| 'bridge'
| 'none'
| 'custom',
};
await writeDockerHosts(
CODEMAN_CONFIG_DIR,
hosts.some((h) => h.id === hostId)
? hosts.map((h) => (h.id === hostId ? { ...h, ...importedHost } : h))
: [...hosts, importedHost]
);
const newCase = {
name: newCaseName,
type: 'docker' as const,
@@ -763,6 +795,47 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return { success: true, data: { case: newCase } };
});
// Recreate-on-drift confirm (docs/docker-cases-plan.md §4): remove the case
// container so the next launch recreates it with the CURRENT host config. The
// workspace + transcripts ride bind mounts and survive; the conversation resumes
// via the case's lastClaudeSessionId. Refused while sessions of the case are live
// (removal would yank the container out from under their panes).
app.post(
'/api/docker-cases/:name/recreate',
async (req): Promise<ApiResponse<{ name: string; container: string }>> => {
const { name } = req.params as { name: string };
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const sessionDocker = toSessionDocker(host, dockerCase);
for (const session of ctx.sessions.values()) {
if (session.docker?.containerName === sessionDocker.containerName && session.pid) {
return createErrorResponse(
ApiErrorCode.CONFLICT,
`Sessions of case "${name}" are still running — stop them first, then recreate the container.`
);
}
}
try {
await removeDockerContainer(sessionDocker);
} catch (err) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`Failed to remove container: ${getErrorMessage(err)}`
);
}
// Drop the per-container claude-config seed; it is regenerated at next launch.
await fs
.rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true })
.catch(() => {});
ctx.broadcast(SseEvent.DockerContainerRecreated, { name, container: sessionDocker.containerName });
return { success: true, data: { name, container: sessionDocker.containerName } };
}
);
// Link an existing folder as a case
app.post('/api/cases/link', async (req, reply): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
// Linking writes an arbitrary absolute path into the shared ownerless registry:
+13
View File
@@ -8,6 +8,8 @@ import { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
import { sanitizeHookData, parseBody } from '../route-helpers.js';
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
export function registerHookEventRoutes(
@@ -48,7 +50,18 @@ export function registerHookEventRoutes(
// the user ran `/clear` (which spins up a new conversation jsonl).
if (data && typeof data.session_id === 'string' && data.session_id) {
const session = ctx.sessions.get(sessionId);
const prevClaudeSessionId = session?.claudeSessionId;
session?.adoptClaudeSessionId(data.session_id);
// Docker sessions: keep the case's resume seed following the LIVE
// conversation (post-/clear id switches), so a container stop/reboot
// relaunch resumes the right transcript.
if (session?.docker && session.claudeSessionId && session.claudeSessionId !== prevClaudeSessionId) {
void persistDockerCaseClaudeSessionId(
getDataDir(),
session.docker.containerName,
session.claudeSessionId
).catch(() => {});
}
}
// Sanitize forwarded data: only include known safe fields, limit size
+198 -4
View File
@@ -32,12 +32,15 @@ import {
AutoClearSchema,
AutoCompactSchema,
AutoResumeSchema,
PinSessionSchema,
ImageWatcherSchema,
FlickerFilterSchema,
QuickRunSchema,
QuickStartSchema,
InteractiveStartSchema,
SessionOrderUpdateSchema,
} from '../schemas.js';
import { mergeSessionOrder } from '../../session-order.js';
import {
autoConfigureRalph,
canAccessOwned,
@@ -83,12 +86,20 @@ import { RunSummaryTracker } from '../../run-summary.js';
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
import { dataPath, getDataDir } from '../../config/instance.js';
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
import {
checkRemoteTmuxAvailable,
readRemoteCases,
readRemoteHosts,
toAttachedSessionRemote,
toSessionRemote,
} from '../../remote-hosts.js';
import {
checkDockerAvailable,
checkDockerConfigDrift,
checkDockerTmuxAvailable,
ensureAgentBaseImage,
DEFAULT_AGENT_IMAGE,
persistDockerCaseClaudeSessionId,
readDockerCases,
readDockerHosts,
toSessionDocker,
@@ -326,6 +337,18 @@ export function registerSessionRoutes(
return (list as Array<{ owner?: string }>).filter((s) => canAccessOwned(user, s.owner));
});
// ========== Session Tab Order (global sync, COD-131) ==========
app.put('/api/session-order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order');
// Server is authoritative but never drops ids it knows about that the
// pushing device hadn't loaded yet — those fall to the end (mergeSessionOrder).
const merged = mergeSessionOrder(order, ctx.store.getSessionOrder());
ctx.store.setSessionOrder(merged);
ctx.broadcast(SseEvent.SessionOrderChanged, { order: merged });
return { success: true, data: { order: merged } };
});
// ========== Session Creation ==========
app.post('/api/sessions', async (req) => {
@@ -335,7 +358,21 @@ export function registerSessionRoutes(
if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
const body = parseBody(CreateSessionSchema, req.body);
const workingDir = body.workingDir || process.cwd();
let workingDir = body.workingDir || process.cwd();
let remote = undefined;
// COD-105 — attach to a discovered (non-owned) remote tmux session. The
// remote session is already running, so we skip the tmux-prereq probe and
// build a NON-owned SessionRemote (detach-not-kill on close). Remote CASE
// creation (owned durable sessions) is handled by the dedicated case-create
// endpoint below, which #145 consolidated remote-host resolution into.
if (body.attachRemoteSession) {
const { hostId, remoteSessionName } = body.attachRemoteSession;
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
workingDir = `${host.username}@${host.host}:${remoteSessionName}`;
remote = toAttachedSessionRemote(host, remoteSessionName, workingDir);
}
// Multi-user: shell mode is arbitrary command execution as the host account,
// gated behind the same grant as bypass (section 6.3). Resolve the owner's grant
@@ -514,6 +551,7 @@ export function registerSessionRoutes(
envOverrides: body.envOverrides,
effort: body.effort,
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
remote,
owner,
});
@@ -999,6 +1037,12 @@ export function registerSessionRoutes(
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
if (activeId && activeId !== session.claudeSessionId) {
session.adoptClaudeSessionId(activeId);
// Docker sessions: keep the case's resume seed following the live conversation.
if (session.docker) {
void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch(
() => {}
);
}
}
// The Claude conversation ID (used as JSONL filename)
@@ -1623,6 +1667,50 @@ export function registerSessionRoutes(
};
});
// ========== Pin (float to top of the session manager list, COD-139) ==========
app.post('/api/sessions/:id/pin', async (req) => {
const { id } = req.params as { id: string };
const body = parseBody(PinSessionSchema, req.body, 'Invalid request body');
const session = ctx.sessions.get(id);
if (session) {
if (!canAccessOwned(getAuthUser(req), session.owner)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
}
session.setPinned(body.pinned);
// Persist + broadcast session:updated (keeps tabs/state consistent), then a
// dedicated session:pinned event so the session manager list re-sorts live.
persistAndBroadcastSession(ctx, session);
ctx.broadcast(SseEvent.SessionPinned, {
id,
pinned: session.pinned,
pinnedAt: session.pinnedAt ?? undefined,
});
return {
success: true,
data: {
pinned: session.pinned,
pinnedAt: session.pinnedAt ?? undefined,
},
};
}
// COD-142 keeps a pinned session's record after kill (demoteOrRemoveSession),
// so pin toggles must also work WITHOUT a live Session — otherwise a
// pinned-then-killed record could never be unpinned (cleanup skips pinned
// records, and the record has no live session to route through).
const persisted = ctx.store.getSession(id);
if (!persisted || !canAccessOwned(getAuthUser(req), persisted.owner)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
}
const pinnedAt = body.pinned ? Date.now() : undefined;
ctx.store.setSession(id, { ...persisted, pinned: body.pinned || undefined, pinnedAt });
ctx.broadcast(SseEvent.SessionPinned, { id, pinned: body.pinned, pinnedAt });
return { success: true, data: { pinned: body.pinned, pinnedAt } };
});
// ========== Image Watcher ==========
app.post('/api/sessions/:id/image-watcher', async (req) => {
@@ -1753,6 +1841,7 @@ export function registerSessionRoutes(
caseName = 'testcase',
sessionName,
mode = 'claude',
modelOverride,
openCodeConfig,
codexConfig,
geminiConfig,
@@ -1798,13 +1887,14 @@ export function registerSessionRoutes(
if (
(envOverrides && Object.keys(envOverrides).length > 0) ||
effort ||
modelOverride !== undefined ||
codexConfig ||
geminiConfig ||
openCodeConfig
) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'envOverrides, effort, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
'envOverrides, effort, modelOverride, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
);
}
@@ -1849,7 +1939,7 @@ export function registerSessionRoutes(
// Ensure the base image exists, auto-building the default image on first use so
// it is never a blocker. Dedup'd with any build kicked off at case-create, so
// this awaits the SAME in-flight build rather than starting a second one.
const ensured = await ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
});
if (!ensured.ok) {
@@ -1868,6 +1958,19 @@ export function registerSessionRoutes(
}
}
// Config drift (docs/docker-cases-plan.md §4): the desired create-config no
// longer matches the existing container's codeman.confighash label. Refuse to
// silently launch into the stale container — the frontend confirms a recreate
// (POST /api/docker-cases/:name/recreate; workspace + transcripts ride bind
// mounts and the conversation resumes), or the user reverts the host edit.
const drift = await checkDockerConfigDrift(sessionDocker);
if (drift.exists && drift.drifted) {
return createErrorResponse(
ApiErrorCode.CONFLICT,
`Container config for case "${dockerCase.name}" changed since the container was created. Recreate the container to apply it (workspace and conversation survive), or revert the docker host edit.`
);
}
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
docker = sessionDocker;
// Seed resume so a relaunch resumes the case's last conversation from the
@@ -1992,6 +2095,13 @@ export function registerSessionRoutes(
}
}
// Model override → <case>/.claude/settings.local.json (claude-mode; local AND
// docker — the docker workspace is a real host dir, so the settings file crosses
// the bind mount and the in-container claude reads it). Remote was rejected above.
if (mode === 'claude' && modelOverride !== undefined) {
await updateCaseModel(resolvedCasePath, modelOverride || null);
}
// Strip stale disk entries for keys this request is actively setting (Claude only —
// see POST /api/sessions for full rationale).
if (
@@ -2096,6 +2206,19 @@ export function registerSessionRoutes(
}
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
// Docker + claude: the pane command pins the conversation id (--session-id /
// --resume, claudeDockerPaneCommand), so persist it as the case's resume seed
// NOW — a later container stop/reboot relaunch resumes this conversation even
// if no in-container hook ever reaches the host (loopback bind, no bridge
// listener). Hook/last-response adoption updates it again after /clear.
if (docker && mode === 'claude') {
void persistDockerCaseClaudeSessionId(
CODEMAN_CONFIG_DIR,
docker.containerName,
session.claudeSessionId || session.id
).catch(() => {});
}
// Save lastUsedCase to settings for TUI/web sync
try {
const settingsFilePath = SETTINGS_PATH;
@@ -2185,6 +2308,59 @@ export function registerSessionRoutes(
return undefined;
}
/**
* Extract the text of the LAST user message from a JSONL transcript chunk
* (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
* detection, same noise/secret/slash-command filters, same 120-char cap — but
* keeps the last qualifying match instead of returning on the first. Scan the
* file tail for this (the most recent prompt lives near the end).
*/
function extractLastUserPrompt(text: string): string | undefined {
const MAX_PROMPT_LEN = 120;
let result: string | undefined;
let start = 0;
while (start < text.length) {
const end = text.indexOf('\n', start);
const line = end === -1 ? text.slice(start) : text.slice(start, end);
start = end === -1 ? text.length : end + 1;
if (!line.includes('"type":"user"')) continue;
try {
const entry = JSON.parse(line);
if (entry.type !== 'user' || !entry.message) continue;
const content = entry.message.content;
let msgText: string | undefined;
if (typeof content === 'string') {
msgText = content;
} else if (Array.isArray(content)) {
const textBlock = content.find((b: { type: string }) => b.type === 'text');
if (textBlock) msgText = textBlock.text;
}
if (!msgText) continue;
msgText = msgText
.replace(/<[^>]+>/g, '')
.replace(new RegExp(String.raw`\x1b\[[0-9;]*[a-zA-Z]`, 'g'), '')
.trim()
.replace(/\s+/g, ' ');
if (!msgText) continue;
if (
/^(Caveat:|init\b|clear\b|resume\b|\/[a-z][\w-]*\b|You are a |\[Request |Set model to )/i.test(msgText) ||
/^(Please )?(analyze|review) this codebase/i.test(msgText) ||
/^(Read|Implement the following) .+, then (search|list|check) /i.test(msgText) ||
/^\d+ vulnerabilit/i.test(msgText) ||
/\btoolu_/.test(msgText) ||
/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+/.test(msgText) ||
/\b(sk-ant-|ANTHROPIC_API_KEY|API_KEY=|SECRET|TOKEN=)/i.test(msgText) ||
msgText.length < 8
)
continue;
result = msgText.length > MAX_PROMPT_LEN ? msgText.slice(0, MAX_PROMPT_LEN) + '…' : msgText;
} catch {
// Malformed line — skip
}
}
return result;
}
/**
* Decode a Claude project key (e.g. "-Users-teigen-Documents-Workspace-AI-project-Mirror")
* back to a filesystem path ("/Users/teigen/Documents/Workspace/AI_project/Mirror").
@@ -2323,6 +2499,7 @@ export function registerSessionRoutes(
sizeBytes: number;
lastModified: string;
firstPrompt?: string;
lastPrompt?: string;
};
// Scan a single project directory and return all valid history sessions in it.
@@ -2368,6 +2545,17 @@ export function registerSessionRoutes(
if (tail) firstPrompt = extractFirstUserPrompt(tail);
}
// COD-145: last (most recent) user prompt lives near the END of the file, so
// prefer the tail. For large files where no tail was read yet, read one
// (mirrors the firstPrompt > 65536 block). Small files fit in `head`, which
// then contains the whole transcript — scan it for the last match instead.
if (!tail && fileStat.size > 65536) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
}
const lastPrompt =
(tail ? extractLastUserPrompt(tail) : undefined) ?? (head ? extractLastUserPrompt(head) : undefined);
out.push({
sessionId,
workingDir,
@@ -2375,6 +2563,7 @@ export function registerSessionRoutes(
sizeBytes: fileStat.size,
lastModified: fileStat.mtime.toISOString(),
firstPrompt,
lastPrompt,
});
}
return out;
@@ -2450,6 +2639,8 @@ export function registerSessionRoutes(
createdAt: st.createdAt,
lastActivityAt: st.lastActivityAt,
claudeSessionId: s.claudeSessionId ?? undefined,
pinned: st.pinned,
pinnedAt: st.pinnedAt,
};
});
@@ -2465,6 +2656,8 @@ export function registerSessionRoutes(
createdAt: p.createdAt,
lastActivityAt: p.lastActivityAt,
claudeSessionId: p.resumeSessionId,
pinned: p.pinned,
pinnedAt: p.pinnedAt,
}));
// Lifecycle audit log (newest-first, capped).
@@ -2498,6 +2691,7 @@ export function registerSessionRoutes(
sizeBytes: h.sizeBytes,
lastModified: h.lastModified,
firstPrompt: h.firstPrompt,
lastPrompt: h.lastPrompt,
projectKey: h.projectKey,
});
}
+60
View File
@@ -192,6 +192,22 @@ export const CreateSessionSchema = z.object({
.max(100)
.regex(/^[a-f0-9-]+$/, 'resumeSessionId must be a valid UUID')
.optional(),
/**
* COD-105 — attach to an EXISTING remote tmux session discovered via
* `GET /api/remote-hosts/:hostId/sessions` (one this Codeman didn't create).
* The resulting session is NON-owned (closing it detaches, never kills the
* remote). `remoteSessionName` is a discovered `codeman-*` tmux session name.
*/
attachRemoteSession: z
.object({
hostId: z.string().min(1).max(200),
remoteSessionName: z
.string()
.min(1)
.max(200)
.regex(/^codeman-[a-zA-Z0-9._-]+$/, 'remoteSessionName must be a codeman-* tmux session name'),
})
.optional(),
});
/**
@@ -449,17 +465,22 @@ export const DockerHostSchema = z.object({
export const DockerCaseLinkSchema = z.object({
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
// No commas: the path is embedded in a `--mount type=bind,src=<path>,dst=<path>`
// CSV spec, and docker's --mount parser splits fields on commas (shell escaping
// cannot protect it). Spaces are fine.
hostWorkspacePath: z
.string()
.min(1)
.max(2000)
.regex(/^\//, 'Workspace path must be absolute')
.regex(/^[^,]*$/, 'Workspace path must not contain commas (docker --mount is comma-delimited)')
.regex(NO_SHELL_META, 'Invalid characters in workspace path'),
containerWorkdir: z
.string()
.min(1)
.max(2000)
.regex(/^\//, 'Container workdir must be absolute')
.regex(/^[^,]*$/, 'Container workdir must not contain commas (docker --mount is comma-delimited)')
.regex(NO_SHELL_META, 'Invalid characters in container workdir')
.optional(),
container: z
@@ -487,6 +508,7 @@ export const DockerImportSchema = z.object({
.min(1)
.max(2000)
.regex(/^\//, 'Destination path must be absolute')
.regex(/^[^,]*$/, 'Destination path must not contain commas (docker --mount is comma-delimited)')
.regex(NO_SHELL_META, 'Invalid characters in destination path'),
});
@@ -539,6 +561,11 @@ export const QuickStartSchema = z.object({
/** Display name for the created session tab (e.g. w1-mycase). Cosmetic; the durable
* mux/container names derive from the session id, not this. Defaults server-side. */
sessionName: z.string().max(128).optional(),
/** Model override written to <case>/.claude/settings.local.json (e.g. "opus[1m]").
* Empty string clears. Applied for local AND docker cases (the docker workspace is
* a real host dir, so the settings file crosses the bind mount); rejected for
* remote cases (the file would be written on the WRONG machine). */
modelOverride: z.string().max(50).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
@@ -623,6 +650,22 @@ const NotificationEventSchema = z
export const SettingsUpdateSchema = z
.object({
// User-facing product branding. This changes browser/UI copy only; package,
// CLI, API, storage, and protocol identifiers remain Codeman.
displayName: z
.string()
.trim()
.min(1)
.max(40)
.refine(
(value) =>
Array.from(value).every((character) => {
const codePoint = character.codePointAt(0);
return codePoint !== undefined && codePoint > 31 && codePoint !== 127;
}),
'Display name must not contain control characters'
)
.optional(),
// Paths
defaultClaudeMdPath: z.string().max(500).optional(),
defaultWorkingDir: z.string().max(500).optional(),
@@ -647,6 +690,10 @@ export const SettingsUpdateSchema = z
/** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */
claudeModel: z.string().max(50).optional(),
opusContext1mEnabled: z.boolean().optional(),
// COD-108 remote-session auto-reconnect kill-switch (default ON). When false,
// the TmuxManager watcher does nothing — dropped remote sessions are NOT
// auto-reattached.
remoteAutoReconnect: z.boolean().optional(),
thinkingEffort: z.string().max(20).optional(),
// UI visibility
showFontControls: z.boolean().optional(),
@@ -836,6 +883,11 @@ export const AutoResumeSchema = z.object({
enabled: z.boolean(),
});
/** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */
export const PinSessionSchema = z.object({
pinned: z.boolean(),
});
/** POST /api/sessions/:id/image-watcher */
export const ImageWatcherSchema = z.object({
enabled: z.boolean(),
@@ -938,6 +990,14 @@ export const CaseOrderSchema = z.object({
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
});
/** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */
export const SessionOrderUpdateSchema = z.object({
// Bounded defensively: ids are uuid-ish (<=100 chars) and the client pushes only
// its open-tab order (max sessions is 50) — 500 leaves ample headroom while
// keeping a hostile/buggy client from persisting megabytes into state.json.
order: z.array(z.string().max(100)).max(500),
});
/** POST /api/auth/revoke */
export const RevokeSessionSchema = z.object({
sessionToken: z.string().min(1).max(200).optional(),
+73 -3
View File
@@ -288,7 +288,7 @@ export class WebServer extends EventEmitter {
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
private readonly titleHostname: string;
private readonly windowTitle: string;
private windowTitle: string;
private readonly indexHtmlTemplate: string;
private readonly allowUnauthenticatedNetwork: boolean;
private _pasteImageGcStop: (() => void) | null = null;
@@ -359,6 +359,22 @@ export class WebServer extends EventEmitter {
this.broadcast(SseEvent.MuxStatsUpdated, sessions);
});
// COD-108 — remote-session auto-reconnect. The TmuxManager watcher detects a
// dead remote pane and emits `remoteSessionDropped`; the session owner (here)
// reassembles the respawn options and reattaches via Session.reattachRemote()
// (D1: the watcher does NOT reassemble options itself). On success we reset
// the watcher's backoff; on failure the backoff schedules the next attempt.
this.mux.on('remoteSessionDropped', (data) => {
const { sessionId, attempt } = data as { sessionId: string; attempt: number };
this.broadcast(SseEvent.RemoteSessionDropped, { sessionId, attempt });
void this.handleRemoteSessionDropped(sessionId);
});
this.mux.on('remoteReconnectExhausted', (data) => {
const { sessionId } = data as { sessionId: string };
console.warn(`[Server] Remote auto-reconnect exhausted for session ${sessionId}`);
this.broadcast(SseEvent.RemoteReconnectExhausted, { sessionId });
});
// Set up subagent watcher listeners
this.setupSubagentWatcherListeners();
this.setupWorkflowRunWatcherListeners();
@@ -1196,7 +1212,7 @@ export class WebServer extends EventEmitter {
// Only remove from state.json if we're also killing the mux session.
// When killMux=false (server shutdown), preserve state for recovery.
if (killMux) {
this.store.removeSession(sessionId);
this.store.demoteOrRemoveSession(sessionId);
}
}
@@ -1204,6 +1220,17 @@ export class WebServer extends EventEmitter {
}
private async renderIndexHtml(soloSessionId?: string): Promise<string> {
// Detached-session windows intentionally skip App Settings during server
// rendering (their client bootstrap loads the synced name moments later).
const persistedSettings: Record<string, unknown> = soloSessionId ? {} : await this.readSettings(true);
const configuredDisplayName =
typeof persistedSettings.displayName === 'string' ? persistedSettings.displayName.trim() : '';
const displayName = configuredDisplayName || 'Codeman';
// Solo renders read no settings; recomputing here would reset the shared
// push-notification prefix (hostTitle) to the default name.
if (!soloSessionId) {
this.windowTitle = `${displayName === 'Codeman' ? 'codeman' : displayName}:${this.titleHostname}`;
}
let html = this.indexHtmlTemplate.replace(
'<title>Codeman</title>',
`<title>${escapeHtmlText(this.windowTitle)}</title>`
@@ -1217,7 +1244,7 @@ export class WebServer extends EventEmitter {
// moments ago triggers a reload here, and the cached value would render the
// pre-toggle state (e.g. the gesture bundle wouldn't inject until a 2nd
// reload). Skipped for solo popups (their header differs).
const settings: Record<string, unknown> = soloSessionId ? {} : await this.readSettings(true);
const settings: Record<string, unknown> = soloSessionId ? {} : persistedSettings;
// Multi-monitor header button: carries the `btn-multimonitor--hidden` class
// in the template by default (App Settings → Display → "Header Displays");
// reveal by stripping that class when the user enabled it. Matching a unique
@@ -1855,6 +1882,7 @@ export class WebServer extends EventEmitter {
timestamp: now,
inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON',
planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load
sessionOrder: this.store.getSessionOrder(), // global tab order, synced across devices (COD-131)
};
this.cachedLightState = { data: result, timestamp: now };
@@ -2603,6 +2631,13 @@ export class WebServer extends EventEmitter {
(this.mux as { startMouseModeSync: (ms?: number) => void }).startMouseModeSync();
}
// 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.
if ('startRemoteReconnectWatcher' in this.mux) {
(this.mux as { startRemoteReconnectWatcher: (ms?: number) => void }).startRemoteReconnectWatcher();
}
if (dead.length > 0) {
console.log(`[Server] Cleaned up ${dead.length} dead mux session(s)`);
}
@@ -2611,6 +2646,41 @@ export class WebServer extends EventEmitter {
}
}
/**
* COD-108 — handle a `remoteSessionDropped` emit from the watcher: reattach
* the dropped remote session and report the outcome back to the watcher so it
* can reset/advance its backoff. Re-running the idempotent remote command
* REATTACHES the durable remote tmux session (does NOT recreate it).
*/
private async handleRemoteSessionDropped(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
// No live Session object (e.g. detached/restored-but-not-attached) — nothing
// to drive the reattach; report failure so the watcher backs off and retries.
if (!session) {
this.noteRemoteReconnect(sessionId, false);
return;
}
let ok = false;
try {
ok = await session.reattachRemote();
} catch (err) {
console.error(`[Server] Remote reattach failed for ${sessionId}:`, err);
ok = false;
}
this.noteRemoteReconnect(sessionId, ok);
if (ok) {
this.persistSessionState(session);
this.broadcast(SseEvent.RemoteSessionReconnected, { sessionId });
}
}
/** Forward a reattach outcome to the TmuxManager watcher (resets/clears backoff). */
private noteRemoteReconnect(sessionId: string, success: boolean): void {
if ('noteRemoteReconnect' in this.mux) {
(this.mux as { noteRemoteReconnect: (id: string, ok: boolean) => void }).noteRemoteReconnect(sessionId, success);
}
}
private initOrchestratorLoop(): import('../orchestrator-loop.js').OrchestratorLoop {
if (this._orchestratorLoop) return this._orchestratorLoop;
+33 -3
View File
@@ -5,27 +5,31 @@
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
* Both files MUST be kept in sync.
*
* 120 event constants organized by category:
* 148 event constants organized by category:
* - **Core** (1): init
* - **Session lifecycle** (17): created, updated, deleted, terminal, idle, working, ...
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
* - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate
* - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded
* - **Tasks** (4): created, completed, failed, updated
* - **Mux** (4): created, killed, died, statsUpdated
* - **Remote auto-reconnect** (3): sessionDropped, sessionReconnected, reconnectExhausted
* - **Respawn** (24): stateChanged, cycleStarted/Completed, step*, aiCheck*, planCheck*, timer*, log, ...
* - **Subagents** (7): discovered, updated, tool_call, tool_result, progress, message, completed
* - **Workflow runs** (3): run_discovered, run_updated, run_removed (ultracode / Workflow tool)
* - **Scheduled** (6): created, updated, completed, stopped, log, deleted
* - **Cron jobs** (4): jobsChanged, jobDeleted, runCreated, runUpdated
* - **Teams** (4): created, updated, removed, taskUpdated
* - **Transcript** (4): complete, plan_mode, tool_start, tool_end
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
* - **Image** (1): detected
* - **Image / attachments** (2): image:detected, attachment:detected
* - **Hooks** (6): idle_prompt, permission_prompt, elicitation_dialog, stop, teammate_idle, task_completed
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
* - **Clipboard** (1): write
* - **Cases** (4): created, linked, deleted, order-changed
* - **Docker cases** (8): exportComplete/Failed, importComplete, imageBuild*, containerRecreated
* - **Multi-user** (3): admin:usersChanged, auth:passwordChangeRequired, session:orderChanged
*
* Naming convention: `domain:action` (e.g., `session:created`, `respawn:stateChanged`)
*
@@ -84,6 +88,8 @@ export const SessionLimitResumeCancelled = 'session:limitResumeCancelled' as con
export const SessionRespawnBreakerTripped = 'session:respawnBreakerTripped' as const;
/** CLI version/model info detected from session output. */
export const SessionCliInfo = 'session:cliInfo' as const;
/** Session pin state changed (COD-139): pinned/unpinned in the session manager list. */
export const SessionPinned = 'session:pinned' as const;
/** General session message (e.g. status text). */
export const SessionMessage = 'session:message' as const;
/** Session entered interactive mode (claude or shell). */
@@ -150,6 +156,15 @@ export const MuxDied = 'mux:died' as const;
/** tmux session stats refreshed. */
export const MuxStatsUpdated = 'mux:statsUpdated' as const;
// ─── Remote auto-reconnect (COD-108) ─────────────────────────────────────────
/** A remote session's local ssh pane died; an auto-reconnect attempt is starting. */
export const RemoteSessionDropped = 'remote:sessionDropped' as const;
/** A dropped remote session was successfully re-established (reattached). */
export const RemoteSessionReconnected = 'remote:sessionReconnected' as const;
/** Auto-reconnect gave up after the bounded backoff cap — manual reconnect needed. */
export const RemoteReconnectExhausted = 'remote:reconnectExhausted' as const;
// ─── Respawn ─────────────────────────────────────────────────────────────────
/** Respawn loop started for a session. */
@@ -385,6 +400,8 @@ export const DockerImageBuildProgress = 'docker:imageBuildProgress' as const;
export const DockerImageBuildComplete = 'docker:imageBuildComplete' as const;
/** The agent base image build failed. */
export const DockerImageBuildFailed = 'docker:imageBuildFailed' as const;
/** A case container was removed after a config-drift confirm (recreated with the new config on next launch). */
export const DockerContainerRecreated = 'docker:containerRecreated' as const;
// ─── Multi-user (admin-only / targeted) ──────────────────────────────────────
@@ -393,6 +410,9 @@ export const AdminUsersChanged = 'admin:usersChanged' as const;
/** A user must change their password (targeted); the frontend shows the modal. */
export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const;
/** Global session tab order changed (synced across devices). COD-131. */
export const SessionOrderChanged = 'session:orderChanged' as const;
// ─── Namespace Re-export ─────────────────────────────────────────────────────
/**
@@ -422,6 +442,7 @@ export const SseEvent = {
SessionLimitResumeCancelled,
SessionRespawnBreakerTripped,
SessionCliInfo,
SessionPinned,
SessionMessage,
SessionInteractive,
SessionRunning,
@@ -458,6 +479,11 @@ export const SseEvent = {
MuxDied,
MuxStatsUpdated,
// Remote auto-reconnect (COD-108)
RemoteSessionDropped,
RemoteSessionReconnected,
RemoteReconnectExhausted,
// Respawn
RespawnStarted,
RespawnStopped,
@@ -585,4 +611,8 @@ export const SseEvent = {
DockerImageBuildFailed,
AdminUsersChanged,
AuthPasswordChangeRequired,
DockerContainerRecreated,
// Session order (global tab order sync)
SessionOrderChanged,
} as const;
+55
View File
@@ -145,3 +145,58 @@ describe('admin API', () => {
}
});
});
describe('admin case-folder API', () => {
const kim = { Authorization: basic('kim', 'kimpass1234') };
it('lists a user case folders (admin only, hidden dirs excluded)', async () => {
// Fresh regular user with a known password (joe's was reset above).
const created = await fetch(url('/api/admin/users'), {
method: 'POST',
headers: admin,
body: JSON.stringify({ username: 'kim', role: 'user', password: 'kimpass1234' }),
});
expect(created.status).toBe(200);
await fs.mkdir(path.join(spacesDir, 'kim', 'cases', 'proj1'), { recursive: true });
await fs.mkdir(path.join(spacesDir, 'kim', 'cases', '.hidden'), { recursive: true });
const forbidden = await fetch(url('/api/admin/users/kim/cases'), { headers: kim });
expect(forbidden.status).toBe(403);
const res = await fetch(url('/api/admin/users/kim/cases'), { headers: adminNoBody });
expect(res.status).toBe(200);
const { data } = await res.json();
expect(data.cases.map((c: { name: string }) => c.name)).toEqual(['proj1']);
expect(data.cases[0].liveSessions).toBe(0);
});
it('404s for an unknown user', async () => {
const res = await fetch(url('/api/admin/users/ghost/cases'), { headers: adminNoBody });
expect(res.status).toBe(404);
});
it('deletes a case folder, refusing unsafe names and symlinks', async () => {
// Traversal-shaped name: rejected before any filesystem access.
const bad = await fetch(url('/api/admin/users/kim/cases/..%2Fescape'), {
method: 'DELETE',
headers: adminNoBody,
});
expect([400, 404]).toContain(bad.status);
// A symlinked "case" is refused, never followed.
await fs.mkdir(path.join(spacesDir, 'outside'), { recursive: true });
await fs.symlink(path.join(spacesDir, 'outside'), path.join(spacesDir, 'kim', 'cases', 'link'));
const sl = await fetch(url('/api/admin/users/kim/cases/link'), { method: 'DELETE', headers: adminNoBody });
expect(sl.status).toBe(400);
await expect(fs.stat(path.join(spacesDir, 'outside'))).resolves.toBeTruthy();
// A real folder is deleted.
const del = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody });
expect(del.status).toBe(200);
await expect(fs.stat(path.join(spacesDir, 'kim', 'cases', 'proj1'))).rejects.toBeTruthy();
// Deleting it again 404s.
const gone = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody });
expect(gone.status).toBe(404);
});
});
+65
View File
@@ -27,6 +27,7 @@ function resp(status: number, body: unknown) {
async function bootWith(me: Record<string, unknown>) {
const dom = new JSDOM(
`<!doctype html><body>
<button id="adminPanelBtn" class="btn-admin-panel btn-admin-panel--hidden"></button>
<div class="modal" id="appSettingsModal"><div class="modal-tabs"></div><div class="modal-body"></div></div>
</body>`,
{ url: 'http://localhost/', runScripts: 'outside-only' }
@@ -70,6 +71,65 @@ describe('admin-ui boot', () => {
// Forced: the cancel button is hidden.
expect((modal!.querySelector('#cpCancel') as HTMLElement).style.display).toBe('none');
});
it('reveals the header Admin Panel button for a multi-user admin only', async () => {
const hidden = (w: Window) =>
w.document.getElementById('adminPanelBtn')!.classList.contains('btn-admin-panel--hidden');
const a = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false });
expect(hidden(a.win)).toBe(false);
const b = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
expect(hidden(b.win)).toBe(true);
const c = await bootWith({ username: 'admin', role: 'admin', multiUser: false, mustChangePassword: false });
expect(hidden(c.win)).toBe(true);
});
});
describe('admin panel modal', () => {
it('opens for an admin, renders users, and shows the case-folder drawer', async () => {
const { win } = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false });
win.fetch = (async (path: string) => {
if (path === '/api/admin/users')
return resp(200, {
success: true,
data: [
{
username: 'root',
role: 'admin',
disabled: false,
mustChangePassword: false,
canBypassPermissions: true,
createdAt: 1,
lastLoginAt: 2,
stats: { liveSessions: 1, activeSessions: 2, caseCount: 1 },
},
],
});
if (path === '/api/admin/users/root/cases')
return resp(200, {
success: true,
data: { dir: '/tmp/spaces/root/cases', cases: [{ name: 'proj1', modifiedAt: 3, liveSessions: 0 }] },
});
return resp(200, { success: true });
}) as unknown as typeof fetch;
(win as unknown as { codemanAdmin: { openAdminPanel: () => void } }).codemanAdmin.openAdminPanel();
for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0));
const modal = win.document.getElementById('adminPanelModal') as HTMLElement;
expect(modal).toBeTruthy();
expect(modal.style.display).toBe('flex');
expect(modal.querySelector('#apTable')!.textContent).toContain('root');
(modal.querySelector('button[data-act="cases"]') as HTMLButtonElement).click();
for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0));
expect(modal.textContent).toContain('proj1');
expect(modal.textContent).toContain('/tmp/spaces/root/cases');
});
it('does NOT open for a regular user', async () => {
const { win } = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
(win as unknown as { codemanAdmin: { openAdminPanel: () => void } }).codemanAdmin.openAdminPanel();
expect(win.document.getElementById('adminPanelModal')).toBeFalsy();
});
});
describe('index.html wiring', () => {
@@ -80,4 +140,9 @@ describe('index.html wiring', () => {
expect(admin).toBeGreaterThan(settings);
expect(session).toBeGreaterThan(admin);
});
it('ships the header Admin Panel button hidden by default', () => {
expect(INDEX_HTML).toContain('id="adminPanelBtn"');
expect(INDEX_HTML).toContain('btn-admin-panel--hidden');
});
});
+26 -7
View File
@@ -88,11 +88,25 @@ describe('buildDockerLaunchCommand', () => {
expect(cmd).toContain("sh -lc '");
});
it('injects the resume flag ONLY when a resume id is passed', () => {
it('pins a deterministic conversation id with a reboot-surviving fallback (fresh launch)', () => {
const cmd = buildDockerLaunchCommand(launchOpts());
// --session-id first (fresh start), || --resume so a container stop/reboot
// relaunch of the SAME session resumes instead of dead-paning on
// "Session ID already in use".
expect(cmd).toContain(
'claude --dangerously-skip-permissions --session-id 1a2b3c4d5e6f || ' +
'claude --dangerously-skip-permissions --resume 1a2b3c4d5e6f'
);
// exec is stripped from the claude pane command — an exec'd first branch could never fall back.
expect(cmd).not.toContain('exec claude');
});
it('resumes an explicit id with a --session-id fallback (stale id never dead-panes)', () => {
const withResume = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'abc-123-def' }));
expect(withResume).toContain('exec claude --dangerously-skip-permissions --resume abc-123-def');
const without = buildDockerLaunchCommand(launchOpts());
expect(without).not.toContain('--resume');
expect(withResume).toContain(
'claude --dangerously-skip-permissions --resume abc-123-def || ' +
'claude --dangerously-skip-permissions --session-id 1a2b3c4d5e6f'
);
});
it('uses codex resume syntax and drops an unsafe resume id', () => {
@@ -101,8 +115,10 @@ describe('buildDockerLaunchCommand', () => {
);
expect(codex).toContain('exec codex resume 01H-codex-id');
const unsafe = buildDockerLaunchCommand(launchOpts({ resumeSessionId: 'x; rm -rf /' }));
expect(unsafe).not.toContain('--resume');
expect(unsafe).not.toContain('x; rm'); // unsafe id dropped entirely
expect(unsafe).not.toContain('rm -rf');
// falls back to the deterministic fresh-launch chain on the session's own id
expect(unsafe).toContain('--session-id 1a2b3c4d5e6f');
});
it('forwards codex/gemini keys NAME-ONLY (no value in argv)', () => {
@@ -128,10 +144,13 @@ describe('buildDockerLaunchCommand', () => {
expect(cmd).toContain('/home/arkon/my cases/proj');
});
it('honors a per-host command override', () => {
it('honors a per-host command override (exec stripped for the session-id chain)', () => {
const docker = { ...toSessionDocker(HOST, CASE), commands: { claude: 'exec claude --model opus' } };
const cmd = buildDockerLaunchCommand(launchOpts({ docker }));
expect(cmd).toContain('exec claude --model opus');
expect(cmd).toContain('claude --model opus --session-id 1a2b3c4d5e6f');
const shellOverride = { ...toSessionDocker(HOST, CASE), commands: { shell: 'exec zsh -l' } };
const shellCmd = buildDockerLaunchCommand(launchOpts({ mode: 'shell' as SessionMode, docker: shellOverride }));
expect(shellCmd).toContain('exec zsh -l'); // non-claude overrides keep their exec
});
it('seeds writable config (guarded copies, mkdir -p parent) from the read-only seed mounts', () => {
+39
View File
@@ -12,7 +12,9 @@ import {
isSafeTarMember,
parseLoadedImageRef,
exportDockerCase,
validateImportManifest,
DOCKER_EXPORT_SCHEMA,
type DockerExportManifest,
} from '../src/docker-export.js';
import { toSessionDocker } from '../src/docker-hosts.js';
import type { DockerCase, DockerHost } from '../src/types.js';
@@ -63,6 +65,43 @@ describe('isSafeTarMember (import traversal guard)', () => {
});
});
describe('validateImportManifest (untrusted cross-machine input)', () => {
const good = (): DockerExportManifest => ({
schemaVersion: DOCKER_EXPORT_SCHEMA,
caseName: 'myproj',
mode: 'full',
engine: 'docker',
image: 'codeman/agent:base',
containerWorkdir: '/home/arkon/cases/myproj',
network: 'bridge',
createdAt: 1,
codemanVersion: '1.4.1',
mountCredentials: true,
secretFree: true,
checksums: {},
});
it('accepts a well-formed manifest', () => {
expect(() => validateImportManifest(good())).not.toThrow();
});
it('rejects a hostile engine (would select the probe/launch binary)', () => {
expect(() => validateImportManifest({ ...good(), engine: 'rm' as never })).toThrow(/engine/);
});
it('rejects shell metacharacters in containerWorkdir', () => {
expect(() => validateImportManifest({ ...good(), containerWorkdir: '/w; rm -rf ~' })).toThrow(/containerWorkdir/);
expect(() => validateImportManifest({ ...good(), containerWorkdir: 'relative/path' })).toThrow(/containerWorkdir/);
});
it('rejects bad image refs, case names, networks, and schema versions', () => {
expect(() => validateImportManifest({ ...good(), image: '-bad$(x)' })).toThrow(/image/);
expect(() => validateImportManifest({ ...good(), caseName: '../evil' })).toThrow(/caseName/);
expect(() => validateImportManifest({ ...good(), network: 'host' })).toThrow(/network/);
expect(() => validateImportManifest({ ...good(), schemaVersion: 99 })).toThrow(/schema version/);
});
});
describe('parseLoadedImageRef', () => {
it('parses "Loaded image ID: sha256:..."', () => {
expect(parseLoadedImageRef('Loaded image ID: sha256:abc123def')).toBe('sha256:abc123def');
+14 -2
View File
@@ -25,6 +25,7 @@ import {
defaultDockerCommandForMode,
ensureAgentBaseImage,
hostGatewayAlias,
persistDockerCaseClaudeSessionId,
probeDockerCliVersion,
readDockerCases,
readDockerHosts,
@@ -67,6 +68,17 @@ describe('docker-hosts storage', () => {
expect(await readDockerHosts(dir)).toEqual([]);
expect(await readDockerCases(dir)).toEqual([]);
});
it('persists the last Claude conversation id keyed by container name', async () => {
await writeDockerCases(dir, [CASE, { ...CASE, name: 'other', container: 'custom-name' }]);
await persistDockerCaseClaudeSessionId(dir, dockerContainerName(CASE.name), 'conv-1');
await persistDockerCaseClaudeSessionId(dir, 'custom-name', 'conv-2');
await persistDockerCaseClaudeSessionId(dir, 'no-such-container', 'conv-3'); // no-op
const cases = await readDockerCases(dir);
expect(cases.find((c) => c.name === 'myproj')?.lastClaudeSessionId).toBe('conv-1');
expect(cases.find((c) => c.name === 'other')?.lastClaudeSessionId).toBe('conv-2');
expect(cases.some((c) => c.lastClaudeSessionId === 'conv-3')).toBe(false);
});
});
describe('naming / display / defaults', () => {
@@ -427,7 +439,7 @@ describe('agentImageBuildArgs', () => {
describe('ensureAgentBaseImage (no-op under VITEST)', () => {
it('reports the image as already present without spawning a build', async () => {
const r = await ensureAgentBaseImage('docker', DEFAULT_AGENT_IMAGE);
const r = await ensureAgentBaseImage({ engine: 'docker' }, DEFAULT_AGENT_IMAGE);
expect(r).toEqual({ ok: true, built: false, alreadyPresent: true });
});
});
@@ -442,7 +454,7 @@ describe('daemon probes (no-op under VITEST)', () => {
it('checkDockerTmuxAvailable + image present are canned-true', async () => {
expect((await checkDockerTmuxAvailable({ engine: 'docker', image: DEFAULT_AGENT_IMAGE })).ok).toBe(true);
expect(await checkDockerImagePresent('docker', DEFAULT_AGENT_IMAGE)).toBe(true);
expect(await checkDockerImagePresent({ engine: 'docker' }, DEFAULT_AGENT_IMAGE)).toBe(true);
});
it('probeDockerCliVersion is undefined under test', async () => {
+123
View File
@@ -0,0 +1,123 @@
/**
* Regression coverage for the browser i18n layer and custom display-name setting.
* Port: N/A (JSDOM + direct schema/server render calls only).
*/
import { afterEach, describe, expect, it } from 'vitest';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
import { WebServer } from '../src/web/server.js';
const i18nSource = readFileSync(new URL('../src/web/public/i18n.js', import.meta.url), 'utf8');
const indexSource = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf8');
const settingsSource = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf8');
function makeDom(body = '<span class="logo">Codeman</span><button title="App Settings">App Settings</button>') {
const dom = new JSDOM(
`<!doctype html><html><head><title>codeman:test-host</title></head><body>${body}</body></html>`,
{
runScripts: 'outside-only',
url: 'http://localhost/',
}
);
vm.runInContext(i18nSource, dom.getInternalVMContext(), { filename: 'i18n.js' });
return dom;
}
describe('custom display name and browser localization', () => {
const previousDataDir = process.env.CODEMAN_DATA_DIR;
afterEach(() => {
if (previousDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
else process.env.CODEMAN_DATA_DIR = previousDataDir;
});
it('accepts bounded Chinese display names and rejects invalid values', () => {
expect(SettingsUpdateSchema.safeParse({ displayName: '小码助手' }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({ displayName: ' 小码助手 ' }).data).toMatchObject({
displayName: '小码助手',
});
expect(SettingsUpdateSchema.safeParse({ displayName: '' }).success).toBe(false);
expect(SettingsUpdateSchema.safeParse({ displayName: '名'.repeat(41) }).success).toBe(false);
expect(SettingsUpdateSchema.safeParse({ displayName: 'unsafe\nname' }).success).toBe(false);
});
it('translates static and dynamic UI, restores English, and leaves terminal/user content untouched', async () => {
const dom = makeDom(`
<span class="logo">Codeman</span>
<button id="settings" title="App Settings">App Settings</button>
<div id="user" class="session-tab-name">Home</div>
<div class="xterm">Run</div>
<div class="history-item" title="Run">
<span class="history-item-title">Run</span>
<span class="history-item-subtitle">Home</span>
<span class="history-detail-prompt">Settings saved</span>
<span class="history-detail-path">Home</span>
</div>
<div id="dynamic"></div>
`);
const { window } = dom;
const api = window.CodemanI18n;
api.start();
api.configure({ language: 'zh-CN', displayName: '小码助手' });
expect(window.document.querySelector('.logo')?.textContent).toBe('小码助手');
expect(window.document.getElementById('settings')?.textContent).toBe('应用设置');
expect(window.document.getElementById('settings')?.getAttribute('title')).toBe('应用设置');
expect(window.document.getElementById('user')?.textContent).toBe('Home');
expect(window.document.querySelector('.xterm')?.textContent).toBe('Run');
expect(window.document.querySelector('.history-item-title')?.textContent).toBe('Run');
expect(window.document.querySelector('.history-detail-prompt')?.textContent).toBe('Settings saved');
expect(window.document.querySelector('.history-item')?.getAttribute('title')).toBe('Run');
expect(window.document.title).toBe('小码助手:test-host');
const dynamicButton = window.document.createElement('button');
dynamicButton.textContent = 'Settings saved';
dynamicButton.title = 'Open Codeman across all displays';
window.document.getElementById('dynamic')?.appendChild(dynamicButton);
await new Promise((resolve) => window.setTimeout(resolve, 0));
expect(dynamicButton.textContent).toBe('设置已保存');
expect(dynamicButton.title).toBe('在所有显示器上打开 小码助手');
api.configure({ language: 'en', displayName: 'Workbench' });
expect(window.document.getElementById('settings')?.textContent).toBe('App Settings');
expect(window.document.querySelector('.logo')?.textContent).toBe('Workbench');
expect(dynamicButton.textContent).toBe('Settings saved');
expect(window.document.title).toBe('Workbench:test-host');
dom.window.close();
});
it('renders hostile-looking names as text rather than HTML', () => {
const dom = makeDom('<span class="logo">Codeman</span>');
const api = dom.window.CodemanI18n;
api.start();
api.configure({ displayName: '<img src=x onerror=alert(1)>', language: 'en' });
const logo = dom.window.document.querySelector('.logo');
expect(logo?.textContent).toBe('<img src=x onerror=alert(1)>');
expect(logo?.querySelector('img')).toBeNull();
dom.window.close();
});
it('wires the two settings with language kept per-device and display name synced', () => {
expect(indexSource).toContain('id="appSettingsDisplayName"');
expect(indexSource).toContain('id="appSettingsLanguage"');
expect(indexSource).toContain('<option value="zh-CN">简体中文</option>');
expect(settingsSource).toMatch(/language:\s*_language/);
expect(settingsSource).toContain("'language',");
expect(settingsSource).not.toMatch(/displayName:\s*_displayName/);
});
it('uses the escaped custom name in the server-rendered window title', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'codeman-brand-title-'));
process.env.CODEMAN_DATA_DIR = dataDir;
writeFileSync(join(dataDir, 'settings.json'), JSON.stringify({ displayName: '小码<&助手' }));
const server = new WebServer(0, false, true, '127.0.0.1', 'laptop');
const html = await (server as unknown as { renderIndexHtml: () => Promise<string> }).renderIndexHtml();
expect(html).toContain('<title>小码&lt;&amp;助手:laptop</title>');
expect(html).not.toContain('<title>小码<&助手:laptop</title>');
});
});
+1 -1
View File
@@ -38,7 +38,7 @@ const MOBILE_VISIBLE_ALLOWLIST = new Set<string>([]);
// that removes a hide rule fails loudly (not silently). The attachments button is
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
// it's excluded from the default-visible enumeration rather than mobile-hidden.
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager'];
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager', 'btn-file-viewer'];
function attrOf(openTag: string, name: string): string {
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
+6 -6
View File
@@ -2,11 +2,11 @@
Comprehensive mobile UI testing for Codeman's web interface using Playwright with dual-engine support (Chromium + WebKit).
**325 tests across 135 devices — all passing.**
**326 tests across 136 devices — all passing.**
## Purpose
Validates Codeman's mobile UI across 135 devices, covering:
Validates Codeman's mobile UI across 136 devices, covering:
- **Keyboard simulation** — 3-layer approach to emulate virtual keyboards in headless browsers
- **Touch/swipe interactions** — CDP trusted events (Chromium) + synthetic fallback (WebKit)
@@ -26,7 +26,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/keyboard.test.t
# Quick mode — 6 representative devices, skip full matrix
CI_QUICK=1 npx vitest run --config test/mobile/vitest.config.ts
# Full device matrix only (135 devices)
# Full device matrix only (136 devices)
npx vitest run --config test/mobile/vitest.config.ts test/mobile/device-matrix.test.ts
# Update visual baselines (delete old baselines, re-run)
@@ -43,7 +43,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regressi
| `subagent-windows.test.ts` | 3202 | Mobile subagent card dimensions, stacking, interactions |
| `settings.test.ts` | 3203 | Settings modal, mobile defaults, persistence |
| `layout.test.ts` | 3204 | General mobile layout, fixed elements, device classes |
| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (135 devices) |
| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (136 devices) |
| `visual-regression.test.ts` | 3206 | Screenshot comparison at key breakpoints |
| `accessibility.test.ts` | 3207 | WCAG touch targets, zoom, focus, ARIA |
@@ -58,7 +58,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regressi
| standard-tablet | 768–834px | ~8 | iPad Mini |
| large-tablet | 835px+ | ~5 | iPad Pro 11" |
135 devices are defined in `devices.ts` — 68 from Playwright's built-in device profiles plus 67 custom entries for newer devices (iPhone 16/17, Pixel 9, Galaxy S25, iPad Air M2, Surface Pro, etc.).
136 devices are defined in `devices.ts` — 68 from Playwright's built-in device profiles plus 68 custom entries for newer devices (iPhone 16/17, Pixel 9, Galaxy S25, OPPO Find N5 unfolded, iPad Air M2, Surface Pro, etc.).
### How Devices Are Differentiated
@@ -98,7 +98,7 @@ Test File
├─ helpers/touch-sim.ts → CDP trusted touch / synthetic fallback
├─ helpers/assertions.ts → Layout, CSS, accessibility assertions
├─ helpers/visual.ts → pixelmatch screenshot comparison
└─ devices.ts → 135-device registry
└─ devices.ts → 136-device registry
```
### Keyboard Simulation — 3-Layer Approach
+173 -157
View File
@@ -1,6 +1,12 @@
import { devices as playwrightDevices } from 'playwright';
export type DeviceCategory = 'small-phone' | 'standard-phone' | 'large-phone' | 'small-tablet' | 'standard-tablet' | 'large-tablet';
export type DeviceCategory =
| 'small-phone'
| 'standard-phone'
| 'large-phone'
| 'small-tablet'
| 'standard-tablet'
| 'large-tablet';
export interface DeviceEntry {
name: string;
@@ -55,14 +61,7 @@ function fromPlaywright(name: string): DeviceEntry | null {
}
/** Create a custom DeviceEntry for devices not in Playwright. */
function custom(
name: string,
width: number,
height: number,
dpr: number,
ua: string,
isIOS: boolean,
): DeviceEntry {
function custom(name: string, width: number, height: number, dpr: number, ua: string, isIOS: boolean): DeviceEntry {
return {
name,
category: categoryFor(width),
@@ -83,89 +82,89 @@ function custom(
const PLAYWRIGHT_DEVICE_NAMES = [
// Small phones (<375px)
'iPhone SE', // 320x568
'Galaxy S9+', // 320x658
'Nokia Lumia 520', // 320x533
'Galaxy S III', // 360x640
'Galaxy Note 3', // 360x640
'Galaxy Note II', // 360x640
'Galaxy S5', // 360x640
'Galaxy S8', // 360x740
'Galaxy S24', // 360x780
'BlackBerry Z30', // 360x640
'iPhone SE', // 320x568
'Galaxy S9+', // 320x658
'Nokia Lumia 520', // 320x533
'Galaxy S III', // 360x640
'Galaxy Note 3', // 360x640
'Galaxy Note II', // 360x640
'Galaxy S5', // 360x640
'Galaxy S8', // 360x740
'Galaxy S24', // 360x780
'BlackBerry Z30', // 360x640
'Microsoft Lumia 550', // 360x640
'Microsoft Lumia 950', // 360x640
'Nexus 5', // 360x640
'Moto G4', // 360x640
'Pixel 4', // 353x745
'Nexus 5', // 360x640
'Moto G4', // 360x640
'Pixel 4', // 353x745
// Standard phones (375-429px)
'iPhone 6', // 375x667
'iPhone 7', // 375x667
'iPhone 8', // 375x667
'iPhone 6', // 375x667
'iPhone 7', // 375x667
'iPhone 8', // 375x667
'iPhone SE (3rd gen)', // 375x667
'iPhone X', // 375x812
'iPhone 11 Pro', // 375x635
'iPhone 12 Mini', // 375x629
'iPhone 13 Mini', // 375x629
'LG Optimus L70', // 384x640
'Nexus 4', // 384x640
'iPhone 12', // 390x664
'iPhone 12 Pro', // 390x664
'iPhone 13', // 390x664
'iPhone 13 Pro', // 390x664
'iPhone 14', // 390x664
'iPhone 14 Pro', // 393x660
'iPhone 15', // 393x659
'iPhone 15 Pro', // 393x659
'Pixel 3', // 393x786
'Pixel 5', // 393x727
'Pixel 2', // 411x731
'Pixel 2 XL', // 411x823
'Pixel 7', // 412x839
'Pixel 4a (5G)', // 412x765
'Nexus 5X', // 412x732
'Nexus 6', // 412x732
'Nexus 6P', // 412x732
'iPhone 6 Plus', // 414x736
'iPhone 7 Plus', // 414x736
'iPhone 8 Plus', // 414x736
'iPhone XR', // 414x896
'iPhone 11', // 414x715
'iPhone 11 Pro Max', // 414x715
'iPhone 12 Pro Max', // 428x746
'iPhone 13 Pro Max', // 428x746
'iPhone 14 Plus', // 428x746
'iPhone X', // 375x812
'iPhone 11 Pro', // 375x635
'iPhone 12 Mini', // 375x629
'iPhone 13 Mini', // 375x629
'LG Optimus L70', // 384x640
'Nexus 4', // 384x640
'iPhone 12', // 390x664
'iPhone 12 Pro', // 390x664
'iPhone 13', // 390x664
'iPhone 13 Pro', // 390x664
'iPhone 14', // 390x664
'iPhone 14 Pro', // 393x660
'iPhone 15', // 393x659
'iPhone 15 Pro', // 393x659
'Pixel 3', // 393x786
'Pixel 5', // 393x727
'Pixel 2', // 411x731
'Pixel 2 XL', // 411x823
'Pixel 7', // 412x839
'Pixel 4a (5G)', // 412x765
'Nexus 5X', // 412x732
'Nexus 6', // 412x732
'Nexus 6P', // 412x732
'iPhone 6 Plus', // 414x736
'iPhone 7 Plus', // 414x736
'iPhone 8 Plus', // 414x736
'iPhone XR', // 414x896
'iPhone 11', // 414x715
'iPhone 11 Pro Max', // 414x715
'iPhone 12 Pro Max', // 428x746
'iPhone 13 Pro Max', // 428x746
'iPhone 14 Plus', // 428x746
// Large phones (430-599px)
'iPhone 14 Pro Max', // 430x740
'iPhone 15 Plus', // 430x739
'iPhone 15 Pro Max', // 430x739
'Galaxy A55', // 480x1040
'Nokia N9', // 480x854
'iPhone 14 Pro Max', // 430x740
'iPhone 15 Plus', // 430x739
'iPhone 15 Pro Max', // 430x739
'Galaxy A55', // 480x1040
'Nokia N9', // 480x854
// Small tablets (600-767px)
'Blackberry PlayBook', // 600x1024
'Nexus 7', // 600x960
'Galaxy Tab S9', // 640x1024
'iPad (gen 11)', // 656x944
'Galaxy Tab S4', // 712x1138
'Nexus 7', // 600x960
'Galaxy Tab S9', // 640x1024
'iPad (gen 11)', // 656x944
'Galaxy Tab S4', // 712x1138
// Standard tablets (768-834px)
'iPad (gen 5)', // 768x1024
'iPad (gen 6)', // 768x1024
'iPad Mini', // 768x1024
'Kindle Fire HDX', // 800x1280
'Nexus 10', // 800x1280
'iPad (gen 7)', // 810x1080
'iPad (gen 5)', // 768x1024
'iPad (gen 6)', // 768x1024
'iPad Mini', // 768x1024
'Kindle Fire HDX', // 800x1280
'Nexus 10', // 800x1280
'iPad (gen 7)', // 810x1080
// Large tablets (834px+)
'iPad Pro 11', // 834x1194
'iPad Pro 11', // 834x1194
];
const playwrightEntries: DeviceEntry[] = PLAYWRIGHT_DEVICE_NAMES
.map(n => fromPlaywright(n))
.filter((d): d is DeviceEntry => d !== null);
const playwrightEntries: DeviceEntry[] = PLAYWRIGHT_DEVICE_NAMES.map((n) => fromPlaywright(n)).filter(
(d): d is DeviceEntry => d !== null
);
// ---------------------------------------------------------------------------
// Custom devices — newer models and those missing from Playwright
@@ -185,84 +184,101 @@ const ANDROID_TABLET_UA = (androidVer: string, model: string) =>
const customEntries: DeviceEntry[] = [
// ── Small phones (<375px) ──────────────────────────────────────────────
custom('iPhone 5', 320, 568, 2, IOS_MOBILE_UA('10_3_4'), true),
custom('iPhone 5s', 320, 568, 2, IOS_MOBILE_UA('12_5_7'), true),
custom('iPhone 5c', 320, 568, 2, IOS_MOBILE_UA('10_3_3'), true),
custom('iPod Touch (7th gen)',320, 568, 2, IOS_MOBILE_UA('15_8'), true),
custom('Galaxy Y', 240, 320, 1, ANDROID_MOBILE_UA('2.3.6', 'GT-S5360'), false),
custom('Galaxy Ace', 320, 480, 1, ANDROID_MOBILE_UA('2.3.7', 'GT-S5830'), false),
custom('Pixel 4a', 353, 745, 2.75, ANDROID_MOBILE_UA('12', 'Pixel 4a'), false),
custom('iPhone 5', 320, 568, 2, IOS_MOBILE_UA('10_3_4'), true),
custom('iPhone 5s', 320, 568, 2, IOS_MOBILE_UA('12_5_7'), true),
custom('iPhone 5c', 320, 568, 2, IOS_MOBILE_UA('10_3_3'), true),
custom('iPod Touch (7th gen)', 320, 568, 2, IOS_MOBILE_UA('15_8'), true),
custom('Galaxy Y', 240, 320, 1, ANDROID_MOBILE_UA('2.3.6', 'GT-S5360'), false),
custom('Galaxy Ace', 320, 480, 1, ANDROID_MOBILE_UA('2.3.7', 'GT-S5830'), false),
custom('Pixel 4a', 353, 745, 2.75, ANDROID_MOBILE_UA('12', 'Pixel 4a'), false),
// ── Standard phones (375-429px) ────────────────────────────────────────
custom('iPhone 16', 393, 659, 3, IOS_MOBILE_UA('18_0'), true),
custom('iPhone 16 Pro', 402, 674, 3, IOS_MOBILE_UA('18_0'), true),
custom('Galaxy S20', 360, 800, 3, ANDROID_MOBILE_UA('12', 'SM-G980F'), false),
custom('Galaxy S20 FE', 360, 800, 3, ANDROID_MOBILE_UA('13', 'SM-G780F'), false),
custom('Galaxy S21', 360, 800, 3, ANDROID_MOBILE_UA('13', 'SM-G991B'), false),
custom('Galaxy S21 FE', 360, 800, 3, ANDROID_MOBILE_UA('14', 'SM-G990B'), false),
custom('Galaxy S22', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S901B'), false),
custom('Galaxy S23', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S911B'), false),
custom('Galaxy S24 FE', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S721B'), false),
custom('Galaxy A54', 360, 800, 3, ANDROID_MOBILE_UA('14', 'SM-A546B'), false),
custom('Galaxy A34', 360, 800, 2.625, ANDROID_MOBILE_UA('14', 'SM-A346B'), false),
custom('Galaxy A14', 384, 854, 1.5, ANDROID_MOBILE_UA('13', 'SM-A145F'), false),
custom('Galaxy Z Flip 5', 412, 919, 2.625, ANDROID_MOBILE_UA('14', 'SM-F731B'), false),
custom('Galaxy Z Flip 4', 412, 919, 2.625, ANDROID_MOBILE_UA('14', 'SM-F721B'), false),
custom('Pixel 6', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6'), false),
custom('Pixel 6a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6a'), false),
custom('Pixel 7a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 7a'), false),
custom('Pixel 8', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8'), false),
custom('Pixel 8a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8a'), false),
custom('Pixel 9', 412, 923, 2.75, ANDROID_MOBILE_UA('15', 'Pixel 9'), false),
custom('OnePlus 12', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'CPH2581'), false),
custom('OnePlus Nord 3', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'CPH2491'), false),
custom('Xiaomi 14', 393, 873, 2.75, ANDROID_MOBILE_UA('14', '23127PN0CC'), false),
custom('Xiaomi Redmi Note 13',393, 873, 2.75, ANDROID_MOBILE_UA('14', '23106RN0DA'), false),
custom('Nothing Phone (2)', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'A065'), false),
custom('Sony Xperia 1 V', 411, 960, 2.625, ANDROID_MOBILE_UA('14', 'XQ-DQ72'), false),
custom('iPhone 16', 393, 659, 3, IOS_MOBILE_UA('18_0'), true),
custom('iPhone 16 Pro', 402, 674, 3, IOS_MOBILE_UA('18_0'), true),
custom('Galaxy S20', 360, 800, 3, ANDROID_MOBILE_UA('12', 'SM-G980F'), false),
custom('Galaxy S20 FE', 360, 800, 3, ANDROID_MOBILE_UA('13', 'SM-G780F'), false),
custom('Galaxy S21', 360, 800, 3, ANDROID_MOBILE_UA('13', 'SM-G991B'), false),
custom('Galaxy S21 FE', 360, 800, 3, ANDROID_MOBILE_UA('14', 'SM-G990B'), false),
custom('Galaxy S22', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S901B'), false),
custom('Galaxy S23', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S911B'), false),
custom('Galaxy S24 FE', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S721B'), false),
custom('Galaxy A54', 360, 800, 3, ANDROID_MOBILE_UA('14', 'SM-A546B'), false),
custom('Galaxy A34', 360, 800, 2.625, ANDROID_MOBILE_UA('14', 'SM-A346B'), false),
custom('Galaxy A14', 384, 854, 1.5, ANDROID_MOBILE_UA('13', 'SM-A145F'), false),
custom('Galaxy Z Flip 5', 412, 919, 2.625, ANDROID_MOBILE_UA('14', 'SM-F731B'), false),
custom('Galaxy Z Flip 4', 412, 919, 2.625, ANDROID_MOBILE_UA('14', 'SM-F721B'), false),
custom('Pixel 6', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6'), false),
custom('Pixel 6a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6a'), false),
custom('Pixel 7a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 7a'), false),
custom('Pixel 8', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8'), false),
custom('Pixel 8a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8a'), false),
custom('Pixel 9', 412, 923, 2.75, ANDROID_MOBILE_UA('15', 'Pixel 9'), false),
custom('OnePlus 12', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'CPH2581'), false),
custom('OnePlus Nord 3', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'CPH2491'), false),
custom('Xiaomi 14', 393, 873, 2.75, ANDROID_MOBILE_UA('14', '23127PN0CC'), false),
custom('Xiaomi Redmi Note 13', 393, 873, 2.75, ANDROID_MOBILE_UA('14', '23106RN0DA'), false),
custom('Nothing Phone (2)', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'A065'), false),
custom('Sony Xperia 1 V', 411, 960, 2.625, ANDROID_MOBILE_UA('14', 'XQ-DQ72'), false),
// ── Large phones (430-599px) ───────────────────────────────────────────
custom('iPhone 16 Plus', 430, 739, 3, IOS_MOBILE_UA('18_0'), true),
custom('iPhone 16 Pro Max', 440, 756, 3, IOS_MOBILE_UA('18_0'), true),
custom('Galaxy S20 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('13', 'SM-G988B'), false),
custom('Galaxy S21 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('13', 'SM-G998B'), false),
custom('Galaxy S22 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S908B'), false),
custom('Galaxy S23 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S918B'), false),
custom('Galaxy S24 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S928B'), false),
custom('Galaxy Z Fold 5', 460, 1016, 2.5, ANDROID_MOBILE_UA('14', 'SM-F946B'), false),
custom('Pixel 6 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6 Pro'), false),
custom('Pixel 7 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 7 Pro'), false),
custom('Pixel 8 Pro', 448, 998, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8 Pro'), false),
custom('Pixel 9 Pro XL', 448, 998, 2.75, ANDROID_MOBILE_UA('15', 'Pixel 9 Pro XL'), false),
custom('OnePlus 12 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'CPH2583'), false),
custom('iPhone 16 Plus', 430, 739, 3, IOS_MOBILE_UA('18_0'), true),
custom('iPhone 16 Pro Max', 440, 756, 3, IOS_MOBILE_UA('18_0'), true),
custom('Galaxy S20 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('13', 'SM-G988B'), false),
custom('Galaxy S21 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('13', 'SM-G998B'), false),
custom('Galaxy S22 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S908B'), false),
custom('Galaxy S23 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S918B'), false),
custom('Galaxy S24 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S928B'), false),
custom('Galaxy Z Fold 5', 460, 1016, 2.5, ANDROID_MOBILE_UA('14', 'SM-F946B'), false),
custom('Pixel 6 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6 Pro'), false),
custom('Pixel 7 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 7 Pro'), false),
custom('Pixel 8 Pro', 448, 998, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8 Pro'), false),
custom('Pixel 9 Pro XL', 448, 998, 2.75, ANDROID_MOBILE_UA('15', 'Pixel 9 Pro XL'), false),
custom('OnePlus 12 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'CPH2583'), false),
// ── Small tablets (600-767px) ──────────────────────────────────────────
custom('Galaxy Tab A8', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-X200'), false),
custom('Galaxy Tab S6 Lite', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-P613'), false),
custom('Galaxy Tab A7 Lite', 600, 960, 1.5, ANDROID_TABLET_UA('13', 'SM-T220'), false),
custom('Kindle Fire HD 8', 600, 1024, 1.5, 'Mozilla/5.0 (Linux; Android 11; KFRAPWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/110.1.4 like Chrome/110.0.5481.154 Safari/537.36', false),
custom('Lenovo Tab M10', 600, 1024, 1.5, ANDROID_TABLET_UA('12', 'TB-X606F'), false),
custom('Xiaomi Pad 6', 600, 960, 2, ANDROID_TABLET_UA('14', '23043RP34G'), false),
custom('Galaxy Tab A8', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-X200'), false),
custom('Galaxy Tab S6 Lite', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-P613'), false),
custom('Galaxy Tab A7 Lite', 600, 960, 1.5, ANDROID_TABLET_UA('13', 'SM-T220'), false),
custom(
'Kindle Fire HD 8',
600,
1024,
1.5,
'Mozilla/5.0 (Linux; Android 11; KFRAPWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/110.1.4 like Chrome/110.0.5481.154 Safari/537.36',
false
),
custom('Lenovo Tab M10', 600, 1024, 1.5, ANDROID_TABLET_UA('12', 'TB-X606F'), false),
custom('Xiaomi Pad 6', 600, 960, 2, ANDROID_TABLET_UA('14', '23043RP34G'), false),
// ── Standard tablets (768-834px) ───────────────────────────────────────
custom('iPad Air (5th gen)', 820, 1180, 2, IPAD_UA('16_0'), true),
custom('iPad (9th gen)', 810, 1080, 2, IPAD_UA('16_0'), true),
custom('iPad (10th gen)', 820, 1180, 2, IPAD_UA('16_0'), true),
custom('iPad Mini (6th gen)', 768, 1024, 2, IPAD_UA('16_0'), true),
custom('Galaxy Tab S7', 800, 1280, 2, ANDROID_TABLET_UA('13', 'SM-T870'), false),
custom('Galaxy Tab S8', 800, 1280, 2, ANDROID_TABLET_UA('14', 'SM-X700'), false),
custom('iPad Air (5th gen)', 820, 1180, 2, IPAD_UA('16_0'), true),
custom('iPad (9th gen)', 810, 1080, 2, IPAD_UA('16_0'), true),
custom('iPad (10th gen)', 820, 1180, 2, IPAD_UA('16_0'), true),
custom('iPad Mini (6th gen)', 768, 1024, 2, IPAD_UA('16_0'), true),
custom('Galaxy Tab S7', 800, 1280, 2, ANDROID_TABLET_UA('13', 'SM-T870'), false),
custom('Galaxy Tab S8', 800, 1280, 2, ANDROID_TABLET_UA('14', 'SM-X700'), false),
// ── Large tablets (834px+) ──────────────────────────────────────────────
custom('iPad Pro 12.9 (6th gen)', 1024, 1366, 2, IPAD_UA('16_0'), true),
custom('iPad Pro 11 (4th gen)', 834, 1194, 2, IPAD_UA('16_0'), true),
custom('iPad Air (M2)', 834, 1194, 2, IPAD_UA('17_0'), true),
custom('Surface Pro 7', 912, 1368, 2,
'Mozilla/5.0 (Windows NT 10.0; ARM; Surface Pro 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36 Edg/145.0.0.0', false),
custom('Galaxy Tab S8+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X800'), false),
custom('Galaxy Tab S9+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X810'), false),
custom('Galaxy Tab S9 Ultra', 900, 1440, 2.25, ANDROID_TABLET_UA('14', 'SM-X910'), false),
custom('Pixel Tablet', 888, 1280, 2, ANDROID_TABLET_UA('14', 'GPD8'), false),
custom('Lenovo Tab P12 Pro', 900, 1440, 2, ANDROID_TABLET_UA('13', 'TB-Q706F'), false),
custom('iPad Pro 11 (4th gen)', 834, 1194, 2, IPAD_UA('16_0'), true),
custom('iPad Air (M2)', 834, 1194, 2, IPAD_UA('17_0'), true),
custom(
'Surface Pro 7',
912,
1368,
2,
'Mozilla/5.0 (Windows NT 10.0; ARM; Surface Pro 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36 Edg/145.0.0.0',
false
),
custom('Galaxy Tab S8+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X800'), false),
custom('Galaxy Tab S9+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X810'), false),
custom('Galaxy Tab S9 Ultra', 900, 1440, 2.25, ANDROID_TABLET_UA('14', 'SM-X910'), false),
custom('Pixel Tablet', 888, 1280, 2, ANDROID_TABLET_UA('14', 'GPD8'), false),
custom('Lenovo Tab P12 Pro', 900, 1440, 2, ANDROID_TABLET_UA('13', 'TB-Q706F'), false),
// Find N5 inner display is 2248x2480 physical pixels. At DPR 2 its full-
// resolution CSS viewport crosses Codeman's desktop breakpoint while the
// browser remains a mobile/touch device.
custom('OPPO Find N5 (unfolded)', 1124, 1240, 2, ANDROID_MOBILE_UA('15', 'CPH2671'), false),
];
// ---------------------------------------------------------------------------
@@ -276,29 +292,29 @@ export const DEVICE_REGISTRY: DeviceEntry[] = [...playwrightEntries, ...customEn
// Per-category exports
// ---------------------------------------------------------------------------
export const SMALL_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'small-phone');
export const STANDARD_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'standard-phone');
export const LARGE_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'large-phone');
export const SMALL_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'small-tablet');
export const STANDARD_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'standard-tablet');
export const LARGE_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'large-tablet');
export const SMALL_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'small-phone');
export const STANDARD_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'standard-phone');
export const LARGE_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'large-phone');
export const SMALL_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'small-tablet');
export const STANDARD_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'standard-tablet');
export const LARGE_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'large-tablet');
// ---------------------------------------------------------------------------
// Platform exports
// ---------------------------------------------------------------------------
export const IOS_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.isIOS);
export const ANDROID_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => !d.isIOS);
export const IOS_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.isIOS);
export const ANDROID_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => !d.isIOS);
// ---------------------------------------------------------------------------
// Representative devices — one per category for quick smoke tests
// ---------------------------------------------------------------------------
export const REPRESENTATIVE_DEVICES: Record<DeviceCategory, DeviceEntry> = {
'small-phone': SMALL_PHONES.find(d => d.name === 'iPhone SE')!,
'standard-phone': STANDARD_PHONES.find(d => d.name === 'iPhone 14 Pro')!,
'large-phone': LARGE_PHONES.find(d => d.name === 'iPhone 15 Pro Max')!,
'small-tablet': SMALL_TABLETS.find(d => d.name === 'Nexus 7')!,
'standard-tablet': STANDARD_TABLETS.find(d => d.name === 'iPad Mini')!,
'large-tablet': LARGE_TABLETS.find(d => d.name === 'iPad Pro 11')!,
'small-phone': SMALL_PHONES.find((d) => d.name === 'iPhone SE')!,
'standard-phone': STANDARD_PHONES.find((d) => d.name === 'iPhone 14 Pro')!,
'large-phone': LARGE_PHONES.find((d) => d.name === 'iPhone 15 Pro Max')!,
'small-tablet': SMALL_TABLETS.find((d) => d.name === 'Nexus 7')!,
'standard-tablet': STANDARD_TABLETS.find((d) => d.name === 'iPad Mini')!,
'large-tablet': LARGE_TABLETS.find((d) => d.name === 'iPad Pro 11')!,
};
+83 -24
View File
@@ -5,10 +5,8 @@ import { PORTS, SELECTORS, KEYBOARD, STORAGE_KEYS, BODY_CLASSES, WAIT } from './
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
import {
assertVisible, assertHidden, getCSSProperty, getCSSNumericValue,
} from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES } from './devices.js';
import { assertVisible, assertHidden, getCSSProperty, getCSSNumericValue } from './helpers/assertions.js';
import { DEVICE_REGISTRY, REPRESENTATIVE_DEVICES } from './devices.js';
import type { WebServer } from '../src/web/server.js';
const PORT = PORTS.SETTINGS;
@@ -94,9 +92,7 @@ describe('Settings Modal', () => {
if (gearBox && toolbarBox) {
// Gear button should be within toolbar's vertical range
expect(gearBox.y).toBeGreaterThanOrEqual(toolbarBox.y - 5);
expect(gearBox.y + gearBox.height).toBeLessThanOrEqual(
toolbarBox.y + toolbarBox.height + 5,
);
expect(gearBox.y + gearBox.height).toBeLessThanOrEqual(toolbarBox.y + toolbarBox.height + 5);
}
});
});
@@ -304,11 +300,14 @@ describe('Settings Modal', () => {
try {
// Store a test setting
await page.evaluate((key) => {
localStorage.setItem(key, JSON.stringify({
showFontControls: true,
showMonitor: true,
subagentTrackingEnabled: true,
}));
localStorage.setItem(
key,
JSON.stringify({
showFontControls: true,
showMonitor: true,
subagentTrackingEnabled: true,
})
);
}, STORAGE_KEYS.SETTINGS_MOBILE);
// Reload page
@@ -335,20 +334,32 @@ describe('Settings Modal', () => {
try {
// Store both mobile and desktop settings
await page.evaluate(({ mobileKey, desktopKey, notifKey }) => {
localStorage.setItem(mobileKey, JSON.stringify({ showFontControls: false }));
localStorage.setItem(desktopKey, JSON.stringify({ showFontControls: true }));
localStorage.setItem(notifKey, JSON.stringify({ mobileNotif: true }));
}, {
mobileKey: STORAGE_KEYS.SETTINGS_MOBILE,
desktopKey: STORAGE_KEYS.SETTINGS_DESKTOP,
notifKey: STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE,
});
await page.evaluate(
({ mobileKey, desktopKey, notifKey }) => {
localStorage.setItem(mobileKey, JSON.stringify({ showFontControls: false }));
localStorage.setItem(desktopKey, JSON.stringify({ showFontControls: true }));
localStorage.setItem(notifKey, JSON.stringify({ mobileNotif: true }));
},
{
mobileKey: STORAGE_KEYS.SETTINGS_MOBILE,
desktopKey: STORAGE_KEYS.SETTINGS_DESKTOP,
notifKey: STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE,
}
);
// Verify they are independent
const mobile = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.SETTINGS_MOBILE);
const desktop = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.SETTINGS_DESKTOP);
const notif = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE);
const mobile = await page.evaluate(
(key) => JSON.parse(localStorage.getItem(key) || '{}'),
STORAGE_KEYS.SETTINGS_MOBILE
);
const desktop = await page.evaluate(
(key) => JSON.parse(localStorage.getItem(key) || '{}'),
STORAGE_KEYS.SETTINGS_DESKTOP
);
const notif = await page.evaluate(
(key) => JSON.parse(localStorage.getItem(key) || '{}'),
STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE
);
expect(mobile.showFontControls).toBe(false);
expect(desktop.showFontControls).toBe(true);
@@ -390,5 +401,53 @@ describe('Settings Modal', () => {
await context.close();
}
});
it('keeps handheld settings when a foldable unfolds past the desktop breakpoint', async () => {
const device = DEVICE_REGISTRY.find((entry) => entry.name === 'OPPO Find N5 (unfolded)')!;
const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
try {
// Seed the preferences while folded, exactly as a phone user does.
await page.setViewportSize({ width: 412, height: 915 });
await page.evaluate((key) => {
localStorage.setItem(
key,
JSON.stringify({
showResponseViewer: true,
extendedKeyboardBar: true,
})
);
}, STORAGE_KEYS.SETTINGS_MOBILE);
await page.reload({ waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.waitForTimeout(WAIT.SSE_CONNECT);
// Unfolding can reload Android WebView. The viewport now uses desktop
// layout, but the physical device and its preferences have not changed.
await page.setViewportSize(device.viewport);
await page.reload({ waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.waitForTimeout(WAIT.SSE_CONNECT);
const state = await page.evaluate(() => ({
deviceType: (window as any).MobileDetection.getDeviceType(),
handheld: (window as any).MobileDetection.isHandheldDevice(),
storageKey: (window as any).app.getSettingsStorageKey(),
responseViewerVisible: !document
.querySelector('.btn-response-viewer-header')
?.classList.contains('btn-response-viewer-header--hidden'),
keyboardExtended: Boolean(document.querySelector('.keyboard-accessory-bar [data-action="arrow-left"]')),
}));
expect(state.deviceType).toBe('desktop');
expect(state.handheld).toBe(true);
expect(state.storageKey).toBe(STORAGE_KEYS.SETTINGS_MOBILE);
expect(state.responseViewerVisible).toBe(true);
expect(state.keyboardExtended).toBe(true);
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await assertVisible(page, '.keyboard-accessory-bar');
} finally {
await context.close();
}
});
});
});
+8
View File
@@ -21,6 +21,10 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
const sessions = new Map<string, MockSession>();
sessions.set(sessionId, session);
// Stateful backing for the global tab order (COD-131) so route tests can
// assert that setSessionOrder() actually persists what the handler computed.
let sessionOrder: string[] = [];
return {
// -- SessionPort --
sessions,
@@ -65,6 +69,10 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
load: vi.fn(),
incrementSessionsCreated: vi.fn(),
setConfig: vi.fn(),
getSessionOrder: vi.fn(() => sessionOrder),
setSessionOrder: vi.fn((order: string[]) => {
sessionOrder = order;
}),
getAggregateStats: vi.fn(() => ({ totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0 })),
getGlobalStats: vi.fn(() => ({ sessionsCreated: 0 })),
getDailyStats: vi.fn(() => []),
+10
View File
@@ -229,6 +229,8 @@ export class MockSession extends EventEmitter {
color: this.color,
mode: this.mode,
muxName: this._muxName,
pinned: this.pinned || undefined,
pinnedAt: this.pinned ? (this.pinnedAt ?? undefined) : undefined,
};
}
@@ -241,6 +243,14 @@ export class MockSession extends EventEmitter {
if (!enabled) this.autoResumeAt = null;
});
/** Pin state (COD-139) */
pinned: boolean = false;
pinnedAt: number | null = null;
setPinned = vi.fn((pinned: boolean) => {
this.pinned = pinned;
this.pinnedAt = pinned ? Date.now() : null;
});
/** Check if session is busy */
isBusy = vi.fn(() => false);
+298
View File
@@ -0,0 +1,298 @@
/**
* @fileoverview COD-108 — remote-session auto-reconnect watcher tests.
*
* Three layers, all tmux-safe (under VITEST TmuxManager no-ops real tmux and
* `isPaneDead` returns false, so live behavior is driven via injected stubs):
*
* (a) PURE backoff schedule — attempt→delay, cap, reset-on-success.
* (b) PURE eligibility decision — dead remote pane + due → emit; guarded →
* never; non-remote / pane-alive / not-due → skip; over-cap → exhaust.
* (c) MANAGER integration — drive ticks with a stubbed pane-death signal +
* controllable clock and assert the emit → backoff → exhausted progression,
* and that a guarded (intentionally-killed) session emits nothing.
*
* Port: N/A (no server).
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
BACKOFF_SCHEDULE_MS,
MAX_RECONNECT_ATTEMPTS,
reconnectDelayForAttempt,
isExhausted,
freshReconnectState,
advanceBackoff,
resetReconnectState,
decideReconnect,
} from '../src/remote-reconnect.js';
import type { ReconnectSessionView } from '../src/remote-reconnect.js';
import { TmuxManager } from '../src/tmux-manager.js';
import type { SessionRemote } from '../src/types.js';
const REMOTE: SessionRemote = {
hostId: 'aa-desktop',
label: 'aa-desktop',
host: 'aa-desktop',
username: 'aakhter',
remotePath: '/home/aakhter',
owned: true,
};
// ────────────────────────────────────────────────────────────────────────────
// (a) PURE backoff schedule
// ────────────────────────────────────────────────────────────────────────────
describe('reconnect backoff schedule (pure)', () => {
it('returns the documented bounded exponential delays per attempt', () => {
expect(reconnectDelayForAttempt(1)).toBe(5_000);
expect(reconnectDelayForAttempt(2)).toBe(15_000);
expect(reconnectDelayForAttempt(3)).toBe(45_000);
expect(reconnectDelayForAttempt(4)).toBe(120_000);
expect(reconnectDelayForAttempt(5)).toBe(300_000);
expect(reconnectDelayForAttempt(6)).toBe(300_000);
});
it('clamps below-range and above-range attempts to the schedule bounds', () => {
expect(reconnectDelayForAttempt(0)).toBe(BACKOFF_SCHEDULE_MS[0]);
expect(reconnectDelayForAttempt(-3)).toBe(BACKOFF_SCHEDULE_MS[0]);
expect(reconnectDelayForAttempt(99)).toBe(BACKOFF_SCHEDULE_MS[BACKOFF_SCHEDULE_MS.length - 1]);
});
it('flags exhaustion only at/after the cap', () => {
expect(isExhausted(0)).toBe(false);
expect(isExhausted(MAX_RECONNECT_ATTEMPTS - 1)).toBe(false);
expect(isExhausted(MAX_RECONNECT_ATTEMPTS)).toBe(true);
expect(isExhausted(MAX_RECONNECT_ATTEMPTS + 1)).toBe(true);
});
it('advanceBackoff increments attempts and schedules next-eligible from now (immutable)', () => {
const s0 = freshReconnectState();
const s1 = advanceBackoff(s0, 1_000);
expect(s0.attempts).toBe(0); // input untouched
expect(s1.attempts).toBe(1);
expect(s1.nextEligibleAt).toBe(1_000 + 5_000);
expect(s1.exhausted).toBe(false);
const s2 = advanceBackoff(s1, 10_000);
expect(s2.attempts).toBe(2);
expect(s2.nextEligibleAt).toBe(10_000 + 15_000);
});
it('reaches the attempt cap after the scheduled number of advances', () => {
let s = freshReconnectState();
let now = 0;
for (let i = 0; i < MAX_RECONNECT_ATTEMPTS; i++) {
s = advanceBackoff(s, now);
now += reconnectDelayForAttempt(s.attempts);
}
expect(s.attempts).toBe(MAX_RECONNECT_ATTEMPTS);
// advanceBackoff does not itself set `exhausted`; the watcher decides that
// on the following tick via isExhausted(attempts).
expect(isExhausted(s.attempts)).toBe(true);
});
it('reset-on-success returns to a fresh, immediately-eligible state', () => {
const advanced = advanceBackoff(advanceBackoff(freshReconnectState(), 0), 100);
expect(advanced.attempts).toBe(2);
const reset = resetReconnectState();
expect(reset.attempts).toBe(0);
expect(reset.nextEligibleAt).toBe(0);
expect(reset.exhausted).toBe(false);
});
});
// ────────────────────────────────────────────────────────────────────────────
// (b) PURE eligibility decision
// ────────────────────────────────────────────────────────────────────────────
describe('decideReconnect (pure eligibility)', () => {
const deadRemote: ReconnectSessionView = { sessionId: 's1', isRemote: true, paneDead: true };
it('emits for a dead remote pane that is not guarded and is due', () => {
const action = decideReconnect({
session: deadRemote,
state: freshReconnectState(),
guarded: false,
enabled: true,
now: 0,
});
expect(action).toEqual({ kind: 'emit', attempt: 1 });
});
it('NEVER reconnects a guarded (intentionally killed/detached) session', () => {
const action = decideReconnect({
session: deadRemote,
state: freshReconnectState(),
guarded: true,
enabled: true,
now: 0,
});
expect(action).toEqual({ kind: 'skip', reason: 'guarded' });
});
it('skips non-remote sessions', () => {
const action = decideReconnect({
session: { sessionId: 's1', isRemote: false, paneDead: true },
state: freshReconnectState(),
guarded: false,
enabled: true,
now: 0,
});
expect(action).toEqual({ kind: 'skip', reason: 'not-remote' });
});
it('skips when the pane is alive', () => {
const action = decideReconnect({
session: { sessionId: 's1', isRemote: true, paneDead: false },
state: freshReconnectState(),
guarded: false,
enabled: true,
now: 0,
});
expect(action).toEqual({ kind: 'skip', reason: 'pane-alive' });
});
it('skips when the kill-switch is off', () => {
const action = decideReconnect({
session: deadRemote,
state: freshReconnectState(),
guarded: false,
enabled: false,
now: 0,
});
expect(action).toEqual({ kind: 'skip', reason: 'disabled' });
});
it('skips when not yet due (within backoff window)', () => {
const state = advanceBackoff(freshReconnectState(), 0); // nextEligibleAt = 5000
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 4_999 });
expect(action).toEqual({ kind: 'skip', reason: 'not-due' });
});
it('emits again once the backoff window elapses', () => {
const state = advanceBackoff(freshReconnectState(), 0); // nextEligibleAt = 5000
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 5_000 });
expect(action).toEqual({ kind: 'emit', attempt: 2 });
});
it('skips while a reconnect is already in flight (no stacked respawns)', () => {
const state = { ...freshReconnectState(), inFlight: true };
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 10_000 });
expect(action).toEqual({ kind: 'skip', reason: 'in-flight' });
});
it('exhausts once the attempt cap is reached', () => {
const state = { ...freshReconnectState(), attempts: MAX_RECONNECT_ATTEMPTS, nextEligibleAt: 0 };
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 1_000_000 });
expect(action).toEqual({ kind: 'exhaust' });
});
it('stays quiet after exhaustion has been recorded', () => {
const state = { ...freshReconnectState(), attempts: MAX_RECONNECT_ATTEMPTS, exhausted: true };
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 1_000_000 });
expect(action).toEqual({ kind: 'skip', reason: 'exhausted' });
});
});
// ────────────────────────────────────────────────────────────────────────────
// (c) MANAGER integration — drive ticks with a stubbed pane-death + clock
// ────────────────────────────────────────────────────────────────────────────
describe('TmuxManager remote reconnect watcher (integration)', () => {
let manager: TmuxManager;
beforeEach(() => {
manager = new TmuxManager();
});
function registerRemote(sessionId: string): void {
manager.registerSession({
sessionId,
muxName: `codeman-${sessionId}`,
pid: 4242,
createdAt: Date.now(),
workingDir: '/home/aakhter',
mode: 'shell',
attached: true,
remote: REMOTE,
});
}
it('emits remoteSessionDropped when a dead remote pane is observed, then backs off and exhausts', () => {
registerRemote('aaaa1111');
// Force the watcher to see a dead pane regardless of test-mode isPaneDead.
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
const dropped: Array<{ sessionId: string; attempt: number }> = [];
const exhausted: Array<{ sessionId: string }> = [];
manager.on('remoteSessionDropped', (d) => dropped.push(d));
manager.on('remoteReconnectExhausted', (d) => exhausted.push(d));
// Drive ticks with a controllable clock. Each emit marks the session
// in-flight; a failed reattach (host still down) releases it via
// noteRemoteReconnect(false), mirroring the real server loop.
let now = 0;
for (let i = 0; i < MAX_RECONNECT_ATTEMPTS + 3; i++) {
manager.runRemoteReconnectTick(now, /* enabled */ true);
manager.noteRemoteReconnect('aaaa1111', false); // reattach failed → clear in-flight
// jump the clock past the just-scheduled backoff window
now += BACKOFF_SCHEDULE_MS[Math.min(i, BACKOFF_SCHEDULE_MS.length - 1)] + 1;
}
expect(dropped.map((d) => d.attempt)).toEqual([1, 2, 3, 4, 5, 6]);
expect(dropped.every((d) => d.sessionId === 'aaaa1111')).toBe(true);
expect(exhausted).toEqual([{ sessionId: 'aaaa1111' }]); // fired exactly once
});
it('emits nothing for a guarded (intentionally killed) session', () => {
registerRemote('bbbb2222');
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
manager.guardRemoteReconnect('bbbb2222'); // simulate killSession/detach guard
const dropped: unknown[] = [];
manager.on('remoteSessionDropped', (d) => dropped.push(d));
let now = 0;
for (let i = 0; i < 5; i++) {
manager.runRemoteReconnectTick(now, true);
now += 600_000;
}
expect(dropped).toEqual([]);
});
it('resets backoff on a successful reattach (noteRemoteReconnect)', () => {
registerRemote('cccc3333');
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
const dropped: Array<{ attempt: number }> = [];
manager.on('remoteSessionDropped', (d) => dropped.push(d));
manager.runRemoteReconnectTick(0, true); // emit attempt 1
manager.noteRemoteReconnect('cccc3333', true); // reattach succeeded → reset
manager.runRemoteReconnectTick(1, true); // immediately eligible again → attempt 1
expect(dropped.map((d) => d.attempt)).toEqual([1, 1]);
});
it('does nothing when the kill-switch (enabled=false) is off', () => {
registerRemote('dddd4444');
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
const dropped: unknown[] = [];
manager.on('remoteSessionDropped', (d) => dropped.push(d));
manager.runRemoteReconnectTick(0, false);
expect(dropped).toEqual([]);
});
it('clears per-session reconnect/guard state when the session is removed', () => {
registerRemote('eeee5555');
manager.guardRemoteReconnect('eeee5555');
manager.clearRemoteReconnectState('eeee5555');
// After clearing the guard, a fresh dead-pane observation should emit again.
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
const dropped: unknown[] = [];
manager.on('remoteSessionDropped', (d) => dropped.push(d));
manager.runRemoteReconnectTick(0, true);
expect(dropped).toEqual([{ sessionId: 'eeee5555', attempt: 1 }]);
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* @fileoverview COD-105 — discover & attach existing remote tmux sessions.
*
* Phase 2 of the remote-tmux arc (builds on COD-104 durable remote sessions +
* COD-107 connection args). These tests are tmux-safe / ssh-safe: they exercise
* the PURE parse helper, the pure attach-command builder, and the killSession
* ownership gate — none open a real ssh connection or a real tmux server.
*
* Port: N/A.
*/
import { execFileSync } from 'node:child_process';
import { describe, it, expect } from 'vitest';
import { parseRemoteSessionList } from '../src/remote-hosts.js';
import { buildRemoteAttachCommand } from '../src/tmux-manager.js';
import { TmuxManager } from '../src/tmux-manager.js';
import type { SessionRemote } from '../src/types.js';
const baseRemote: SessionRemote = {
hostId: 'gpu-box',
label: 'GPU Box',
host: '10.0.0.42',
username: 'ubuntu',
remotePath: '/home/ubuntu/work',
};
describe('COD-105 parseRemoteSessionList', () => {
it('parses tab-delimited -F output and coerces fields', () => {
const stdout = 'codeman-disco1\t0\t1700000000\t1\n' + 'codeman-abcd1234\t1\t1700000123\t3\n';
const list = parseRemoteSessionList(stdout);
expect(list).toEqual([
{ name: 'codeman-disco1', attached: false, attachedClients: 0, created: 1700000000, windows: 1 },
{ name: 'codeman-abcd1234', attached: true, attachedClients: 1, created: 1700000123, windows: 3 },
]);
});
it('parses the LITERAL backslash-t separator the remote tmux actually emits', () => {
// tmux next-3.7's `-F "…\t…"` does NOT expand \t — it prints a literal
// backslash-t (verified on aa-desktop). The parser must split on that.
const stdout = 'codeman-disco1\\t0\\t1781362858\\t1\n' + 'codeman-real\\t1\\t1781329905\\t2\n';
const list = parseRemoteSessionList(stdout);
expect(list).toEqual([
{ name: 'codeman-disco1', attached: false, attachedClients: 0, created: 1781362858, windows: 1 },
{ name: 'codeman-real', attached: true, attachedClients: 1, created: 1781329905, windows: 2 },
]);
});
it('keeps only codeman-* sessions, dropping foreign tmux sessions', () => {
const stdout = 'work\t1\t1700000000\t2\n' + 'codeman-keep\t0\t1700000001\t1\n' + 'scratch\t0\t1700000002\t1\n';
const list = parseRemoteSessionList(stdout);
expect(list.map((s) => s.name)).toEqual(['codeman-keep']);
});
it('returns [] for empty / whitespace output (the no-sessions case)', () => {
expect(parseRemoteSessionList('')).toEqual([]);
expect(parseRemoteSessionList(' \n \n')).toEqual([]);
});
it('tolerates malformed lines (missing columns) by skipping them', () => {
const stdout = 'codeman-ok\t0\t1700000000\t1\n' + 'codeman-bad\tnotanumber\n';
const list = parseRemoteSessionList(stdout);
expect(list).toEqual([
{ name: 'codeman-ok', attached: false, attachedClients: 0, created: 1700000000, windows: 1 },
]);
});
});
describe('COD-105 buildRemoteAttachCommand', () => {
it('emits ssh -t <target> tmux -L codeman attach -t <session>', () => {
const command = buildRemoteAttachCommand(baseRemote, 'codeman-disco1');
expect(command).toContain('ssh');
expect(command).toContain('BatchMode=yes');
expect(command).toContain('-t');
expect(command).toContain('ubuntu@10.0.0.42');
// The tmux invocation is nested-quoted (inner session name escaped, whole
// invocation re-escaped as one ssh arg). Assert the stable prefix here; the
// exact re-parsed token is verified by the argv-reparse test below.
expect(command).toContain('tmux -L codeman attach -t ');
expect(command).toContain('codeman-disco1');
});
it('threads the COD-107 connection args (port / identity / proxy) into the ssh invocation', () => {
const command = buildRemoteAttachCommand(
{ ...baseRemote, port: 2222, identityFile: '/keys/id_ed25519', socksProxy: '127.0.0.1:1080' },
'codeman-disco1'
);
expect(command).toContain('-p 2222');
expect(command).toContain("-i '/keys/id_ed25519'");
expect(command).toContain('ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p');
// Port/identity/proxy belong to ssh, ahead of the target.
expect(command).toMatch(/ssh[\s\S]*-p 2222[\s\S]*ubuntu@10\.0\.0\.42/);
});
it('shell-escapes the session name so it stays a single token', () => {
const command = buildRemoteAttachCommand(baseRemote, 'codeman-disco1');
// Re-parse: stub ssh to dump argv, confirm the trailing tmux invocation is one arg.
const dumpArgs = (name: string, prefix: string) =>
`${name}() { for a in "$@"; do printf '${prefix}:%s\\n' "$a"; done; }`;
const out = execFileSync('/bin/sh', ['-c', `${dumpArgs('ssh', 'A')}\n${command}`], { encoding: 'utf8' });
const sshArgs = out
.split('\n')
.filter((l) => l.startsWith('A:'))
.map((l) => l.slice(2));
expect(sshArgs).toContain('ubuntu@10.0.0.42');
const tmuxArg = sshArgs.find((a) => a.includes('attach'));
expect(tmuxArg).toBe("tmux -L codeman attach -t 'codeman-disco1'");
});
});
describe('COD-105 killSession ownership gate (detach-not-kill)', () => {
it('never issues a remote tmux kill-session for a non-owned remote session', async () => {
const mgr = new TmuxManager();
// Register a discovered+attached (non-owned) remote session.
mgr.registerSession({
sessionId: 'disco-1',
muxName: 'codeman-disco-1',
pid: 0,
createdAt: Date.now(),
workingDir: '/home/ubuntu/work',
mode: 'shell',
attached: false,
remote: { ...baseRemote, owned: false },
});
// killSession under VITEST is in-memory only (IS_TEST_MODE), so it physically
// cannot run a remote kill-session. We assert the contract: the session's
// ownership flag is the gate, and tearing it down removes only local state.
const session = mgr.getSession('disco-1');
expect(session?.remote?.owned).toBe(false);
const ok = await mgr.killSession('disco-1');
expect(ok).toBe(true);
// Local tracking removed; no remote kill was (or could be) issued.
expect(mgr.getSession('disco-1')).toBeUndefined();
});
it('treats COD-104 launched remote sessions as owned by default', () => {
const mgr = new TmuxManager();
mgr.registerSession({
sessionId: 'owned-1',
muxName: 'codeman-owned-1',
pid: 0,
createdAt: Date.now(),
workingDir: '/home/ubuntu/work',
mode: 'shell',
attached: false,
remote: { ...baseRemote, owned: true },
});
expect(mgr.getSession('owned-1')?.remote?.owned).toBe(true);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { buildRemoteKillCommand } from '../src/tmux-manager.js';
import type { SessionRemote } from '../src/types.js';
// COD-109 — terminate an OWNED durable remote tmux session by propagating
// `kill-session` to the remote host. Since COD-104 an owned remote session lives
// in the dedicated `-L codeman-remote` tmux server on the host and outlives the
// local ssh pane, so ending a session we OWN must reach the remote socket.
//
// The owned-kill command builder was consolidated upstream (PR #145) into the
// `{ remote, sessionId }` form, which derives the durable session name and kills
// on the dedicated `codeman-remote` socket (matching buildRemoteLaunchCommand).
// This suite pins that builder's contract; the killSession integration
// (owned-only, after COD-105's non-owned detach-only early-return) is exercised
// against the real remote.
describe('COD-109 buildRemoteKillCommand (owned durable remote kill)', () => {
const base: SessionRemote = {
hostId: 'h',
label: 'aa',
host: '192.168.55.170',
username: 'aakht',
remotePath: '/tmp',
};
it('kills the durable session on the dedicated codeman-remote socket (no ssh -t — non-interactive)', () => {
const cmd = buildRemoteKillCommand({ remote: base, sessionId: 'abc12345def' });
expect(cmd.startsWith('ssh -o BatchMode=yes ')).toBe(true);
expect(cmd).toContain('aakht@192.168.55.170');
// Owned sessions launch on `-L codeman-remote`; the kill MUST target the same socket.
expect(cmd).toContain('tmux -L codeman-remote kill-session -t');
// Deterministic session name derived from the sessionId (codeman-ssh-<first 8>).
expect(cmd).toContain('codeman-ssh-abc12345');
// kill-session needs no PTY — must NOT request the ssh `-t` flag (attach uses
// `ssh -o BatchMode=yes -t …`; kill must not). The ` -t ` inside the quoted
// `kill-session -t <name>` is the tmux target flag, which is expected.
expect(cmd).not.toContain('-o BatchMode=yes -t');
});
it('shares the default ConnectTimeout so an unreachable host fails fast (never blocks kill)', () => {
const cmd = buildRemoteKillCommand({ remote: base, sessionId: 'abc12345def' });
expect(cmd).toContain('-o ConnectTimeout=10');
});
it('reuses the COD-107 connection options (port / identity / SOCKS proxy)', () => {
const cmd = buildRemoteKillCommand({
remote: { ...base, port: 2222, identityFile: '~/.ssh/remote_ed25519', socksProxy: '127.0.0.1:1080' },
sessionId: 'abc12345def',
});
expect(cmd).toContain('-p 2222');
expect(cmd).toMatch(/-i '.*\/\.ssh\/remote_ed25519'/);
expect(cmd).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
});
it('shell-escapes the derived session name so metachars stay one token', () => {
const cmd = buildRemoteKillCommand({ remote: base, sessionId: "x'; rm -rf /" });
expect(cmd).not.toMatch(/rm -rf \/\s*$/); // not a bare trailing command
expect(cmd).toContain('kill-session -t');
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { buildRemoteLaunchCommand } from '../src/tmux-manager.js';
import { parseRemoteSessionList } from '../src/remote-hosts.js';
import type { SessionRemote } from '../src/types.js';
// COD-106 — shared/collaborative remote sessions: window-size policy so concurrent
// clients don't fight, and a client-count surfaced for the "shared · N" badge.
describe('COD-106 shared remote sessions', () => {
const remote: SessionRemote = {
hostId: 'h',
label: 'aa',
host: '192.168.55.170',
username: 'aakht',
remotePath: '/tmp',
commands: { shell: 'exec bash -l' },
};
it('launch command sets window-size latest (so multi-client attach does not clamp to smallest)', () => {
const cmd = buildRemoteLaunchCommand({ mode: 'shell', remote, sessionId: 'cod106aaa' });
// Per-session scoped on the dedicated `codeman-remote` socket (PR #145 hardening).
expect(cmd).toContain('set -t codeman-ssh-cod106aa window-size latest');
// still has the COD-104 config (no regression)
expect(cmd).toContain('set -t codeman-ssh-cod106aa status off');
expect(cmd).toContain('new-session -A -s codeman-ssh-cod106aa');
});
it('parses session_attached as a CLIENT COUNT (>1 = shared)', () => {
const rows = parseRemoteSessionList(
['codeman-solo\\t1\\t100\\t1', 'codeman-shared\\t2\\t200\\t3', 'codeman-idle\\t0\\t300\\t1'].join('\n')
);
const byName = Object.fromEntries(rows.map((r) => [r.name, r]));
expect(byName['codeman-solo'].attachedClients).toBe(1);
expect(byName['codeman-solo'].attached).toBe(true);
expect(byName['codeman-shared'].attachedClients).toBe(2); // shared
expect(byName['codeman-shared'].attached).toBe(true);
expect(byName['codeman-idle'].attachedClients).toBe(0);
expect(byName['codeman-idle'].attached).toBe(false);
});
});
+2
View File
@@ -157,6 +157,8 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
`set -t ${remoteName} mouse off`,
`set -t ${remoteName} prefix C-q`,
'set -s escape-time 0',
// COD-106 — shared/collaborative sizing, per-session scoped (never -g).
`set -t ${remoteName} window-size latest`,
].join(' \\; ');
// Connection args (with the default -o ConnectTimeout=10) sit after -t.
const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(tmuxInvocation)}`;
+91
View File
@@ -0,0 +1,91 @@
/**
* @fileoverview COD-143 — resuming a session from the Session Manager must retain its
* original tab name, not synthesize a fresh `w<N>-<dir>` name every time.
*
* Root cause: `resumeHistorySession(sessionId, workingDir)` ignored the row's `name` and
* always built `w<N>-<dir>` from the working dir. The fix threads the name through and
* extracts the choice into a pure `_resolveResumeName(existingName, workingDir)` helper:
* prefer a non-empty existing name; otherwise generate the next free `w<N>-<dir>` by
* scanning open sessions' names.
*
* This pins the helper's contract:
* 1. a non-empty existing name is returned verbatim (custom name retained),
* 2. a missing/empty/whitespace name falls back to `w<N>-<dir>`,
* 3. the generated number is the next free w-index across `this.sessions`,
* 4. the generated dir segment is the basename of workingDir (or `session` when empty).
*
* Loaded via `vm` against a stub `CodemanApp` (no jsdom — same harness as
* file-browser-reveal.test.ts / connection-indicator.test.ts). terminal-ui.js does
* `Object.assign(CodemanApp.prototype, {...})` at module-eval, so we capture the real
* `_resolveResumeName` off the prototype and invoke it against a minimal host whose
* `sessions` is a Map.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
/** Eval the shipping terminal-ui.js into a vm with a stub CodemanApp, return its prototype. */
function loadTerminalUiPrototype(): Record<string, (...args: unknown[]) => unknown> {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
document: { addEventListener: vi.fn(), getElementById: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
});
vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context);
return (context as { __proto: Record<string, (...args: unknown[]) => unknown> }).__proto;
}
const proto = loadTerminalUiPrototype();
/** Minimal host carrying the real `_resolveResumeName` + a sessions Map. */
function makeApp(sessionNames: string[] = []) {
const sessions = new Map<string, { name: string }>();
sessionNames.forEach((name, i) => sessions.set(`s${i}`, { name }));
return {
sessions,
_resolveResumeName: proto._resolveResumeName as (existingName: unknown, workingDir: unknown) => string,
};
}
describe('COD-143 _resolveResumeName', () => {
it('returns a non-empty existing name verbatim (custom name retained)', () => {
const app = makeApp(['w1-foo', 'w2-bar']);
expect(app._resolveResumeName.call(app, 'my-custom-tab', '/home/me/proj')).toBe('my-custom-tab');
});
it('falls back to w<N>-<dir> when no name is given', () => {
const app = makeApp([]);
expect(app._resolveResumeName.call(app, undefined, '/home/me/proj')).toBe('w1-proj');
});
it('treats empty / whitespace names as no-name (falls back)', () => {
const app = makeApp([]);
expect(app._resolveResumeName.call(app, '', '/a/b/widgets')).toBe('w1-widgets');
expect(app._resolveResumeName.call(app, ' ', '/a/b/widgets')).toBe('w1-widgets');
});
it('generated w-number is the next free index across open sessions', () => {
const app = makeApp(['w1-foo', 'w3-bar', 'plain-name']);
// highest w<N> is 3 → next is 4
expect(app._resolveResumeName.call(app, null, '/x/y/svc')).toBe('w4-svc');
});
it('uses "session" as the dir segment when workingDir is empty', () => {
const app = makeApp([]);
expect(app._resolveResumeName.call(app, '', '')).toBe('w1-session');
});
it('does not let a generated fallback clobber an explicit name even when sessions exist', () => {
const app = makeApp(['w1-foo', 'w2-bar']);
expect(app._resolveResumeName.call(app, 'keepme', '/p/q')).toBe('keepme');
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* @fileoverview COD-105 — GET /api/remote-hosts/:hostId/sessions discovery endpoint.
*
* The endpoint reads the saved host config by id, runs listRemoteCodemanSessions
* (ssh-guarded under VITEST), and returns the discovered sessions in the
* ApiResponse envelope. We mock the remote-hosts module so the test controls the
* host record and the session list WITHOUT any real ssh / filesystem.
*
* Port: N/A (app.inject()).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import type { RemoteHost, RemoteSessionInfo } from '../../src/types.js';
// Mock the remote-hosts module: control readRemoteHosts + listRemoteCodemanSessions.
const mockHosts: RemoteHost[] = [];
let mockSessions: RemoteSessionInfo[] = [];
let lastListArg: unknown = undefined;
vi.mock('../../src/remote-hosts.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/remote-hosts.js')>();
return {
...actual,
readRemoteHosts: vi.fn(async () => mockHosts),
readRemoteCases: vi.fn(async () => []),
listRemoteCodemanSessions: vi.fn(async (remote: unknown) => {
lastListArg = remote;
return mockSessions;
}),
};
});
vi.mock('../../src/templates/claude-md.js', () => ({
generateClaudeMd: vi.fn(() => '# CLAUDE.md'),
}));
vi.mock('../../src/hooks-config.js', () => ({ writeHooksConfig: vi.fn(async () => {}) }));
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
async function createHarness(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
const ctx = createMockRouteContext();
registerCaseRoutes(app, ctx as never);
installRouteErrorHandler(app);
await app.ready();
return app;
}
describe('COD-105 GET /api/remote-hosts/:hostId/sessions', () => {
let app: FastifyInstance;
beforeEach(async () => {
app = await createHarness();
mockHosts.length = 0;
mockSessions = [];
lastListArg = undefined;
});
afterEach(async () => {
await app.close();
});
it('returns discovered sessions for a known host in the envelope', async () => {
mockHosts.push({ id: 'aa-desktop', label: 'aa', host: '1.2.3.4', username: 'aakht', port: 2222 });
mockSessions = [{ name: 'codeman-disco1', attached: false, created: 1700000000, windows: 1 }];
const res = await app.inject({ method: 'GET', url: '/api/remote-hosts/aa-desktop/sessions' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.sessions).toEqual([{ name: 'codeman-disco1', attached: false, created: 1700000000, windows: 1 }]);
// The host config (incl. port) was threaded to the discovery call.
expect((lastListArg as { host?: string; port?: number }).host).toBe('1.2.3.4');
expect((lastListArg as { port?: number }).port).toBe(2222);
});
it('404s when the host id is unknown', async () => {
const res = await app.inject({ method: 'GET', url: '/api/remote-hosts/nope/sessions' });
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe(ApiErrorCode.NOT_FOUND);
});
it('returns an empty list (not an error) when no sessions are discovered', async () => {
mockHosts.push({ id: 'aa-desktop', label: 'aa', host: '1.2.3.4', username: 'aakht' });
mockSessions = [];
const res = await app.inject({ method: 'GET', url: '/api/remote-hosts/aa-desktop/sessions' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.sessions).toEqual([]);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* @fileoverview Tests for PUT /api/session-order (global tab-order sync, COD-131).
*
* Uses app.inject() — no real HTTP ports needed.
* Asserts the uniform envelope contract:
* SUCCESS -> 2xx, { success: true, data: { order } }
* ERROR -> 4xx/5xx, { success: false, error, errorCode }
* and that the order is persisted to the (mock) StateStore + broadcast over SSE.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
// registerSessionRoutes pulls in session.js which can shell out; stub the bits
// that would touch the OS at import/registration time. None are needed by the
// session-order handler itself, but the module imports them.
vi.mock('node:child_process', async (orig) => {
const actual = await orig<typeof import('node:child_process')>();
return { ...actual, execFile: vi.fn(), spawn: vi.fn() };
});
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
interface LocalHarness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function buildHarness(): Promise<LocalHarness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx as unknown as Parameters<typeof registerSessionRoutes>[1]);
// Mirror production's uniform-envelope preSerialization hook (server.ts).
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('PUT /api/session-order', () => {
let harness: LocalHarness;
beforeEach(async () => {
harness = await buildHarness();
});
afterEach(async () => {
await harness.app.close();
});
it('persists the order and returns it in the envelope', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
payload: { order: ['a', 'b', 'c'] },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body).toEqual({ success: true, data: { order: ['a', 'b', 'c'] } });
// Persisted to the store.
expect(harness.ctx.store.setSessionOrder).toHaveBeenCalledWith(['a', 'b', 'c']);
expect(harness.ctx.store.getSessionOrder()).toEqual(['a', 'b', 'c']);
// Broadcast over SSE.
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['a', 'b', 'c'] });
});
it('preserves a server-only id (unknown to the pushing device) at the end', async () => {
// Seed the store with an order containing a server-only id "z".
harness.ctx.store.setSessionOrder(['a', 'z', 'b']);
(harness.ctx.broadcast as ReturnType<typeof vi.fn>).mockClear();
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
payload: { order: ['b', 'a'] },
});
expect(res.statusCode).toBe(200);
const body = res.json();
// Incoming order wins, server-only "z" falls to the end.
expect(body).toEqual({ success: true, data: { order: ['b', 'a', 'z'] } });
expect(harness.ctx.store.getSessionOrder()).toEqual(['b', 'a', 'z']);
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['b', 'a', 'z'] });
});
it('normalizes junk input (dedup + drop empties) before persisting', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
payload: { order: ['a', 'a', '', 'b'] },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: { order: ['a', 'b'] } });
});
it('rejects a non-array order with a 4xx envelope', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
payload: { order: 'nope' },
});
expect(res.statusCode).toBeGreaterThanOrEqual(400);
const body = res.json();
expect(body.success).toBe(false);
expect(harness.ctx.store.setSessionOrder).not.toHaveBeenCalled();
});
});
+169
View File
@@ -0,0 +1,169 @@
/**
* @fileoverview Route tests for POST /api/sessions/:id/pin (COD-139).
*
* Pinning floats a session to the top of the unified session list. The route
* sets the session's pin flag, persists it, and broadcasts session:pinned.
* Uses app.inject() with the production-mirroring envelope harness.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
interface LocalHarness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createEnvelopeHarness(): Promise<LocalHarness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('POST /api/sessions/:id/pin', () => {
let harness: LocalHarness;
beforeEach(async () => {
harness = await createEnvelopeHarness();
});
afterEach(async () => {
await harness.app.close();
});
it('pins a session: sets state, persists, and broadcasts session:pinned', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/pin',
payload: { pinned: true },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.pinned).toBe(true);
expect(typeof body.data.pinnedAt).toBe('number');
expect(harness.ctx._session.pinned).toBe(true);
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
const broadcastCalls = harness.ctx.broadcast.mock.calls.map((c) => c[0]);
expect(broadcastCalls).toContain('session:pinned');
});
it('unpins a session and clears pinnedAt', async () => {
await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/pin',
payload: { pinned: true },
});
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/pin',
payload: { pinned: false },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data.pinned).toBe(false);
expect(body.data.pinnedAt).toBeUndefined();
expect(harness.ctx._session.pinned).toBe(false);
});
it('is idempotent for an explicit pinned value', async () => {
for (let i = 0; i < 3; i++) {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/pin',
payload: { pinned: true },
});
expect(res.statusCode).toBe(200);
expect(res.json().data.pinned).toBe(true);
}
expect(harness.ctx._session.pinned).toBe(true);
});
it('returns 404 for an unknown session', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/does-not-exist/pin',
payload: { pinned: true },
});
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
it('rejects a missing/invalid body with 400', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/pin',
payload: { pinned: 'yes' },
});
expect(res.statusCode).toBe(400);
expect(res.json().success).toBe(false);
});
// COD-142 keeps a pinned session's persisted record after kill, so pin toggles
// must work WITHOUT a live Session — otherwise a pinned-then-killed record could
// never be unpinned (cleanupStaleSessions deliberately skips pinned records).
it('unpins a persisted-only (killed but pinned) record via the store fallback', async () => {
const persisted = { id: 'dead-1', name: 'Dead', status: 'stopped', pinned: true, pinnedAt: 123 };
(harness.ctx.store.getSession as ReturnType<typeof vi.fn>).mockReturnValue(persisted);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/dead-1/pin',
payload: { pinned: false },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data.pinned).toBe(false);
expect(body.data.pinnedAt).toBeUndefined();
expect(harness.ctx.store.setSession).toHaveBeenCalledWith(
'dead-1',
expect.objectContaining({ pinned: undefined, pinnedAt: undefined })
);
const broadcastCalls = harness.ctx.broadcast.mock.calls.map((c) => c[0]);
expect(broadcastCalls).toContain('session:pinned');
});
it('re-pins a persisted-only record via the store fallback', async () => {
const persisted = { id: 'dead-2', name: 'Dead2', status: 'stopped' };
(harness.ctx.store.getSession as ReturnType<typeof vi.fn>).mockReturnValue(persisted);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/dead-2/pin',
payload: { pinned: true },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data.pinned).toBe(true);
expect(typeof body.data.pinnedAt).toBe('number');
expect(harness.ctx.store.setSession).toHaveBeenCalledWith(
'dead-2',
expect.objectContaining({ pinned: true, pinnedAt: expect.any(Number) })
);
});
});
+105
View File
@@ -73,6 +73,101 @@ describe('run mode UI', () => {
});
});
describe('Run launch synchronization', () => {
it('coalesces overlapping Run activations and disables the button while the request is active', async () => {
const runBtn = {
disabled: false,
setAttribute: vi.fn(),
removeAttribute: vi.fn(),
};
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: (id: string) => (id === 'runBtn' ? runBtn : null) },
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app._runMinLockMs = 0;
let finishRun!: () => void;
app.runClaude = vi.fn(
() =>
new Promise<void>((resolveRun) => {
finishRun = resolveRun;
})
);
const first = app.run();
const duplicate = app.run();
expect(app.runClaude).toHaveBeenCalledTimes(1);
expect(runBtn.disabled).toBe(true);
expect(runBtn.setAttribute).toHaveBeenCalledWith('aria-busy', 'true');
finishRun();
await Promise.all([first, duplicate]);
expect(runBtn.disabled).toBe(false);
expect(runBtn.removeAttribute).toHaveBeenCalledWith('aria-busy');
});
it('renders a POST response session immediately without waiting for SSE', async () => {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
fetch: vi.fn(),
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.sessions = new Map();
app._onSessionCreated = vi.fn((session: any) => app.sessions.set(session.id, session));
app._renderSessionTabsImmediate = vi.fn();
const snapshot = { id: 'sess-new', name: 'w1-case', workingDir: '/tmp/case' };
await app._ensureCreatedSessionVisible(snapshot.id, snapshot);
expect(context.fetch).not.toHaveBeenCalled();
expect(app.sessions.get(snapshot.id)).toEqual(snapshot);
expect(app._renderSessionTabsImmediate).toHaveBeenCalledTimes(1);
});
it('loads the new session when a quick-start response wins the race with SSE', async () => {
const snapshot = { id: 'sess-race', name: 'w1-remote', workingDir: '/remote/work' };
const fetchMock = vi.fn(async () => ({
json: async () => ({ success: true, data: snapshot }),
}));
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
fetch: fetchMock,
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.sessions = new Map();
app._onSessionCreated = vi.fn((session: any) => app.sessions.set(session.id, session));
app._renderSessionTabsImmediate = vi.fn();
await app._ensureCreatedSessionVisible(snapshot.id);
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/sess-race');
expect(app.sessions.get(snapshot.id)).toEqual(snapshot);
expect(app._renderSessionTabsImmediate).toHaveBeenCalledTimes(1);
});
});
describe('Codex quick start settings', () => {
it('renders Codex CLI settings in a dedicated app settings tab', () => {
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
@@ -113,6 +208,8 @@ describe('Codex quick start settings', () => {
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
if (url === '/api/codex/status') return { json: async () => ({ success: true, data: { available: true } }) };
if (url === '/api/quick-start') return { json: async () => ({ success: true, data: { sessionId: 'sess-1' } }) };
if (url === '/api/sessions/sess-1')
return { json: async () => ({ success: true, data: { id: 'sess-1', name: 'w1-codex-case' } }) };
throw new Error(`unexpected fetch: ${url}`);
},
console,
@@ -128,6 +225,9 @@ describe('Codex quick start settings', () => {
});
app.getCaseSettings = () => ({});
app.buildEnvOverrides = () => ({});
app.sessions = new Map();
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
app._renderSessionTabsImmediate = vi.fn();
const selected: string[] = [];
app.selectSession = async (id: string) => {
selected.push(id);
@@ -424,6 +524,8 @@ describe('Gemini quick start', () => {
if (url === '/api/gemini/status') return { json: async () => ({ success: true, data: { available: true } }) };
if (url === '/api/quick-start')
return { json: async () => ({ success: true, data: { sessionId: 'sess-gm' } }) };
if (url === '/api/sessions/sess-gm')
return { json: async () => ({ success: true, data: { id: 'sess-gm', name: 'w1-gemini-case' } }) };
throw new Error(`unexpected fetch: ${url}`);
},
console,
@@ -437,6 +539,9 @@ describe('Gemini quick start', () => {
app.loadAppSettingsFromStorage = () => ({});
app.getCaseSettings = () => ({});
app.buildEnvOverrides = () => ({});
app.sessions = new Map();
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
app._renderSessionTabsImmediate = vi.fn();
const selected: string[] = [];
app.selectSession = async (id: string) => {
selected.push(id);
+224 -5
View File
@@ -163,6 +163,211 @@ describe('mergeUnifiedSessions', () => {
expect(merged).toHaveLength(1);
expect(merged[0].projectKey).toBe('-repo-alpha');
});
// COD-140: firstPrompt backfill — live sessions whose Codeman id does not match an
// on-disk transcript UUID still surface a first prompt (by claudeSessionId join, then
// by newest transcript in the same workingDir).
it('backfills firstPrompt onto a live session by claudeSessionId join (uuid-join)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-1', status: 'working', claudeSessionId: 'uuid-A', workingDir: '/w' }],
history: [
{
sessionId: 'uuid-A',
workingDir: '/w',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'fix the bug',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-1');
expect(live).toBeDefined();
expect(live!.firstPrompt).toBe('fix the bug');
// The upstream unified-service alias map (COD-160/161) folds a history row keyed
// by the Claude conversation UUID into the owning live session (claudeSessionId
// join), so it does NOT surface as a separate item — the firstPrompt reaches the
// live row above rather than a duplicate uuid-A entry.
const hist = merged.find((m) => m.sessionId === 'uuid-A');
expect(hist).toBeUndefined();
});
it('falls back to the workingDir transcript when no uuid join exists (workingDir fallback)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-2', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/w2' }],
history: [
{
sessionId: 'uuid-other',
workingDir: '/w2',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'borrowed prompt',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-2');
expect(live).toBeDefined();
expect(live!.firstPrompt).toBe('borrowed prompt');
});
it('uses the newest transcript per workingDir for the fallback (newest-wins)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-3', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/w3' }],
history: [
{
sessionId: 'uuid-old',
workingDir: '/w3',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'older prompt',
},
{
sessionId: 'uuid-new',
workingDir: '/w3',
sizeBytes: 6000,
lastModified: '2026-02-01T00:00:00.000Z',
firstPrompt: 'newer prompt',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-3');
expect(live).toBeDefined();
expect(live!.firstPrompt).toBe('newer prompt');
});
it('never overwrites a firstPrompt that already merged from the session own transcript (no overwrite)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'self-uuid', status: 'working', claudeSessionId: 'self-uuid', workingDir: '/w4' }],
history: [
// the session's own transcript (keyed by its id) — provides the real prompt
{
sessionId: 'self-uuid',
workingDir: '/w4',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'own prompt',
},
// a newer sibling transcript in the same dir that must NOT clobber it
{
sessionId: 'sibling-uuid',
workingDir: '/w4',
sizeBytes: 6000,
lastModified: '2026-03-01T00:00:00.000Z',
firstPrompt: 'sibling prompt',
},
],
});
const self = merged.find((m) => m.sessionId === 'self-uuid');
expect(self).toBeDefined();
expect(self!.firstPrompt).toBe('own prompt');
});
it('leaves firstPrompt undefined when there is no transcript at all (no transcript)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-5', status: 'working', claudeSessionId: 'uuid-none', workingDir: '/empty' }],
});
const live = merged.find((m) => m.sessionId === 'codeman-5');
expect(live).toBeDefined();
expect(live!.firstPrompt).toBeUndefined();
});
// COD-145: lastPrompt backfill — mirrors the COD-140 firstPrompt path so the
// most-recent user prompt also reaches live rows whose id ≠ transcript UUID.
it('backfills lastPrompt onto a live session by claudeSessionId join (uuid-join)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-l1', status: 'working', claudeSessionId: 'uuid-LA', workingDir: '/wl' }],
history: [
{
sessionId: 'uuid-LA',
workingDir: '/wl',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'fix the bug',
lastPrompt: 'now ship it',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-l1');
expect(live).toBeDefined();
expect(live!.lastPrompt).toBe('now ship it');
// The upstream unified-service alias map (COD-160/161) folds the UUID-keyed
// history row into the owning live session, so lastPrompt reaches the live row
// above rather than surfacing as a separate uuid-LA entry.
const hist = merged.find((m) => m.sessionId === 'uuid-LA');
expect(hist).toBeUndefined();
});
it('falls back to the workingDir transcript for lastPrompt when no uuid join exists (workingDir fallback)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-l2', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/wl2' }],
history: [
{
sessionId: 'uuid-other',
workingDir: '/wl2',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'borrowed first',
lastPrompt: 'borrowed last',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-l2');
expect(live).toBeDefined();
expect(live!.lastPrompt).toBe('borrowed last');
});
it('uses the newest transcript per workingDir for the lastPrompt fallback (newest-wins)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-l3', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/wl3' }],
history: [
{
sessionId: 'uuid-old',
workingDir: '/wl3',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'older first',
lastPrompt: 'older last',
},
{
sessionId: 'uuid-new',
workingDir: '/wl3',
sizeBytes: 6000,
lastModified: '2026-02-01T00:00:00.000Z',
firstPrompt: 'newer first',
lastPrompt: 'newer last',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-l3');
expect(live).toBeDefined();
expect(live!.lastPrompt).toBe('newer last');
});
it('never overwrites a lastPrompt that already merged from the session own transcript (no overwrite)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'self-luuid', status: 'working', claudeSessionId: 'self-luuid', workingDir: '/wl4' }],
history: [
{
sessionId: 'self-luuid',
workingDir: '/wl4',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'own first',
lastPrompt: 'own last',
},
{
sessionId: 'sibling-uuid',
workingDir: '/wl4',
sizeBytes: 6000,
lastModified: '2026-03-01T00:00:00.000Z',
firstPrompt: 'sibling first',
lastPrompt: 'sibling last',
},
],
});
const self = merged.find((m) => m.sessionId === 'self-luuid');
expect(self).toBeDefined();
expect(self!.lastPrompt).toBe('own last');
});
});
describe('filterAndPaginate', () => {
@@ -170,6 +375,14 @@ describe('filterAndPaginate', () => {
{ sessionId: 's1', name: 'Alpha build', sources: ['live'], workingDir: '/repo/alpha' },
{ sessionId: 's2', name: 'Beta', firstPrompt: 'fix the login bug', sources: ['history'], workingDir: '/repo/beta' },
{ sessionId: 's3', name: 'Gamma', sources: ['persisted'], workingDir: '/srv/gamma' },
{
sessionId: 's4',
name: 'Delta',
firstPrompt: 'start the migration',
lastPrompt: 'roll back the migration',
sources: ['history'],
workingDir: '/repo/delta',
},
];
it('filters by name (case-insensitive)', () => {
@@ -183,18 +396,24 @@ describe('filterAndPaginate', () => {
expect(filterAndPaginate(items, { q: '/srv/' }).sessions[0].sessionId).toBe('s3');
});
it('filters by lastPrompt (COD-145)', () => {
const r = filterAndPaginate(items, { q: 'roll back' });
expect(r.total).toBe(1);
expect(r.sessions[0].sessionId).toBe('s4');
});
it('reports total as the pre-page filtered count', () => {
const r = filterAndPaginate(items, { q: 'repo', limit: 1 });
// both s1 and s2 have /repo/ workingDir
expect(r.total).toBe(2);
// s1, s2, and s4 all have /repo/ workingDir
expect(r.total).toBe(3);
expect(r.sessions).toHaveLength(1);
});
it('clamps limit to a max of 500', () => {
const r = filterAndPaginate(items, { limit: 99999 });
expect(r.sessions).toHaveLength(items.length);
// clamp does not throw and returns all 3 (< 500)
expect(r.total).toBe(3);
// clamp does not throw and returns all items (< 500)
expect(r.total).toBe(items.length);
});
it('clamps limit to a min of 1', () => {
@@ -206,7 +425,7 @@ describe('filterAndPaginate', () => {
const page1 = filterAndPaginate(items, { offset: 0, limit: 2 });
const page2 = filterAndPaginate(items, { offset: 2, limit: 2 });
expect(page1.sessions.map((s) => s.sessionId)).toEqual(['s1', 's2']);
expect(page2.sessions.map((s) => s.sessionId)).toEqual(['s3']);
expect(page2.sessions.map((s) => s.sessionId)).toEqual(['s3', 's4']);
const overlap = page1.sessions
.map((s) => s.sessionId)
.filter((id) => page2.sessions.map((s2) => s2.sessionId).includes(id));
+74
View File
@@ -0,0 +1,74 @@
/**
* @fileoverview Unit tests for the pure session-order helpers
* (normalizeSessionOrder, mergeSessionOrder) used by global tab-order sync (COD-131).
*/
import { describe, it, expect } from 'vitest';
import { normalizeSessionOrder, mergeSessionOrder } from '../src/session-order.js';
describe('normalizeSessionOrder', () => {
it('keeps a clean array unchanged', () => {
expect(normalizeSessionOrder(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
});
it('dedups, first occurrence wins', () => {
expect(normalizeSessionOrder(['a', 'b', 'a', 'c', 'b'])).toEqual(['a', 'b', 'c']);
});
it('drops empty strings', () => {
expect(normalizeSessionOrder(['a', '', 'b', ' '])).toEqual(['a', 'b', ' ']);
expect(normalizeSessionOrder([''])).toEqual([]);
});
it('drops non-string entries', () => {
expect(normalizeSessionOrder(['a', 1, null, undefined, {}, 'b', true])).toEqual(['a', 'b']);
});
it('returns [] for non-array input', () => {
expect(normalizeSessionOrder(undefined)).toEqual([]);
expect(normalizeSessionOrder(null)).toEqual([]);
expect(normalizeSessionOrder('abc')).toEqual([]);
expect(normalizeSessionOrder(42)).toEqual([]);
expect(normalizeSessionOrder({ 0: 'a' })).toEqual([]);
});
it('returns [] for empty array', () => {
expect(normalizeSessionOrder([])).toEqual([]);
});
});
describe('mergeSessionOrder', () => {
it('incoming order wins', () => {
expect(mergeSessionOrder(['c', 'a', 'b'], ['a', 'b', 'c'])).toEqual(['c', 'a', 'b']);
});
it('appends server-only ids (not in incoming) at the end, preserving their relative order', () => {
expect(mergeSessionOrder(['a', 'b'], ['x', 'a', 'y', 'b', 'z'])).toEqual(['a', 'b', 'x', 'y', 'z']);
});
it('empty incoming yields the existing order (normalized)', () => {
expect(mergeSessionOrder([], ['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
});
it('empty existing yields the incoming order (normalized)', () => {
expect(mergeSessionOrder(['a', 'b', 'c'], [])).toEqual(['a', 'b', 'c']);
});
it('both empty yields empty', () => {
expect(mergeSessionOrder([], [])).toEqual([]);
});
it('normalizes both args (dedup + drop junk) before merging', () => {
expect(mergeSessionOrder(['a', 'a', '', 'b'], ['b', 'c', 'c', ''])).toEqual(['a', 'b', 'c']);
});
it('does not duplicate an id present in both', () => {
expect(mergeSessionOrder(['a', 'b'], ['b', 'a'])).toEqual(['a', 'b']);
});
it('handles non-array / junk inputs defensively', () => {
// @ts-expect-error testing runtime robustness against bad input
expect(mergeSessionOrder(null, ['a', 'b'])).toEqual(['a', 'b']);
// @ts-expect-error testing runtime robustness against bad input
expect(mergeSessionOrder(['a'], 'nope')).toEqual(['a']);
});
});
+85
View File
@@ -0,0 +1,85 @@
/**
* @fileoverview Unit tests for session pinning (COD-139) — the pure
* merge/sort layer in unified-session-service.ts.
*
* Pinned sessions float to the top of the unified session list, ordered by
* pinnedAt descending (most-recently-pinned first). Unpinned sessions keep the
* existing lastActivityAt-desc ordering. Pin state flows through both the live
* and persisted inputs so it survives a reload (live → persisted-only on boot).
*/
import { describe, it, expect } from 'vitest';
import { mergeUnifiedSessions } from '../src/services/unified-session-service.js';
describe('mergeUnifiedSessions — pinning (COD-139)', () => {
it('floats a pinned session above unpinned ones regardless of activity', () => {
const merged = mergeUnifiedSessions({
live: [
{ id: 'a', name: 'A', lastActivityAt: 100 },
{ id: 'b', name: 'B', lastActivityAt: 5000, pinned: true, pinnedAt: 200 },
{ id: 'c', name: 'C', lastActivityAt: 9000 },
],
});
// b is pinned → first, even though c is the most-recently active.
expect(merged.map((m) => m.sessionId)).toEqual(['b', 'c', 'a']);
expect(merged[0].pinned).toBe(true);
});
it('orders multiple pinned sessions by pinnedAt descending (most recent first)', () => {
const merged = mergeUnifiedSessions({
live: [
{ id: 'p1', name: 'P1', lastActivityAt: 1, pinned: true, pinnedAt: 100 },
{ id: 'p2', name: 'P2', lastActivityAt: 2, pinned: true, pinnedAt: 300 },
{ id: 'p3', name: 'P3', lastActivityAt: 3, pinned: true, pinnedAt: 200 },
{ id: 'u', name: 'U', lastActivityAt: 9999 },
],
});
// Pinned group sorted by pinnedAt desc: p2(300) p3(200) p1(100); then unpinned.
expect(merged.map((m) => m.sessionId)).toEqual(['p2', 'p3', 'p1', 'u']);
});
it('keeps the existing activity-desc order among unpinned sessions', () => {
const merged = mergeUnifiedSessions({
live: [
{ id: 'old', name: 'Old', lastActivityAt: 100 },
{ id: 'new', name: 'New', lastActivityAt: 900 },
{ id: 'mid', name: 'Mid', lastActivityAt: 500 },
],
});
expect(merged.map((m) => m.sessionId)).toEqual(['new', 'mid', 'old']);
});
it('surfaces pin state from a persisted-only session (survives reload)', () => {
// On boot, a live session becomes persisted-only (status stopped). The pin
// flag must come through the persisted input so it still floats to the top.
const merged = mergeUnifiedSessions({
persisted: [
{ id: 'fresh', name: 'Fresh', lastActivityAt: 5000 },
{ id: 'pinned', name: 'Pinned', lastActivityAt: 1, pinned: true, pinnedAt: 42 },
],
});
expect(merged[0].sessionId).toBe('pinned');
expect(merged[0].pinned).toBe(true);
expect(merged[0].pinnedAt).toBe(42);
});
it('live pin overrides a stale persisted unpinned value (live precedence)', () => {
const merged = mergeUnifiedSessions({
persisted: [{ id: 's', name: 'S', lastActivityAt: 10 }],
live: [{ id: 's', name: 'S', lastActivityAt: 10, pinned: true, pinnedAt: 77 }],
});
const s = merged.find((m) => m.sessionId === 's');
expect(s?.pinned).toBe(true);
expect(s?.pinnedAt).toBe(77);
});
it('treats pinned:false the same as unpinned', () => {
const merged = mergeUnifiedSessions({
live: [
{ id: 'x', name: 'X', lastActivityAt: 100, pinned: false },
{ id: 'y', name: 'Y', lastActivityAt: 900, pinned: false },
],
});
expect(merged.map((m) => m.sessionId)).toEqual(['y', 'x']);
});
});
+60
View File
@@ -141,6 +141,66 @@ describe('StateStore', () => {
});
});
describe('demoteOrRemoveSession and pinned cleanup (COD-142)', () => {
it('should preserve a pinned session as a stopped record on kill', () => {
const store = new StateStore(testFilePath);
const pinnedAt = Date.now();
store.setSession('pinned-1', {
...createMockSessionState('pinned-1'),
name: 'My Pinned Session',
workingDir: '/tmp/pinned',
pinned: true,
pinnedAt,
});
const result = store.demoteOrRemoveSession('pinned-1');
expect(result).toBe('preserved');
const preserved = store.getSession('pinned-1');
expect(preserved).not.toBeNull();
expect(preserved?.status).toBe('stopped');
expect(preserved?.pid).toBeNull();
expect(preserved?.pinned).toBe(true);
expect(preserved?.pinnedAt).toBe(pinnedAt);
expect(preserved?.name).toBe('My Pinned Session');
expect(preserved?.workingDir).toBe('/tmp/pinned');
});
it('should fully remove an unpinned session on kill', () => {
const store = new StateStore(testFilePath);
store.setSession('plain-1', createMockSessionState('plain-1'));
const result = store.demoteOrRemoveSession('plain-1');
expect(result).toBe('removed');
expect(store.getSession('plain-1')).toBeNull();
});
it('should report absent for an unknown session id', () => {
const store = new StateStore(testFilePath);
expect(store.demoteOrRemoveSession('does-not-exist')).toBe('absent');
});
it('should keep pinned records during cleanupStaleSessions but reap unpinned ones', () => {
const store = new StateStore(testFilePath);
store.setSession('pinned-1', {
...createMockSessionState('pinned-1'),
pinned: true,
pinnedAt: Date.now(),
});
store.setSession('plain-1', createMockSessionState('plain-1'));
const result = store.cleanupStaleSessions(new Set<string>());
expect(result.count).toBe(1);
expect(result.cleaned.map((c) => c.id)).toEqual(['plain-1']);
expect(result.cleaned.some((c) => c.id === 'pinned-1')).toBe(false);
expect(store.getSession('pinned-1')).not.toBeNull();
expect(store.getSession('plain-1')).toBeNull();
});
});
describe('task operations', () => {
it('should set and get tasks', () => {
const store = new StateStore(testFilePath);
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { CreateSessionSchema, QuickRunSchema, ScheduledRunSchema } from '../src/web/schemas.js';
describe('working directory schemas', () => {
const unicodeWorkingDir = '/mnt/d/AI/中文项目';
it('accepts Unicode paths for session creation and run requests', () => {
expect(CreateSessionSchema.safeParse({ workingDir: unicodeWorkingDir, mode: 'codex' }).success).toBe(true);
expect(QuickRunSchema.safeParse({ workingDir: unicodeWorkingDir, prompt: 'test' }).success).toBe(true);
expect(
ScheduledRunSchema.safeParse({ workingDir: unicodeWorkingDir, prompt: 'test', durationMinutes: 10 }).success
).toBe(true);
});
it('continues to reject shell metacharacters in Unicode paths', () => {
const unsafeWorkingDir = `${unicodeWorkingDir};rm -rf /`;
expect(CreateSessionSchema.safeParse({ workingDir: unsafeWorkingDir, mode: 'codex' }).success).toBe(false);
expect(QuickRunSchema.safeParse({ workingDir: unsafeWorkingDir, prompt: 'test' }).success).toBe(false);
expect(
ScheduledRunSchema.safeParse({ workingDir: unsafeWorkingDir, prompt: 'test', durationMinutes: 10 }).success
).toBe(false);
});
});