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
35 changed files with 2997 additions and 326 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/
+62
View File
@@ -1,5 +1,67 @@
# 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
+21 -16
View File
File diff suppressed because one or more lines are too long
+74 -15
View File
@@ -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), [Codex](https://developers.openai.com/codex/cli), or [Gemini CLI](https://github.com/google-gemini/gemini-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
@@ -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:
@@ -367,6 +402,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **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**, **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
@@ -394,6 +430,20 @@ Prerequisite: just Docker (or Podman). The agent base image builds itself automa
---
## 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).
---
## Multi-User Mode (opt-in)
Share one Codeman with a small trusted team, each person getting their own login and workspace. **Off by default** — without the flag, nothing changes.
@@ -537,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)
@@ -693,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
@@ -703,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
@@ -825,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.
@@ -882,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>
+76 -14
View File
@@ -34,18 +34,33 @@
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) 或 [Gemini CLI](https://github.com/google-gemini/gemini-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
@@ -144,7 +159,7 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
### 3. 读懂仪表盘
- **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序。
- **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序(标签顺序会跨设备同步)。
- **终端(中央)** —— 真实的 `xterm.js` 终端;完整 TUI 正常渲染。直接输入并按 **Enter** 发送。`Shift+Enter` 插入换行。
- **侧边面板** —— Respawn、Ralph、Orchestrator、Cron、Subagents、Settings(从工具栏切换)。
@@ -157,13 +172,13 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
### 5. 让它自主运行
| 模式 | 用途 | 位置 |
| ---------------- | --------------------------------------------------------------------------------------------------------- | ---------------------- |
| **Respawn** | 长时间无人值守运行 —— 空闲/限额时自动重启 CLI,带自适应时序。预设:`solo-work`、`overnight-autonomous` 等 | Respawn 标签页 |
| **Ralph / Todo** | 一个自驱循环,跟踪 todo 列表并持续工作直到完成。 | Ralph 标签页 |
| **Orchestrator** | 把一个目标变成分阶段计划,并跨多个智能体推动完成。 | 编排器面板 |
| **Cron** | 已保存的、命名的定时任务(`once`/`interval`/`daily`/`weekly`),到期时拉起会话并发送提示。 | ⏰ Cron 按钮 |
| **Auto-resume** | 订阅限额重置后自动继续。 | Respawn 标签页(顶部) |
| 模式 | 用途 | 位置 |
| ---------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| **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. 随时随地访问
@@ -173,7 +188,7 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
### 7. 运维与维护
- **App Settings** —— 模型、effort、主题/皮肤、通知、显示开关、各 CLI 的专属选项。
- **App Settings** —— 模型、effort、权限启动模式、主题/皮肤、通知、显示开关、各 CLI 的专属选项,以及跨设备同步的自定义显示名称和按设备保存的英文/简体中文界面语言。
- **自更新** —— git-clone 安装可在 **Settings → Updates** 中原地更新。
- **部署你自己的改动** —— 见[开发](#开发)。
@@ -320,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:<主机名>`,让你无需点进去就能分辨每个标签对应哪个后端:
@@ -369,6 +392,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
- **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新)
- **多 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`)
- **图像输入** —— 直接把图片粘贴或拖放进会话
@@ -396,6 +420,40 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
---
## 远程 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。
@@ -519,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)。**发现了漏洞?** 私下披露方式与已知限制清单见 [`SECURITY.md`](SECURITY.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)
@@ -675,7 +734,7 @@ Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission
## API
基于 Fastify 的 REST —— **18 个路由模块中约 160 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。所有响应都使用 `ApiResponse<T>` 信封(`{success, data}` / `{success, error, errorCode}`);`/api/v1/*` 是稳定别名。以下是一个有代表性的子集:
基于 Fastify 的 REST —— **20 个路由模块中约 190 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。所有响应都使用 `ApiResponse<T>` 信封(`{success, data}` / `{success, error, errorCode}`);`/api/v1/*` 是稳定别名。以下是一个有代表性的子集:
### 会话(Sessions)
@@ -685,6 +744,9 @@ Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission
| `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)
@@ -807,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)。
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

+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.6.0",
"version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.6.0",
"version": "1.8.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.6.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',
+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 };
})();
+12 -3
View File
@@ -794,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.
@@ -852,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
@@ -1307,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');
@@ -1325,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() {
@@ -1479,6 +1482,10 @@ class CodemanApp {
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, () => {
@@ -1915,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
+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);
+26 -1
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>
@@ -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)">
@@ -2498,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 (
+18 -2
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 */
@@ -474,6 +483,13 @@ html.mobile-init .file-browser-panel {
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;
}
/* Mobile voice input button in toolbar — initially hidden via inline style,
shown by VoiceInput._showButtons() clearing the inline display */
.btn-voice-mobile {
+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
+57 -9
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
@@ -596,6 +637,7 @@ Object.assign(CodemanApp.prototype, {
}
}
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`);
@@ -659,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];
@@ -774,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]) {
@@ -807,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);
}
@@ -884,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)
@@ -940,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)
@@ -992,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);
+34 -6
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)
@@ -1420,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,
@@ -1592,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();
@@ -1620,6 +1629,7 @@ Object.assign(CodemanApp.prototype, {
cjkInputEnabled: _cjk,
extendedKeyboardBar: _ekb,
skin: _skin,
language: _language,
showPlanUsageLimits: _pul,
showAttachmentsButton: _ahb,
showFileViewerButton: _fvb,
@@ -1758,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
@@ -1860,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();
@@ -2180,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();
@@ -2208,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',
]);
+71
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. */
@@ -3110,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;
@@ -4493,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 {
+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 } };
});
}
+16
View File
@@ -650,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(),
+13 -2
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;
@@ -1220,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>`
@@ -1233,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
+7 -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`)
*
+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');
});
});
+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>');
});
});
+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();
}
});
});
});
+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);
+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);
});
});