mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Compare commits
227
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
608ec8a10e | ||
|
|
303afd7fe1 | ||
|
|
0ee268ba82 | ||
|
|
1be98ff8a3 | ||
|
|
b710013add | ||
|
|
4343805672 | ||
|
|
4f8471189e | ||
|
|
fad7cdc1ab | ||
|
|
56db02412b | ||
|
|
689d9fc5e5 | ||
|
|
50547a4e89 | ||
|
|
3c2a5bfef3 | ||
|
|
bc66add7ed | ||
|
|
24b5d8fa63 | ||
|
|
8d9fc4195b | ||
|
|
5abcae16b4 | ||
|
|
66ad681666 | ||
|
|
6c8d4ca72f | ||
|
|
2fdf7dabac | ||
|
|
51cb3a7205 | ||
|
|
b10e354936 | ||
|
|
64559b60d1 | ||
|
|
6351b4143f | ||
|
|
3d6e3f3d6e | ||
|
|
5c20fcf464 | ||
|
|
683544a22e | ||
|
|
5181c9abb0 | ||
|
|
25c67f9415 | ||
|
|
d6917e3b21 | ||
|
|
524a096e14 | ||
|
|
8d9dd70b51 | ||
|
|
ed47a599be | ||
|
|
ccb3afc9ee | ||
|
|
c3b0dc345b | ||
|
|
0ab2416460 | ||
|
|
ac6fe6ef79 | ||
|
|
dafe3de185 | ||
|
|
2a06f7a5a8 | ||
|
|
453605a58f | ||
|
|
4d8857f72a | ||
|
|
f496e35d71 | ||
|
|
91070f5dda | ||
|
|
fdce57ce5a | ||
|
|
a21400614a | ||
|
|
9046b95b7e | ||
|
|
8b3fa5f37c | ||
|
|
4c6f96a2ef | ||
|
|
d1928f300e | ||
|
|
ca731c67b3 | ||
|
|
a3fe0ae728 | ||
|
|
82825cbfb3 | ||
|
|
d1868516f7 | ||
|
|
3e1272a675 | ||
|
|
db6cd838b1 | ||
|
|
66a41f5aa9 | ||
|
|
a36c1f62db | ||
|
|
8b2c857c3f | ||
|
|
583678c950 | ||
|
|
5728b86a68 | ||
|
|
39ef17b6af | ||
|
|
814362b67b | ||
|
|
e9f9497259 | ||
|
|
8768ca4a5a | ||
|
|
df9214ba9a | ||
|
|
5f4c89b990 | ||
|
|
54615e2371 | ||
|
|
828b1664f7 | ||
|
|
5ec71ace5a | ||
|
|
b27a0e9188 | ||
|
|
35a0217ccd | ||
|
|
7a86cf87f7 | ||
|
|
5792c2d62e | ||
|
|
8807b3ff6d | ||
|
|
115ada1e9e | ||
|
|
b2ebdcbf47 | ||
|
|
6dba8b5227 | ||
|
|
897bfdff59 | ||
|
|
fb013e9de0 | ||
|
|
7f24a132d0 | ||
|
|
6f4b2b8a17 | ||
|
|
a531f48e17 | ||
|
|
7d5ea0bd50 | ||
|
|
a9ae141eec | ||
|
|
7b79d4207c | ||
|
|
28744a2761 | ||
|
|
cca07e2b11 | ||
|
|
9806efdf0a | ||
|
|
b00e7cf17c | ||
|
|
efe2d8966a | ||
|
|
f55f035690 | ||
|
|
58fc5f874a | ||
|
|
1301b4b58c | ||
|
|
46493f374e | ||
|
|
b20c00702a | ||
|
|
d55ebcb644 | ||
|
|
e84a3834d0 | ||
|
|
f89bc420ba | ||
|
|
5a4e60dc8e | ||
|
|
460972a50e | ||
|
|
8a971c3935 | ||
|
|
83779cab4d | ||
|
|
a8e7669f5a | ||
|
|
5deb0d4a4c | ||
|
|
84ab4ff07b | ||
|
|
88f47754ad | ||
|
|
6e417d69dc | ||
|
|
f98d29b323 | ||
|
|
360d58ca4f | ||
|
|
6cac517fa6 | ||
|
|
2235f06ea5 | ||
|
|
65b609b6db | ||
|
|
c9f37f2628 | ||
|
|
309959be27 | ||
|
|
13c877f938 | ||
|
|
895edfedb0 | ||
|
|
3f23621f8d | ||
|
|
5cca965aa4 | ||
|
|
b74a904b41 | ||
|
|
05d366e405 | ||
|
|
9204e42812 | ||
|
|
a9749ead6a | ||
|
|
e510ab74ca | ||
|
|
7fa52cdcd6 | ||
|
|
5ea424565d | ||
|
|
0ad673794f | ||
|
|
4d3080aacc | ||
|
|
246f7b532d | ||
|
|
116db81002 | ||
|
|
bb1d16e230 | ||
|
|
978ca57343 | ||
|
|
f8aa93969b | ||
|
|
584910f645 | ||
|
|
b86b132af5 | ||
|
|
4ab89f9a4e | ||
|
|
20cb42d202 | ||
|
|
68fd6e8962 | ||
|
|
be4fecdad5 | ||
|
|
c7967d4b55 | ||
|
|
8be83cd585 | ||
|
|
09fd1e495f | ||
|
|
7efc6cd5a8 | ||
|
|
286cf0768d | ||
|
|
3c0e6286f6 | ||
|
|
8a133d083b | ||
|
|
a0e26db1dc | ||
|
|
596899e19b | ||
|
|
e8f5ac94f3 | ||
|
|
03192d9980 | ||
|
|
3d4444ad78 | ||
|
|
c45e456b0e | ||
|
|
ad25e234f4 | ||
|
|
48fd2da6ce | ||
|
|
e29721046c | ||
|
|
3a03792009 | ||
|
|
e83ff72b61 | ||
|
|
268a0bbdbd | ||
|
|
26e78daf58 | ||
|
|
568d93efb0 | ||
|
|
3bf991d730 | ||
|
|
7fb58648ba | ||
|
|
9535edc367 | ||
|
|
443b85c18e | ||
|
|
66eaaf0da3 | ||
|
|
ce4c5dd584 | ||
|
|
e77af21107 | ||
|
|
a842f2db4d | ||
|
|
bf36eb0db4 | ||
|
|
4dfdbcd100 | ||
|
|
ad71a92f29 | ||
|
|
1fa88cd187 | ||
|
|
613eb25302 | ||
|
|
8c0c94540c | ||
|
|
d9c2c6420d | ||
|
|
6082bceee6 | ||
|
|
40e26c5422 | ||
|
|
9feaa0d6e5 | ||
|
|
2d2f4e592b | ||
|
|
6ae86b53f6 | ||
|
|
abb6447f66 | ||
|
|
cc7c0e5dcb | ||
|
|
368fc20fc2 | ||
|
|
9cc310e843 | ||
|
|
aa991ece8f | ||
|
|
3b4106c349 | ||
|
|
c2867be77f | ||
|
|
a1b66f3510 | ||
|
|
98ba1fd49c | ||
|
|
9df310c30a | ||
|
|
50b8f1d9a0 | ||
|
|
11bacf67a0 | ||
|
|
509595b837 | ||
|
|
c95e94e4cb | ||
|
|
19139837e4 | ||
|
|
9afaccc85d | ||
|
|
95df96e06a | ||
|
|
1255e28f6f | ||
|
|
9d12fc7f94 | ||
|
|
5d406c9705 | ||
|
|
a8782b364f | ||
|
|
d8da1bd3ff | ||
|
|
06871eb7e3 | ||
|
|
5d59c1764d | ||
|
|
cfcd9d288b | ||
|
|
9c22114b5a | ||
|
|
98b2124d7e | ||
|
|
bdaec320f5 | ||
|
|
dfe20a3742 | ||
|
|
de5216b83f | ||
|
|
57eefd7aa5 | ||
|
|
2c81bbc08b | ||
|
|
b374121c18 | ||
|
|
b1c4330680 | ||
|
|
47359e4002 | ||
|
|
a8e7d60db4 | ||
|
|
8dc70a5f1d | ||
|
|
4d129086d1 | ||
|
|
566c65c3c9 | ||
|
|
cd7d8c7329 | ||
|
|
c55af9ec39 | ||
|
|
1a54217bfb | ||
|
|
70742d400a | ||
|
|
d5809d1808 | ||
|
|
a0ac10a07c | ||
|
|
f7814ad364 | ||
|
|
3172befd5d | ||
|
|
29ffc62536 | ||
|
|
4cb3a4aac8 |
+11
@@ -2,6 +2,9 @@
|
||||
.agents/
|
||||
skills-lock.json
|
||||
|
||||
# Written by install.sh into end-user clones when setup finishes
|
||||
.install-complete
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
@@ -52,8 +55,16 @@ Thumbs.db
|
||||
# Generated output
|
||||
out/
|
||||
screenshots-echo-diag/
|
||||
screenshots-readme/
|
||||
screenshots-readme-real/
|
||||
screenshots-real/
|
||||
scripts/remotion/out/
|
||||
|
||||
# Local UI/README capture scratch (screenshot runs, design mockups). Not build
|
||||
# output, but never meant for git — an unqualified `git add -A` during a COM has
|
||||
# swept dirs like these into a release before.
|
||||
design-explorations/
|
||||
|
||||
# Artifacts that should not be tracked
|
||||
test-results/
|
||||
tmp/
|
||||
|
||||
+409
@@ -1,5 +1,414 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Mobile and UI polish plus docs refresh.
|
||||
- Mobile: the header brand collapses to a single "C" home button on phones (<430px), freeing header space for session tabs while keeping the same tap target. The compact letter lives in its own span so i18n custom branding keeps rewriting only the full wordmark.
|
||||
- UI fix: the absolutely-centered toolbar voice button no longer overlaps the case picker's chevron and "+" button. Below ~1500px (or with long case names widening the left toolbar group) it now falls back into normal flex flow where overlap is impossible; wide viewports keep the centered layout.
|
||||
- Docs: README gains a hero pitch block with deep links, npm version + GitHub stars badges, and a star CTA; CLAUDE.md core-files table synced (Infra docker modules, app.js line count); blog article images added under docs/images/blog/.
|
||||
|
||||
## 1.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Community release (thanks @shenlvkang-collab for all four PRs) plus documentation fixes.
|
||||
- fix(mobile): per-device settings now key off a stable handheld classification (`MobileDetection.isHandheldDevice()`: touch plus UA form-factor tokens, with User-Agent Client Hints fallback) instead of the instantaneous viewport width, so an Android foldable that unfolds past the desktop breakpoint keeps `codeman-app-settings-mobile` and opt-ins such as the Response Viewer and Extended Keyboard Bar. Responsive layout stays width-driven. Adds an OPPO Find N5 (unfolded) device profile and a fold/unfold/reload Playwright regression test (mobile suite now 136 devices). (#162)
|
||||
- fix(paths): `SAFE_PATH_PATTERN` now accepts Unicode letters and numbers (`\p{L}\p{N}` with the `u` flag), so working directories like `/mnt/d/AI/中文项目` validate in Create Session, Quick Run, and Scheduled Run. All shell-metacharacter, traversal, and absolute-path protections are unchanged. (#163)
|
||||
- fix(ui): newly created run sessions render their tab immediately instead of waiting for the `session:created` SSE event (idempotent upsert from the POST response, with a `GET /api/sessions/:id` fallback for quick-start modes), and the Run button holds an in-flight lock (min 500 ms) so a double click cannot create duplicate sessions. (#164)
|
||||
- feat(ui): the synced custom display name and per-device English/Simplified Chinese UI language are described in their own entry (#165); on top of that PR, `renderIndexHtml` no longer recomputes `windowTitle` on solo-session renders, so a detached window cannot reset the push-notification `hostTitle` prefix to the default name.
|
||||
- docs: corrected the `sse-events.ts` fileoverview breakdown (148 event constants, was stale at 120; per-category counts refreshed, including Cron, Docker, Remote auto-reconnect, and Multi-user) and the CLAUDE.md SSE registry count; READMEs synced with the 1.6.2 installer behavior.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8d9fc41: Add a synced custom display name and a per-device English/Simplified Chinese browser UI language picker under App Settings → Display.
|
||||
|
||||
## 1.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Installer (install.sh) reliability and safety overhaul, prompted by a review of the Linux flow:
|
||||
- Install-completion marker (`.install-complete`): a bare re-run only takes the quiet update path when a previous install actually finished. Previously, a first install that failed during npm install/build (or was interrupted) left `.git` behind, so the retry silently became an "update" and the user never got the launch menu, the `codeman`/`tmux-chooser` symlinks, the PATH entry, or the `sc` alias. The marker is refreshed by updates and cleared by uninstall when the app dir is kept; added to .gitignore for end-user clones.
|
||||
- `update` no longer runs an unconditional `git reset --hard` over local changes: interactive runs are asked to stash (declining keeps everything and skips the update), headless runs auto-stash with a dated message (same policy as scripts/self-update.sh).
|
||||
- Service setup is verified instead of asserted: after starting codeman-web, the installer polls `systemctl --user is-active` (up to 6s) and only then prints "Codeman is running now!"; failures print an honest warning plus status/journalctl hints. Uses `restart` instead of `start` so re-running the installer over an already-running service actually loads the new build. A missing user D-Bus session (e.g. bare `ssh host 'curl | bash'`) is detected up front with copy-paste recovery commands instead of dying mid-setup via `set -e`. macOS gets the equivalent `launchctl list` verification, and the update path verifies its service restart too. The Cloudflare tunnel-service offer is skipped when service setup failed.
|
||||
- Headless consent guard: with no interactive terminal AND no explicit `CODEMAN_NONINTERACTIVE=1`, the installer now refuses (with instructions) to run sudo package installs (git/node/tmux) or third-party `curl | bash` AI CLI installers, instead of silently taking the default-yes prompts. Explicit `CODEMAN_NONINTERACTIVE=1` keeps the previous full-auto behavior for CI/automation.
|
||||
- AI CLI gate now recognizes Codex and Gemini (search paths mirrored from the CLI resolvers), so a box with only Codex or Gemini installed is no longer forced to install Claude Code/OpenCode. The install menu gains a "Skip" option (with npm install hints for Codex/Gemini), and the final reminder lists all four CLIs.
|
||||
|
||||
Docs: CLAUDE.md documents `src/remote-reconnect.ts` (pure COD-108 auto-reconnect backoff/eligibility logic) in the Infra table and the remote-sessions pattern.
|
||||
|
||||
## 1.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Admin Panel for multi-user mode.** Admins in multi-user mode now get a prominent Admin Panel button at the top of the page (header, admin-only; the template ships it hidden and `admin-ui.js` reveals it after identity boot; hidden on phones per the mobile header policy, where user management stays reachable via App Settings > Users). It opens a full Admin Panel modal: a users table with role, enabled/disabled status, bypass-permissions grant, live sessions, active logins, case count, and last login; per-user actions for Promote/Demote, Enable/Disable, Grant/Revoke bypass, Reset password (copyable one-time password), Force logout, and Delete (with an optional "also delete their files" step); and a proper add-user form (role, optional password, bypass checkbox) replacing the old prompt() flow. Each user's cases open in a drawer listing their case folders (modified date, live-session badge) with per-folder delete. Two new admin endpoints back this: `GET /api/admin/users/:username/cases` and `DELETE /api/admin/users/:username/cases/:caseName`, guarded like `deleteUserSpace` (symlinks refused, realpath confined to the user's space, folders in use by a live session refused with 409, audit-logged). The panel and the App Settings Users tab live-refresh on the SSE `admin:usersChanged` event (now wired in app.js). New coverage in `test/admin-routes.test.ts` (list/delete, traversal + symlink refusal, non-admin 403) and `test/admin-ui.test.ts` (button reveal gating, panel render, case drawer); verified end to end against a live multi-user instance with curl and Playwright.
|
||||
|
||||
**Also in this release:** README/docs synced with 1.6.0 (remote SSH cases, session manager, permissions) and fixed installer prompts when run via `curl | bash`.
|
||||
|
||||
**Recap of the recent feature line, for readers catching up:**
|
||||
- **Multi-user mode (shipped 1.5.0, opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`).** Named users with scrypt-hashed passwords, per-user case spaces under `~/codeman-users/<name>/cases`, and full ownership scoping of sessions, cases, cron jobs, scheduled runs, search, file previews, and SSE/WS streams. Non-admin users default to Claude's classifier-guarded `--permission-mode auto`; shell mode, cron `launchCommand`, and skip-permissions bypass switches require the per-user `canBypassPermissions` grant (now toggleable from the Admin Panel). Admin API with one-time passwords, last-admin invariants, and an append-only audit log; self-service `/api/me` password change; `codeman users add|passwd|list|rm` CLI. Off by default is byte-identical to single-user. Note: multi-user separates workspaces for a trusted team; it is not a security boundary (all sessions share the host OS account), so pair it with Docker cases for real isolation.
|
||||
- **Docker cases (shipped 1.4.0/1.4.1).** A case can run inside an isolated per-case container (any of the five CLI backends), with one-click "Run in Docker" quick-create, durable in-container tmux that survives Codeman restarts and resumes conversations after container stops, hardened container creation (cap-drop ALL, no-new-privileges, non-root, memory/pid limits, never privileged, never the docker socket), commit-safe seeded credentials, config-drift detection, GPU passthrough, and portable export/import bundles to move a whole case between machines.
|
||||
- **1.6.0 highlights.** Remote SSH cases with durable remote tmux (survives SSH drops, auto-reconnect, shared multi-client attach, discover + attach with detach-not-kill); the Cmd+K session palette and unified Session Manager with pinning, cross-device tab order, and first/last prompt search; full-scrollback replay; and the multi-user permission downgrade now threading through to remote launch/attach.
|
||||
|
||||
## 1.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Remote tmux durability, Session Manager polish, and an opt-in Cron button.
|
||||
|
||||
**Remote sessions: durability, discovery, and auto-reconnect** (PR #156 by @aakhter, COD-104 to COD-109)
|
||||
- Durable remote launches survive an SSH drop: the agent runs inside `tmux -L codeman-remote new-session -A` on the remote host, and reconnecting lands back in the same session.
|
||||
- Discover + attach: a "Discover existing sessions" action per remote host lists `codeman-*` tmux sessions on the host's canonical socket (started by the remote's own Codeman or another instance) and attaches to one. Attached (non-owned) sessions detach on tab close, never kill; a structural early-return in `killSession()` guarantees no remote `kill-session` can ever be issued for a session Codeman doesn't own (COD-105).
|
||||
- Shared/collaborative sessions: per-session `window-size latest` so concurrent clients at different viewports don't clamp each other, plus a "shared - N clients" badge in discovery results (COD-106).
|
||||
- Auto-reconnect watcher: a bounded-backoff (5s to 5m, ~6 attempts) watcher detects a dead remote pane and reattaches the still-running remote tmux session; intentional kills/detaches are guarded and never revived. Kill-switch setting `remoteAutoReconnect` (default on). SSE `remote:sessionDropped`/`sessionReconnected`/`reconnectExhausted`, with a manual Reconnect toast after exhaustion (COD-108).
|
||||
- Owned durable sessions propagate `kill-session` to the remote on close (COD-109); the remote tmux prereq probe is skipped under the test runner (COD-104).
|
||||
- All ssh command lines continue to flow through the single shell-safe `buildSshConnectionArgs()` (COD-107). New design doc: `docs/remote-sessions.md`.
|
||||
- Maintainer additions: the discovery endpoint is admin-gated in multi-user mode, and the remote launch/attach chooser threads the multi-user permission downgrade (`claudeMode`/`allowedTools`) through to the remote agent.
|
||||
|
||||
**Session Manager: pinning, cross-device ordering, name/prompt retention** (PR #157 by @aakhter, COD-131/139/140/142/143/145)
|
||||
- Session pinning: pin a session to the top of the Session Manager list (`POST /api/sessions/:id/pin`, `session:pinned` SSE, amber highlight + pin glyph). Pinned group orders most-recently-pinned first (COD-139).
|
||||
- Pinned sessions survive kill: killing a pinned session demotes its record to a lightweight stopped entry instead of removing it, so it stays visible and resumable; cleanup skips pinned records (COD-142). The pin route also works on these persisted-only records, so a pinned-then-killed session can always be unpinned.
|
||||
- Cross-device tab order: tab order syncs via server state (`PUT /api/session-order`, `session:orderChanged` SSE, persisted in `state.json`); the pushing device wins and server-only ids fall to the end, never dropped (COD-131).
|
||||
- Resuming from the Session Manager keeps the session's original name instead of always synthesizing a fresh `w<N>-<dir>` one (COD-143).
|
||||
- firstPrompt backfill for sessions whose Codeman id is not the transcript UUID (claudeSessionId join, then newest transcript in the same workingDir), and the most recent prompt is shown alongside the first and included in search (COD-140/145).
|
||||
|
||||
**Cron button now opt-in** (hidden by default)
|
||||
- The Cron footer-toolbar button follows the same opt-in pattern as the Session Manager / Away Digest / File Viewer buttons: hidden by default, enable per device under App Settings -> Display -> Header Displays. Cron jobs themselves are unchanged.
|
||||
|
||||
Also: `docs/remote-sessions.md` synced with the shipped `-L codeman-remote` / `codeman-ssh-<id8>` naming.
|
||||
|
||||
## 1.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Docker session-mode deep-review fixes — the work intended for the skipped **1.4.2**, now merged onto the 1.5.x line — plus a recap of the multi-user mode shipped in 1.5.0.
|
||||
|
||||
**Docker resume actually works now.** `DockerCase.lastClaudeSessionId` was read at quick-start but never written, so the documented resume-after-container-stop never fired. Claude-mode docker panes now pin a deterministic conversation id (`claudeDockerPaneCommand()`): a fresh launch runs `claude --session-id <id> || claude --resume <id>` (a duplicate `--session-id` exits 1 "already in use", so the fallback resumes after a container stop/reboot — verified CLI behavior), an explicit resume runs `--resume <rid> || --session-id <sid>` so a stale id never dead-panes. The id is persisted at launch and again on hook / last-response conversation-id adoption. Verified end-to-end across a `docker stop` + relaunch and a full container recreate.
|
||||
|
||||
**Config-drift detection + recreate (was documented but entirely missing).** The `codeman.confighash` label was stamped but never read, so docker-host config edits silently never applied. Quick-start now compares via `checkDockerConfigDrift()` and refuses a drifted launch with `CONFLICT`; the UI confirms and calls the new `POST /api/docker-cases/:name/recreate` (refused while the case has live sessions), then relaunches with the new config. New SSE event `docker:containerRecreated`.
|
||||
|
||||
**Model picker now applies to docker sessions.** `modelOverride` was absent from `QuickStartSchema`, so the App Settings Claude Model choice was silently inert for docker runs. It is now accepted and applied via `updateCaseModel` for local and docker quick-starts (still rejected for remote, where the settings file would land on the wrong machine).
|
||||
|
||||
**Import hardening.** `importDockerBundle` validates the untrusted cross-machine manifest before trusting any field (`validateImportManifest`: engine/image/containerWorkdir/network/caseName/schemaVersion — a hostile `engine` could previously select the probe binary); the outer bundle tar gets the same member-traversal guard as the inner workspace tar; the quarantine image tag derives from the schema-validated case name.
|
||||
|
||||
**Remote-daemon correctness.** All docker probes and the base-image auto-build now honor a host's `context`/`daemonHost` (`dockerEngineArgv`) instead of always probing the local daemon.
|
||||
|
||||
**Smaller fixes:** commas are rejected in docker workspace/workdir/destination paths (a comma corrupts the `--mount type=bind,src=…` CSV spec, which shell escaping cannot protect); a dead `this.escapeHtml` reference in the exports refresh is fixed; `docker:importComplete` / `docker:containerRecreated` get frontend SSE listeners so other open tabs refresh; the File Viewer header button is hidden on phone headers like its siblings.
|
||||
|
||||
**Docs.** CLAUDE.md + READMEs synced with the current feature set, including a full zh-CN README re-translation.
|
||||
|
||||
**Multi-user mode (recap — shipped in 1.5.0).** Opt-in named users (`--multiuser` / `CODEMAN_MULTIUSER=1`, off by default) with per-user case spaces and full ownership scoping of sessions, cases, cron jobs, scheduled runs, search, file previews, and real-time SSE/WS streams. Non-admin users default to Claude's classifier-guarded `--permission-mode auto`; raw shell mode, cron `launchCommand`, skip-permissions, and the Codex/Gemini bypass switches require an explicit per-user `canBypassPermissions` grant. Machine-level resources are admin-only. Admin API (`/api/admin/users*`) with one-time passwords, last-admin invariants, and an append-only audit log; self-service `/api/me` + password change; and a `codeman users add|passwd|list|rm` CLI. Off by default is byte-identical to single-user. Note: multi-user separates workspaces for a trusted team; it is not a security boundary between mutually-distrusting users (all sessions share the host OS account) — pair with Docker cases for real isolation.
|
||||
|
||||
## 1.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 0ab2416: Opt-in multi-user mode (`--multiuser` / `CODEMAN_MULTIUSER=1`, off by default).
|
||||
|
||||
Named users with individually scrypt-hashed passwords in `~/.codeman/users.json`, per-user case spaces under `~/codeman-users/<name>/cases`, and ownership scoping of sessions (create/list/delete/mutate, incl. bulk delete), cases, cron jobs + run history, scheduled runs, search, file previews, session history, away digest, subagent/workflow monitors, and real-time SSE/WS streams (including the debounced session/task update path, clipboard, and push notifications). A non-admin's `workingDir` is realpath-confined to their own space at every spawn/link path (session create, quick-start, cron create/fire, scheduled runs, case link/docker-link, docker import). Non-admin users default to Claude's classifier-guarded `--permission-mode auto`; raw shell mode, cron `launchCommand`, skip-permissions, and the Codex/Gemini bypass switches require an explicit per-user `canBypassPermissions` grant (enforced at every spawn site incl. one-shots, plan generation, scheduled runs, and remote launches). Machine-level resources (remote/Docker hosts + host reads, mux sessions, orchestrator, tunnel, self-update, settings) are admin-only. Admin API (`/api/admin/users*`) with one-time passwords, last-admin invariants (validated before any teardown), and an append-only audit log; self-service `/api/me` + password change; a frontend admin Users tab + change-password modal; and `codeman users add|passwd|list|rm` CLI. Also adds a global `auto` Claude startup permission mode. When off, behavior is byte-identical to single-user.
|
||||
|
||||
Auth hardening: the login throttle verifies the password before consulting the per-account failure bucket (a correct password can never be locked out); the `mustChangePassword` lockbox covers the WebSocket terminal; the cookie fast-path re-validates identity against the store each request (so a CLI/admin delete/disable/demote takes effect promptly); a role/grant change revokes the target's sessions. (Known limitation: a bare CLI `codeman users passwd` reset — no delete — does not by itself revoke an already-active cookie until it expires; use `codeman users rm`, the admin API, or a restart to force-revoke.) Data-integrity hardening: the store distinguishes a missing users file from a corrupt/unreadable one (so a transient read error can't overwrite all accounts) and writes via a unique per-process temp file; the earlier fire-and-forget `touchLastLogin` corruption race is serialized.
|
||||
|
||||
Note: multi-user mode separates workspaces for a trusted team; it is not a security boundary between users (all sessions share the host OS account). Pair with Docker cases for real isolation.
|
||||
|
||||
## 1.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Docker session mode** hardening + fixes, plus a File Viewer header button.
|
||||
|
||||
**What Docker session mode is** (recap): a case can run inside an isolated, hardened Docker container instead of on the host, and any of the CLI backends (Claude, Codex, Gemini, OpenCode, or a plain shell) runs inside it. It is a location overlay on cases — not a new session mode — and the container analog of remote-SSH cases: a local tmux pane `docker exec`s into a durable in-container tmux, with exactly one long-lived container per case that multiple sessions share. The workspace, credentials, and conversation transcripts are bind-mounted so the agent is authenticated and resumable; containers are hardened by default (`--cap-drop ALL`, `--security-opt no-new-privileges`, non-root, pids/memory caps, `--init`, never `--privileged` or the docker socket) and export-safe. Start one with the one-click "Run in Docker" checkbox on Create Case, or the Docker tab for full control.
|
||||
|
||||
This release fixes the rough edges found running it for real:
|
||||
|
||||
Docker cases:
|
||||
- **Seamless Claude auth in containers**: `~/.claude.json` is no longer bind-mounted as a single file (a mount point that broke Claude's atomic-rename config writes — forcing re-auth and, via failed in-place writes, corrupting the host `~/.claude.json`). It is now seeded as a writable, onboarding-complete copy, so a docker session boots straight to the prompt (no theme picker, login, or folder-trust prompt).
|
||||
- **Claude-state isolation**: containers no longer bind-mount the whole `~/.claude` directory (which wrote backups/tasks/teams/settings back into the host). Only `~/.claude/projects` transcripts are shared (host watchers + `--resume`); credentials, settings, and stats-cache are seeded as writable copies; everything else stays container-local.
|
||||
- **Codex/Gemini/gcloud/opencode isolation**: same treatment — codex shares `sessions/` + `history.jsonl` (response-viewer + resume) and seeds `auth.json`/`config.toml`; gemini/gcloud/opencode are whole seed-copies. Containers never write their credential state back into the host dirs.
|
||||
- **Base image auto-builds on first use**: a missing `codeman/agent:base` no longer blocks case creation or launch; it builds locally on first use (concurrency-safe, with SSE progress toasts).
|
||||
- **UTF-8 locale**: containers set `LANG`/`LC_ALL=C.UTF-8` so tmux renders Claude's box-drawing correctly (fixes `qqqq` line artifacts).
|
||||
- **Create Case UI**: larger, collapsed-by-default "Run in Docker" settings with a shorter hint; dockerized cases show a short `(docker)` tag (or the custom host id) in the case menus.
|
||||
- **Tab naming**: docker/remote (and codex/gemini/opencode) sessions now follow the `w<n>-<case>` convention instead of `codeman-<id>`.
|
||||
|
||||
Other:
|
||||
- **File Viewer header button** (opt-in via App Settings, Header Displays): toggle the file browser panel from the header.
|
||||
- Fixed a timezone-boundary flaky test in the away-digest route suite.
|
||||
|
||||
## 1.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add **Docker session mode**: a case can now run inside an isolated Docker container instead of on the host, with configurable network / resource / credential settings, multiple sessions sharing one per-case container, and one-click export to move a container (toolchain + workspace) to another machine.
|
||||
- Docker is a location overlay on cases (not a new session mode), mirroring the remote-SSH feature: a local tmux pane runs `docker exec -it` into a durable in-container tmux server. The container is scoped to the case (`codeman-case-<name>`), so multiple sessions share it; killing one session never stops the shared container.
|
||||
- New `/api/docker-hosts` CRUD, `/api/cases/docker-link`, and a `/api/quick-start` docker branch. Create Case gains a **Docker** tab. Base image is built locally via `scripts/build-agent-image.mjs` (node + claude/codex/gemini/opencode + tmux, secret-free, arbitrary-uid-writable HOME).
|
||||
- Hardened by default: `--cap-drop ALL`, `--security-opt no-new-privileges`, non-root, `--pids-limit`, `--memory`==`--memory-swap`, `--init`; never `--privileged` or the docker socket. Convenient credential default bind-mounts host `~/.claude` etc. read-write (never captured by `docker commit`); a sealed profile is opt-in.
|
||||
- Two-layer durability: reconnect after a Codeman restart reattaches the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript via `--resume`.
|
||||
- Export / import: full-image (`docker commit` + `save` + workspace tar + manifest) or workspace-only, to one portable `.codeman-container.tgz`; import validates checksums, guards path traversal, and re-tags the loaded image into a quarantined namespace. Instance-scoped boot reaper cleans orphaned containers. New `docker:*` SSE events. Docs in `docs/docker-cases.md`.
|
||||
- Robustness: sets `CLAUDE_CODE_TMPDIR` in the container so claude launches regardless of workspace path. In-container hooks require the server to be reachable from the container (documented); on a loopback-only bind, idle detection falls back to output-based.
|
||||
|
||||
Also wire session, away-digest, and cron header-button visibility toggles in App Settings.
|
||||
|
||||
## 1.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a842f2d: fix(auth): slide the session cookie so active users aren't logged out
|
||||
|
||||
Re-issue the `codeman_session` cookie on every authenticated request so the
|
||||
browser cookie lifetime tracks the server-side sliding TTL (the session store
|
||||
already uses `refreshOnGet`). Previously the cookie was only set on the Basic
|
||||
Auth path with a fixed 24h lifetime from login, so the browser dropped it
|
||||
mid-use; the next request arrived cookie-less, fell through to Basic Auth and
|
||||
popped the native username/password dialog, perceived as a random logout while
|
||||
actively working.
|
||||
|
||||
## 1.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix "Run Shell" not switching the terminal to the newly created shell session. Clicking Run Shell created the shell tab but left the previous session's terminal on screen, so you had to manually click the new tab to actually enter it. Root cause: `runShell()` pre-set `activeSessionId` to the new session's id right before calling `selectSession()`, and `selectSession()` early-returns when the requested id already matches the active one, so it skipped the terminal buffer load, tab activation, and focus. Removed the premature assignment in both the local and remote-SSH shell branches so `selectSession()` runs to completion (matching `runClaude`/`runCodex`/`runGemini`/`runOpenCode`, which already avoid this). Verified end-to-end in a real browser with a negative/positive control.
|
||||
|
||||
## 1.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix terminal scroll-back in Claude sessions, especially on macOS trackpads (#154).
|
||||
- **Deterministic CLI version detection.** `cliVersion` was often `undefined` because it was scraped from the `Claude Code vX.Y.Z` startup banner, which newer Claude Code builds (2.1.187+) don't reliably print and resumed sessions never show. With the version unknown, wheel-forwarding to Claude's transcript was silently disabled — and since repaint-mode Claude keeps no local terminal scrollback, scrolling up reached nothing. A new `getClaudeCliVersion()` probe (`claude --version`, cached, local-only) seeds the version at session start so forwarding engages. Restored sessions pick it up on restart.
|
||||
- **Trackpad Shift+scroll.** The wheel handler now reads the dominant axis, so a macOS trackpad's Shift+two-finger scroll — which the browser reports as horizontal `deltaX` — reaches xterm's local scrollback instead of collapsing to a fixed one line per tick.
|
||||
- **Opt-out setting.** New per-device App Settings → Input → "Wheel Scrolls Local History" (default off) pins the plain wheel to local scrollback (the pre-#144 behavior) for shell and other non-repaint sessions.
|
||||
- **No more "queued bytes" flicker on scroll.** Wheel-scroll reports now use a fire-and-forget send path (seq-less input frame) instead of the durable exactly-once input queue, so they no longer appear in the pending-bytes connection indicator or churn localStorage. Keystrokes, taps, and clicks still use the durable queue.
|
||||
|
||||
## 1.3.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Make the Cron Jobs modal fully skin-aware and consistent with App Settings' design language.
|
||||
- **Fix white dropdowns:** `.form-select` had no `appearance` reset and the app set no `color-scheme`, so native `<select>` fields rendered as white OS widgets that ignored the active skin. Selects now use `appearance: none` with an opaque `var(--bg-input)` fill, a `var(--border)` outline, and a custom chevron, so they follow the skin (daylight `#202833`, OG `#1a1a1f`). This is on the shared `.form-select` class, so App Settings, Cron, and every other select match and are fixed together.
|
||||
- Set `color-scheme: dark` on `:root` so native select option popups, date/time pickers, and scrollbars render dark across all three (dark) skins instead of flashing white.
|
||||
- Themed the Cron date/time inputs with `var(--bg-input)` / `var(--border)` instead of hardcoded values.
|
||||
- Fixed the Cron toolbar: "+ New Job" / "Refresh" and the footer Save / Cancel now use the full `btn-toolbar` size (matching the App Settings footer), with a wider gap and a divider under the toolbar for better spacing.
|
||||
|
||||
## 1.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Redesign the Cron Jobs modal to match the App Settings styling, and fix a bug that left its create form fully expanded.
|
||||
- **Fix:** the cron modal's "New Cron Job" form and all of its conditional rows (Launch Command, Prompt File Path, and the once/interval/daily/weekly schedule fields) never actually collapsed — there is no global `.hidden` utility in the stylesheet and the cron modal never scoped its own, so the form opened fully expanded with every field visible at once. Added a scoped `#cronModal .hidden` rule; the form now stays collapsed until "+ New Job" and only shows the fields relevant to the selected agent type, prompt source, and schedule type.
|
||||
- Sectioned the create/edit form into Basics / Prompt / Schedule / Options with the same section-header dividers used in App Settings, and increased row spacing.
|
||||
- Styled the agent-type / prompt-source / input-mode / schedule-type dropdowns and the datetime-local / time inputs to share the bordered, rounded, focus-ringed field look.
|
||||
- Converted the "Auto-close previous run's session" and "Enabled" toggles into App-Settings-style cards (label + description on the left, compact switch on the right).
|
||||
- Replaced the raw weekday checkboxes with pill toggles that fill with the accent color when selected.
|
||||
- Restyled the job list rows as hover-highlighted cards with pill badges (agent type, schedule, disabled) and right-aligned actions, and gave the modal a divider-topped Cancel / Save footer.
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Community release: 16 contributor PRs reviewed (multi-agent adversarial review), fixed, and merged. Thanks to @aakhter, @TeigenZhang, @chatgptkrylor, @kvncrw, and @pirronewantlux529-coder!
|
||||
|
||||
**New features**
|
||||
- **Cron jobs** (#141, @chatgptkrylor): recurring scheduled jobs (once/interval/daily/weekly) that spawn a session and send a prompt when due — CRUD + run history (`/api/cron/*`), ⏰ modal UI, per-job concurrency policy and `autoClosePreviousSession` lifecycle, pure unit-tested next-run math. Distinct from the legacy `ScheduledRun`.
|
||||
- **Remote host SSH cases** (#145, @aakhter): link cases on remote hosts (`remote-hosts.json`/`remote-cases.json`), launch sessions over ssh into a durable remote tmux (dedicated `-L codeman-remote` socket; adoption-safe naming), per-host command overrides, injection-guarded schemas, remote tmux probe + ConnectTimeout, remote kill on delete, recovery-safe persistence.
|
||||
- **Command-K session palette + searchable case picker + shortcut registry** (#146, @aakhter): Ctrl/Cmd/Alt+K fuzzy session palette with "Browse all sessions" Session Manager; searchable quick-start case picker (remote-aware labels); rebindable shortcut registry with App Settings → Shortcuts tab and Ctrl+? overlay.
|
||||
- **Unified session list** (#139, @aakhter): `GET /api/sessions/unified` merges live/persisted/lifecycle/transcript sessions into one deduped list (resumed sessions fold via claudeSessionId alias map).
|
||||
- **Unified Session Manager UX** (#153, @aakhter): unified welcome list with mode/LIVE badges + per-row kebab menu, `projectKey` plumbing for "View all in this folder", SSE-driven live list refresh, desktop Session Manager header button.
|
||||
- **Full-scrollback replay** (#148, @aakhter): page reload replays the entire tmux scrollback (`?full=1`, bounded capture with proper maxBuffer) with CRLF normalization for shell panes.
|
||||
- **WebSocket resilience** (#149, @aakhter): reconnect with preserved exponential backoff, per-tab connection identity (multi-tab safe), ACK re-drive, and a truthful connection chip (WS/HTTP/reconnecting states).
|
||||
- **PTY-exit circuit breaker + TMUX scrub** (#147, @aakhter): rapid PTY crash-loops trip a breaker (SSE + critical push notification; explicit-restart-only reset); inherited TMUX vars are scrubbed so Codeman-in-tmux doesn't nest.
|
||||
- **Codex generated-artifact attachments** (#150, @aakhter): codex sessions surface `Saved to: file://…` outputs as attachment cards (realpath-anchored trust, codex-mode-gated, jpg/gif/webp thumbnails).
|
||||
- **Codex response viewer** (#152, @pirronewantlux529-coder): the eye button now works for Codex sessions via 4-layer rollout resolution (history pin → originator → resume-UUID → cwd) with dedup + injected-context filtering.
|
||||
- **HEIC paste conversion** (#151, @aakhter): iPhone HEIC pastes convert to JPEG server-side in a worker thread (concurrency-capped, 64MP decompression-bomb guard, magic-byte detection for mislabeled Android HEIFs). Deps: heic-decode + jpeg-js.
|
||||
- **WebGL renderer toggle** (#140, @kvncrw): per-device setting to switch xterm between WebGL and DOM renderers, cooperating with the GPU-stall auto-fallback marker.
|
||||
- **Raised terminal history defaults** (#138, @aakhter): tmux history-limit 50k→100k lines, PTY buffer 2MB/1.5MB→32MB/24MB (env-clamped so trim always stays below max).
|
||||
|
||||
**Mobile & input fixes**
|
||||
- CJK input loss fixes: IME state machine, focus routing, Android InputConnection recovery — with content-free diagnostics (#143, @TeigenZhang).
|
||||
- Tap/click/wheel restored when the server strips mouse DECSETs — version-gated wheel passthrough (claude ≥ 2.1.187), link-click double-fire fix, Shift+wheel documented (#144, @TeigenZhang).
|
||||
- Response-viewer readability on phones + iOS dvh viewport fix (#142, @TeigenZhang).
|
||||
|
||||
**Docs**: CLAUDE.md accuracy audit (18 verified fixes: security hook-bypass description, env-prefix allowlist, state-file inventory, watcher/function names, counts) + documentation for all new subsystems. README gains a user walkthrough (#141).
|
||||
|
||||
All PRs went through adversarial multi-agent review; ~60 verified findings (including 12 blockers) were fixed on the contributors' branches before merge. Full test suite green: 3,400+ tests.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- bf36eb0: Add a **WebGL Renderer** toggle to Settings → Appearance (desktop). WebGL stays on by default; turning it off forces the DOM renderer for users who hit GPU glitches, without needing the `?nowebgl` URL param. Turning it back on (or `?webgl=force`) clears any stale auto-fallback marker. The existing mobile skip and long-task auto-fallback safety net are unchanged. The skip decision is factored into a pure, unit-tested `shouldSkipWebGL()` helper.
|
||||
|
||||
## 1.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Centralize terminal history/scrollback/buffer retention limits into config (PR #137, COD-80).
|
||||
|
||||
New `src/config/terminal-history.ts` is now the single source of truth for the terminal scrollback lines, tmux `history-limit`, and server PTY buffer byte caps that were previously scattered as hardcoded literals across `buffer-limits.ts`, `tmux-manager.ts`, and `session.ts`. Each value is overridable (env var or the settings object) and bounds-clamped via a pure `resolveTerminalHistoryConfig()`.
|
||||
|
||||
This change is behavior-neutral: the defaults intentionally match the prior hardcoded values (tmux history-limit 50,000; terminal scrollback 50,000; PTY buffer max 2 MB; trim 1.5 MB) and the existing `CODEMAN_MAX_TERMINAL_BUFFER` / `CODEMAN_TRIM_TERMINAL_TO` env overrides are preserved, so runtime behavior is unchanged on its own. It is the mechanism half of a stacked change; a follow-up raises the defaults.
|
||||
- `buffer-limits.ts` sources `MAX_TERMINAL_BUFFER_SIZE` / `TRIM_TERMINAL_TO` from the resolver.
|
||||
- `tmux-manager.ts` uses `DEFAULT_TMUX_HISTORY_LIMIT` in place of the hardcoded `history-limit 50000`, gains `setHistoryLimit()` (mux-interface + impl) so a settings change applies to live sessions, and re-applies the limit on `respawnPane` so it survives a respawn.
|
||||
- `session.ts` threads a per-session `tmuxHistoryLimit` into the tmux spawn calls; `server.ts` exposes `getTerminalHistoryConfig()` on the route ctx and `system-routes.ts` applies a changed `tmuxHistoryLimit` to live sessions immediately.
|
||||
- `schemas.ts` adds four optional, bounds-clamped settings keys (`terminalScrollbackLines`, `tmuxHistoryLimit`, `terminalBufferMaxBytes`, `terminalBufferTrimBytes`) with a `trim <= max` cross-field check.
|
||||
- New tests: `test/terminal-history.test.ts` (resolver defaults / clamping / trim<=max / non-number fallback) and `test/terminal-history-schema.test.ts` (settings-schema validation).
|
||||
|
||||
## 1.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix local echo on iOS Safari when switching into a tab whose session already has output. The on-screen-keyboard "heal" (refit + scroll-to-bottom + overlay re-render + one-shot resize) only ran on a keyboard visibility transition, so switching into a tab while the keyboard was already up never triggered it — leaving the local-echo overlay rendering against stale, off-bottom terminal state. Typed characters were invisible (or mispositioned at the cursor row, far below the actual `❯` prompt) until the user manually hid and re-showed the keyboard. `selectSession` now replicates that heal when the keyboard is already visible, so local echo paints correctly on the first keystroke after a keyboard-up tab switch.
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Merge four feature PRs and harden them for release.
|
||||
|
||||
**Gemini run mode (PR #134, COD-36)** — a third external-CLI backend alongside Codex and OpenCode (`SessionMode` adds `'gemini'`). New `gemini-cli-resolver.ts`, `buildGeminiCommand()` (`--skip-trust`, `--approval-mode {default|auto_edit|yolo|plan}` defaulting to `yolo`, `--model`, `--resume`), `setGeminiEnvVars()` (socket-scoped `tmux setenv` of `GEMINI_*`/`GOOGLE_*` auth incl. Vertex AI), `GET /api/gemini/status` with an install hint (`npm install -g @google/gemini-cli`), run-mode dropdown + welcome "Run Gemini" button + "Run GM" label, `GeminiConfigSchema`, and `GEMINI_*`/`GOOGLE_*` added to the env-override allowlist. Requires tmux (no PTY fallback), like Codex.
|
||||
|
||||
**Cross-session search (PR #133, COD-113)** — `GET /api/search?q=&types=&limit=` federates an in-memory search across session metadata, run-summary events, and attachment-history file entries (substring match, hard caps, no FS reads); history-panel search box in the frontend.
|
||||
|
||||
**Away digest (PR #136, COD-41)** — `GET /api/away-digest` aggregates "what happened while you were away" (lifecycle log, run summaries, live sessions, daily token stats, recent subagents) into categorized sections behind a header-button modal (hidden on phones).
|
||||
|
||||
**Ralph todo-config (PR #135, COD-79)** — per-session `maxTodos` and `todoExpirationMinutes` via `POST /api/sessions/:id/ralph-config`; now persisted in `RalphTrackerState` and read back into the Session Options modal (mirrors `maxIterations` round-trip).
|
||||
|
||||
**Review fixes applied on merge:**
|
||||
- Gemini: fixed two `{success,data}` envelope bugs in `runGemini()` (status check and new-session selection) that made the Run-Gemini button non-functional; fixed `setGeminiEnvVars()` to use the socket-scoped tmux command so Google-auth env injection actually reaches the session.
|
||||
- Gemini parity: tab-mode badge, kill-dialog label, `codeman doctor` registry entry, `isGeminiAvailable` barrel export, `COLORTERM=truecolor`, and alt-screen/scrollback stripping (Ink TUI, like Codex/Claude).
|
||||
- Restored four envelope-shape test assertions weakened during the Gemini PR; added a `runGemini()` regression test covering the envelope path.
|
||||
- Ralph todo-config values now persist across restart and read back correctly instead of always reverting to defaults.
|
||||
|
||||
## 1.1.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix the connection indicator flashing "Sending 1B…" on every keystroke. The reliable input-delivery layer (1.1.16) marks each keystroke as briefly pending until its ACK arrives a few milliseconds later, which made the indicator flash on every character while typing on a healthy connection. The indicator is now hidden whenever the connection is healthy and only appears for an actual problem (reconnecting/offline), where it still shows the queued byte count so you know buffered input will be sent.
|
||||
|
||||
## 1.1.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Mobile image uploads, reliable input delivery, and gesture window dragging.
|
||||
|
||||
**Mobile image uploads (camera-roll picker / drag-drop / paste).** The "🖼 Image" button now handles real photo batches: up to 20 images per batch uploaded with bounded concurrency and a live "Uploading N/M…" progress toast (with a summary of successes, failures, and whether the 20-cap trimmed the selection). The per-file limit is raised from 10MB to 50MB (`MAX_PASTE_IMAGE_BYTES`, env-overridable via `CODEMAN_MAX_PASTE_IMAGE_BYTES`) so full-resolution phone photos and large screenshots are accepted. Very large images are downscaled to ≤4096px on the longest edge before upload, fixing iOS Safari's ~16.7M-px `<canvas>` limit that previously made huge photos fail to re-encode. Also fixes a latent concurrency bug the batch path exposed where the first parallel uploads to a session raced on creating `.claude-images/` and failed with EEXIST.
|
||||
|
||||
**Reliable, exactly-once input delivery.** A "sent" prompt could be silently lost on a flaky connection (e.g. a train): a half-open WebSocket accepts `ws.send()` without error while discarding the frame, and nothing was queued or resent. Input is now recorded durably (localStorage) with a stable clientId + monotonic per-session sequence before delivery, and only dropped once the server ACKs it — delivered over the WebSocket (acked via `{t:'ia',seq}`) or, when the socket is down, over POST in order. A 2s sweep force-reconnects a half-open socket; pending input survives reconnects and page reloads. The server applies each `(clientId, seq)` at most once (`Session.shouldApplyInput`), so an at-least-once resend can never type the prompt twice. Untagged input (curl/legacy) is unchanged. See `docs/reliable-input-delivery.md`.
|
||||
|
||||
**Gesture beta: drag agent windows.** With the camera hand-tracking overlay, you can now pinch and move the floating subagent and ultracode run/transcript windows. They keep their glowing connector line to the session tab while moving and can travel across a multi-monitor seam.
|
||||
|
||||
## 1.1.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Security: harden all frontend inline `onclick`/`ondblclick` handlers against a stored-XSS double-context bug.
|
||||
|
||||
Many inline handlers interpolated values as `'${escapeHtml(value)}'` — a JavaScript string literal sitting inside an HTML attribute. The browser HTML-decodes the attribute value _before_ parsing the handler source, so `escapeHtml`'s `'` reverts to a literal `'` and a quote-bearing id/name/path/URL breaks out of the JS string into executable code. `escapeHtml` alone is insufficient for this JS-string-within-HTML-attribute context.
|
||||
|
||||
All affected handlers now use `escapeHtml(JSON.stringify(value))`: `JSON.stringify` JS-encodes and quote-wraps the value, then `escapeHtml` handles the HTML-attribute layer, so the value round-trips as a single inert string argument.
|
||||
- ultracode run/agent cards and minimized-tab badges (`ultracode-panel.js`, `ultracode-windows.js`) — PR #132.
|
||||
- Session tabs (click/rename/gear/detach/close), notifications, subagent windows + dropdowns, the agents/tools/log-viewer/image-popup panels, mux-session monitor rows, and case-management buttons (`app.js`, `notification-manager.js`, `subagent-windows.js`, `panels-ui.js`, `session-ui.js`).
|
||||
- Two non-`escapeHtml` variants of the same class: a pre-escaped mux-session id in `panels-ui.js` (`selectSession`/`killMuxSession`) and a fully raw, unescaped `phase.id` in `orchestrator-panel.js` (`orchestratorSkipPhase`/`orchestratorRetryPhase`).
|
||||
|
||||
The most realistic exploitation vector was file paths in the project-insights log-viewer link, since filenames can legally contain a single quote. Purely numeric interpolations and developer-literal handler strings were left unchanged.
|
||||
|
||||
## 1.1.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Ultracode (Workflow-tool) floating windows — agent transcripts in-page, and minimize-to-tab.
|
||||
- **Agent transcripts open in-page, connected, instead of a detached browser popup.** Clicking an agent card (in a run window or the dock panel) now opens the agent's live transcript as its own draggable floating window, tied by a connector line to its parent run window (falling back to the run's session tab if that window has since closed) — the same line idiom the run windows use. Re-clicking a card focuses the existing window; closing it removes the window and its line. (Previously this spawned a separate `window.open` browser popup.)
|
||||
- **The window "−" button now minimizes into the originating session tab**, mirroring the subagent-window idiom. The window genie-animates into its tab and is tracked there; the tab shows an `ULTRA` badge whose hover/click dropdown lists each minimized item (🧬 run windows, 📄 agent transcripts). Click an item to restore its floating window, or dismiss it with ×. A run minimized while still active keeps tracking in the background and its badge auto-clears shortly after the run finishes. Both run windows and agent-transcript windows minimize into the same merged badge.
|
||||
- Removed the old collapse-to-header behavior that the "−" button previously triggered (now superseded by minimize-to-tab).
|
||||
|
||||
## 1.1.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Keep the `/compact` button in the extended (full) mobile keyboard accessory bar; only the simple bar drops it. (1.1.12 had removed it from both.)
|
||||
|
||||
## 1.1.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Remove the `/compact` button from the mobile keyboard accessory bar. It had been reintroduced in 1.1.10; this removes the button from both the simple and full accessory-bar layouts (the underlying command handler is left in place as inert plumbing).
|
||||
|
||||
## 1.1.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Ultracode (Workflow-tool) run visualization — much better live tracking.
|
||||
|
||||
While a run is in flight, the watcher previously showed empty agent slots ("agent N", 0 tokens, raw `wf_…` id as the title) because the detailed completion JSON only lands when the run finishes. The live path now enriches in-flight runs directly from the on-disk transcript tree:
|
||||
- **Real per-agent stats mid-run** — tokens and tool-call counts are parsed from each `agent-<id>.jsonl` transcript (tool counts match the final accounting exactly; token totals land within ~1% of the completion value), with model and a prompt preview. All mtime-cached (transcripts, journal, and script meta) so idle polls do no extra reads.
|
||||
- **Readable window/run title** — workflow name, summary, and phases are derived from the persisted `workflows/scripts/<name>-<runId>.js` instead of showing the raw run id.
|
||||
- **Agent status colors** — done agents show green, working agents show yellow (this also fixes the run/agent status badges, which referenced undefined `--success`/`--warning` CSS variables and were rendering with no color).
|
||||
- **Connector line** — the floating-window → session-tab line now uses the session-tab accent blue (was purple).
|
||||
- **Click a run to open its floating window** — clicking a workflow in the dock panel opens (or focuses) its floating window with the connector line, in addition to the auto-popped windows.
|
||||
- Agents are ordered by journal launch order; concurrent run-detail fetches are de-duplicated.
|
||||
|
||||
## 1.1.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Mobile CJK input, iPad keyboard accessory bar, and terminal touch interaction fixes (PRs #130, #131).
|
||||
|
||||
Mobile / CJK (#130):
|
||||
- Restore reliable real-time CJK (e.g. Pinyin) composition in the always-visible textarea, and refocus input when the terminal is tapped.
|
||||
- Stop clearing the textarea during `compositionstart` — some IMEs include existing text in the composition region, and clearing it mid-composition corrupted input.
|
||||
- iPad-specific fixes: `#cjkInput` positioning, paste-dialog placement, and duplicated voice-dictation output.
|
||||
- Split CJK keyboard positioning by device size (phones vs iPad use different keyboard offsets).
|
||||
- iPad accessory-bar styling/positioning: moved the accessory-bar and paste-overlay base styles out of the `max-width:1023px`-gated mobile stylesheet so iPad landscape (≥1024px) renders them correctly.
|
||||
- Raise the toolbar stacking context while the case-settings popover is open so the popover is no longer hidden behind the toolbar.
|
||||
- Restore the `/compact` button to the keyboard accessory bar (with double-tap confirmation, like `/clear`); the paste dialog now submits pasted text on "Send".
|
||||
|
||||
Terminal touch + forced redraw (#131):
|
||||
- Enable terminal touch interaction on all touch devices and show the stop button on touch devices.
|
||||
- Add an 8px tap threshold so micro-drift is treated as a tap, not a scroll, fixing cases where a tap failed to register.
|
||||
- Tap-to-position the cursor via a synthesized mouse report, gated on the live mouse-tracking mode so it never triggers local text selection when tracking is off; let SGR mouse reports through to the PTY even while the CJK input field owns focus.
|
||||
- Suppress the cursor/momentum side effects of a sub-threshold tap so a jittery tap no longer both positions the cursor and starts a momentum fling.
|
||||
- New opt-in, per-device "Redraw Terminal" header button (`showRedrawButton`, default off) that forces an xterm redraw via a resize jitter to clear occasional rendering glitches; the resize path now accepts a `force` flag (threaded through the session, HTTP, and WebSocket resize routes) that guarantees a SIGWINCH/redraw at the current device's size without bypassing multi-client resize arbitration.
|
||||
|
||||
## 1.1.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Two welcome-screen tunnel changes:
|
||||
- **UI (Daylight Blue skin):** the **Cloudflare Tunnel** button is now purple (was orange/yellow), keeping the three welcome buttons visually distinct — Claude blue, Tunnel purple, OpenCode green.
|
||||
- **Enable a tunnel without `CODEMAN_PASSWORD`, with a warning.** Previously enabling the Cloudflare tunnel with no password set was hard-refused unless you set `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`. Now you can opt in straight from the browser: clicking the tunnel toggle without a password pops a **security confirm dialog** ("publishes this machine to a public URL with no login — effectively remote code execution; set CODEMAN_PASSWORD instead"), and only on confirm does it enable, sending an explicit per-request `acknowledgeUnauthTunnel:true`. The server logs a loud warning whenever a passwordless public tunnel starts. curl/API/CLI callers are unchanged — still refused unless they set a password, set the env var, or pass `acknowledgeUnauthTunnel:true` — so nothing gets exposed accidentally. The acknowledgment is an action field and is never persisted to settings.json.
|
||||
|
||||
## 1.1.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- UI (Daylight Blue skin): give the welcome-screen action buttons distinct colors instead of all reading blue. **Run Claude Code** keeps the blue accent, **Cloudflare Tunnel** now uses Cloudflare's brand orange, and **Run OpenCode** uses an emerald green — so the three are visually distinguishable at a glance. Scoped to the default `daylight-blue` skin only (daylight-green and OG are unchanged), with matching hover/active states and dark ink for contrast. Verified in a real browser: the three buttons compute to blue / orange / green gradients on the welcome overlay.
|
||||
|
||||
## 1.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix: terminal scroll-up (scrollback) intermittently breaking for **Claude** sessions — most visible on iPhone, where you suddenly "can't scroll up the Claude console."
|
||||
|
||||
Root cause: Claude Code periodically emits alternate-screen switches (`\x1b[?1049h`/`\x1b[?47h`/`\x1b[?1047h`), scrollback-erase (`\x1b[3J`), and mouse-tracking enables — typically when it draws a full-screen UI (pickers/dialogs, the boot welcome). xterm.js obeys these by moving to the scrollback-less alternate buffer (or wiping saved lines / hijacking the wheel), so the conversation history becomes unreachable until Claude returns to its normal view. Codeman already stripped these sequences so history stays scrollable, but the strip was gated to **Codex mode only** — Claude (and the equivalent buffer-replay path) let them through.
|
||||
|
||||
The strip is now shared via a single `isAltScreenStripMode(mode)` predicate (`codex || claude`) applied at BOTH sites that were Codex-only: the live PTY stream (`Session._handleTerminalOutput`, including the split-across-chunks carry reassembly) and the `/terminal` buffer replay used on tab-switch/reconnect. `shell` is deliberately excluded so full-screen TUIs run from a shell (vim/less/htop) keep their alternate screen; `opencode` is also unchanged.
|
||||
|
||||
Verified end-to-end on an isolated instance against a real Claude session: the replayed buffer and live stream now carry zero alt-screen/scrollback-erase/mouse sequences, the terminal stays in the normal buffer with scrollback intact, and touch swipe-up scrolls correctly. Covered by new unit tests (`test/claude-scrollback-strip.test.ts`); the existing Codex strip tests are unchanged.
|
||||
|
||||
## 1.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix: ultracode floating run windows now pop on a fresh device/browser that loads while a run is already active.
|
||||
|
||||
`ultracodeFloatingWindows` syncs from the server (it's a non-display setting), but on a first-time device the SSE `getLightState` run snapshot can seed the run list BEFORE the async settings load resolves — so the floating-window gate read `false` at that instant and skipped any already-active run, leaving the window un-popped until the next ~10s watcher tick. The app now re-runs `syncAllUltracodeFloatingWindows()` once server settings finish loading (in the `loadAppSettingsFromServer().then()` callback), so an in-flight run pops its window immediately. Idempotent: open windows are left as-is, and if the setting is off any premature windows are torn down. Verified end-to-end against a real in-flight run on an isolated instance — a pristine browser (empty localStorage) seeds the setting from the server and pops the active run's window ~0.4s after first paint.
|
||||
|
||||
Also corrected a stale `@fileoverview` comment in `ultracode-windows.js` that claimed the floating windows are gated on `showUltracodeAgents`; they are gated on the dedicated `ultracodeFloatingWindows` toggle (only the docked "Ultracode Agents" panel uses `showUltracodeAgents`).
|
||||
|
||||
## 1.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix: the Ultracode Agents panel's (×) Close button now fully hides the panel.
|
||||
|
||||
`closeUltracodeAgentsPanel()` only removed the `open` class, which drops the bottom-docked drawer to its collapsed _peek_ state (the 36px header strip stays visible) rather than closing it — so clicking (×) looked like it did nothing. It now also adds the `hidden` class (`display:none`), mirroring `closeSubagentsPanel()`. It deliberately does NOT flip the `showUltracodeAgents` setting (that also gates the run watcher and floating windows); the header launcher button reopens the panel. Verified in a real browser: after (×) the panel computes `display:none`.
|
||||
|
||||
## 1.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h2 align="center">Mission control for AI coding agents</h2>
|
||||
|
||||
<p align="center">
|
||||
<em>Claude Code • OpenCode • Codex • Terminal - One Dashboard • Any Device</em>
|
||||
<em>Claude Code • OpenCode • Codex • Gemini • Terminal - One Dashboard • Any Device</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -14,6 +14,8 @@
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
|
||||
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
|
||||
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
|
||||
<a href="https://www.npmjs.com/package/aicodeman"><img src="https://img.shields.io/npm/v/aicodeman?style=flat-square&label=npm&color=22c55e" alt="npm version"></a>
|
||||
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -24,6 +26,15 @@
|
||||
<img src="docs/images/subagent-demo.gif" alt="Codeman — parallel subagent visualization" width="900">
|
||||
</p>
|
||||
|
||||
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, or Gemini CLI inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
|
||||
|
||||
- **One dashboard, four CLIs** - run [Claude Code, OpenCode, Codex, or Gemini](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions)
|
||||
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
|
||||
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
|
||||
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
|
||||
- **Nothing gets lost** - tmux persistence across restarts and network drops, exactly-once input delivery, full-scrollback replay
|
||||
- **Self-hosted and boring on purpose** - loopback-only by default, MIT licensed, no telemetry, runs entirely on your machine
|
||||
|
||||
---
|
||||
|
||||
## Quick Start - Installation
|
||||
@@ -32,19 +43,35 @@
|
||||
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
|
||||
```
|
||||
|
||||
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it.
|
||||
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it. A few things worth knowing:
|
||||
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), or [Codex](https://developers.openai.com/codex/cli) (any combination works). After install:
|
||||
- **It asks first.** Every system change (package installs, AI CLI download) is prompted, and a menu at the end lets you choose: run Codeman in this terminal, install it as a background service (systemd/launchd, auto-start on boot), or don't start yet. Nothing runs in the background unless you pick it.
|
||||
- **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
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cat > ~/.config/systemd/user/codeman-web.service << EOF
|
||||
@@ -67,6 +94,7 @@ loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
**macOS (launchd):**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Library/LaunchAgents
|
||||
cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
|
||||
@@ -94,6 +122,7 @@ cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
|
||||
EOF
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -103,11 +132,79 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
|
||||
```
|
||||
|
||||
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), or [Codex](https://developers.openai.com/codex/cli)). After installing, `http://localhost:3000` is accessible from your Windows browser.
|
||||
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), or [Gemini CLI](https://github.com/google-gemini/gemini-cli)). After installing, `http://localhost:3000` is accessible from your Windows browser.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Using Codeman — A Human's Guide
|
||||
|
||||
A start-to-finish walkthrough for driving Codeman from the browser. If you just installed, this is where to begin.
|
||||
|
||||
### 1. Launch the server
|
||||
|
||||
```bash
|
||||
codeman web # localhost:3000 (loopback only — safe default)
|
||||
codeman web --port 8080 # custom port (or set CODEMAN_PORT)
|
||||
codeman web --https # self-signed TLS (only needed for remote access)
|
||||
codeman web -H 0.0.0.0 # bind LAN — REQUIRES CODEMAN_PASSWORD (see Security)
|
||||
```
|
||||
|
||||
Open the printed URL. The page is a single dashboard; everything below happens there.
|
||||
|
||||
### 2. Create your first session
|
||||
|
||||
Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in its own tmux-backed terminal. You choose:
|
||||
|
||||
| Field | What it does |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. |
|
||||
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Gemini`, or `Terminal` (plain shell). |
|
||||
| **Model** | Per-session model (App Settings → Claude Model). A soft default — `/model` still works in-session. |
|
||||
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
|
||||
|
||||
Hit start — Codeman spawns the CLI via a real PTY and streams it to your browser over SSE.
|
||||
|
||||
### 3. Read the dashboard
|
||||
|
||||
- **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).
|
||||
|
||||
### 4. Talk to the agent
|
||||
|
||||
- **Type prompts** straight into the terminal — input is delivered exactly-once even across reconnects (a dropped link never loses or double-sends a prompt).
|
||||
- **Paste or drag-and-drop images** directly into the session.
|
||||
- **Voice input** — `Ctrl+Shift+V` (Deepgram Nova-3, with auto-silence stop).
|
||||
- **Attachments** — register external files/docs and preview Office/PDF inline.
|
||||
|
||||
### 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 _(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
|
||||
|
||||
- **Phone/tablet** — the UI is fully touch-optimized; scan the desktop **QR code** to log in without typing a password.
|
||||
- **Outside your network** — `./scripts/tunnel.sh start` opens a Cloudflare tunnel (set `CODEMAN_PASSWORD` first).
|
||||
- **SSH** — the `sc` chooser attaches to any session from a terminal (`sc` interactive, `sc 2` quick-attach, `sc -l` list).
|
||||
|
||||
### 7. Operate & maintain
|
||||
|
||||
- **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).
|
||||
|
||||
> ⚠️ **Safety:** if you're working _inside_ a Codeman-managed session (`echo $CODEMAN_MUX` → `1`), never run `tmux kill-session` / `pkill claude` directly — use the web UI or `./scripts/tmux-manager.sh`.
|
||||
|
||||
---
|
||||
|
||||
## Mobile-Optimized Web UI
|
||||
|
||||
The most responsive AI coding agent experience on any phone. Full xterm.js terminal with local echo, swipe navigation, and a touch-optimized interface designed for real remote work — not a desktop UI crammed onto a small screen.
|
||||
@@ -214,7 +311,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
|
||||
```
|
||||
|
||||
- **Multi-layer idle detection** — completion messages, AI-powered idle check, output silence, token stability
|
||||
- **Auto-resume on usage limit** *(opt-in, off by default)* — when Claude halts on a subscription limit ("You've hit your limit · resets 3pm"), Codeman parses the reset time, waits it out plus a 2-minute safety buffer, then dismisses the rate-limit dialog and sends `continue` — so an overnight run survives the 5-hour window instead of stalling until morning. Recognizes every Claude Code limit-message format, retries if still limited, survives Codeman restarts, and holds respawn cycles while paused so `/clear` can't wipe the waiting conversation. Enable per session at the top of the Respawn tab
|
||||
- **Auto-resume on usage limit** _(opt-in, off by default)_ — when Claude halts on a subscription limit ("You've hit your limit · resets 3pm"), Codeman parses the reset time, waits it out plus a 2-minute safety buffer, then dismisses the rate-limit dialog and sends `continue` — so an overnight run survives the 5-hour window instead of stalling until morning. Recognizes every Claude Code limit-message format, retries if still limited, survives Codeman restarts, and holds respawn cycles while paused so `/clear` can't wipe the waiting conversation. Enable per session at the top of the Respawn tab
|
||||
- **Circuit breaker** — prevents respawn thrashing when Claude is stuck (CLOSED -> HALF_OPEN -> OPEN states, tracks consecutive no-progress and repeated errors)
|
||||
- **Health scoring** — 0-100 health score with component scores for cycle success, circuit breaker state, iteration progress, and stuck recovery
|
||||
- **Built-in presets** — `solo-work` (3s idle, 60min), `subagent-workflow` (45s, 240min), `team-lead` (90s, 480min), `ralph-todo` (8s, 480min), `overnight-autonomous` (10s, 480min)
|
||||
@@ -247,6 +344,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:
|
||||
@@ -260,10 +365,10 @@ The title is templated into the served HTML on first byte, so it's correct from
|
||||
|
||||
### Smart Token Management
|
||||
|
||||
| Threshold | Action | Result |
|
||||
|-----------|--------|--------|
|
||||
| Threshold | Action | Result |
|
||||
| --------------- | --------------- | ---------------------------------- |
|
||||
| **110k tokens** | Auto `/compact` | Context summarized, work continues |
|
||||
| **140k tokens** | Auto `/clear` | Fresh start with `/init` |
|
||||
| **140k tokens** | Auto `/clear` | Fresh start with `/init` |
|
||||
|
||||
### Notifications
|
||||
|
||||
@@ -294,17 +399,70 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
## More Features
|
||||
|
||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, or **Codex** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, or **Gemini** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*`/`GOOGLE_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||
- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
|
||||
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
|
||||
- **Voice input** — dictate prompts with Deepgram Nova-3 (Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
|
||||
- **Image input** — paste or drag-and-drop images straight into a session
|
||||
- **Gesture control** *(opt-in)* — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Display
|
||||
- **Multi-monitor span** *(macOS)* — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
|
||||
- **Gesture control** _(opt-in)_ — a MediaPipe hand-tracking overlay to grab/drag session windows and pinch buttons, hands-free. Enable with `CODEMAN_GESTURE=1` + App Settings → Display
|
||||
- **Multi-monitor span** _(macOS)_ — one click opens a browser window maximized across all displays, so floating agent/gesture panels can cross the physical seam
|
||||
- **File Viewer button** _(opt-in)_ — a header button that toggles the built-in file browser panel with one tap; enable under App Settings → Display → Header Displays
|
||||
- **CJK / IME input** — full composition support for Chinese / Japanese / Korean
|
||||
- **OS notifications & hostname-aware titles** — desktop alerts and tab titles are prefixed `codeman:<host>` so multi-host setups stay unambiguous
|
||||
|
||||
---
|
||||
|
||||
## Isolated Docker Sessions
|
||||
|
||||
Run a case inside its own hardened Docker container instead of directly on your host — for security isolation, reproducible toolchains, and one-click portability.
|
||||
|
||||
- **One click** — on **New Case → Create New**, tick **🐳 Run in an isolated Docker container**. Codeman creates the case folder, spins up a container with default settings, and starts the agent inside it. No host/image/network fields to fill in.
|
||||
- **Resource templates** — expand the checkbox for a **Small / Medium / Large / GPU** preset (memory, CPUs, GPU), or set your own. **Disk is elastic** — storage grows as data flows in, no fixed cap.
|
||||
- **Shared per-case container** — many sessions can `docker exec` into the same container; killing one session never tears the container out from under the others.
|
||||
- **Hardened by default** — non-root, `--cap-drop ALL`, `no-new-privileges`, PID/memory caps, never `--privileged` or the docker socket; a **sealed** profile (no host credentials, network off) is one toggle away.
|
||||
- **Seamless auth, isolated credentials** — your host Claude / Codex / Gemini / OpenCode logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets.
|
||||
- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case.
|
||||
- **Durable** — reconnect after a restart lands back in the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript.
|
||||
|
||||
Prerequisite: just Docker (or Podman). The agent base image builds itself automatically on first use, with progress streamed to the UI (or pre-build it with `node scripts/build-agent-image.mjs`). Full guide: [`docs/docker-cases.md`](docs/docker-cases.md).
|
||||
|
||||
---
|
||||
|
||||
## Remote SSH Sessions
|
||||
|
||||
Point a case at another machine and run the agent **there**, over SSH, with the same dashboard, mobile UI, and autonomy features. Your laptop is just a window onto a session that lives on the remote host.
|
||||
|
||||
- **Durable by design**: the agent runs inside a dedicated tmux session on the remote host, so a dropped SSH connection, network change, or laptop sleep never kills the run. Reconnecting lands back in the same live conversation.
|
||||
- **Auto-reconnect**: a bounded-backoff watcher notices a dead SSH pane and silently reattaches to the still-running remote session (kill-switch in settings; intentional kills are never revived).
|
||||
- **Discover & attach**: list the `codeman-*` sessions already running on a host (started by that machine's own Codeman, or by another operator) and attach to one. Attached sessions you don't own **detach on tab close, never kill**.
|
||||
- **Shared sessions**: several clients can attach the same remote session at different window sizes without clamping each other; discovery shows a "shared" badge with the client count.
|
||||
- **Injection-safe**: every ssh command line flows through a single shell-escaping builder, and host/path/identity fields are schema-guarded.
|
||||
|
||||
Set it up under **New Case → Remote** (host, user, identity file, optional jump host). Full design: [`docs/remote-sessions.md`](docs/remote-sessions.md).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
Enable with `codeman web --multiuser` (or `CODEMAN_MULTIUSER=1`). Create the first admin, then manage users from the CLI or the **Users** tab in App Settings:
|
||||
|
||||
```bash
|
||||
codeman users add alice --admin # prompts for a password (or --password-stdin)
|
||||
codeman users add bob # a regular user
|
||||
codeman users list
|
||||
```
|
||||
|
||||
- **Per-user spaces** — each user's cases live under `~/codeman-users/<name>/cases`; sessions, cases, search, and real-time events are scoped to their owner. Admins see everything.
|
||||
- **Individually revocable logins** — named users with scrypt-hashed passwords in `~/.codeman/users.json`; disable, reset (one-time password), or delete an account at any time. Admin actions are audited to `~/.codeman/admin-audit.jsonl`.
|
||||
- **Safer defaults for regular users** — non-admins run Claude in `--permission-mode auto` (Anthropic's classifier-guarded mode); raw shell sessions, cron `launchCommand`, and skip-permissions require an explicit per-user grant.
|
||||
|
||||
> ⚠️ **This separates workspaces; it does not sandbox users from each other.** Every session runs as the same OS account, so a determined user's agent can still reach another user's files. For real isolation, pair users with **Docker cases** or run separate instances under separate OS accounts. See [`docs/multi-user-plan.md`](docs/multi-user-plan.md) and the multi-user section of [`docs/security-architecture.md`](docs/security-architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## Remote Access — Cloudflare Tunnel
|
||||
|
||||
Access Codeman from your phone or any device outside your local network using a free [Cloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/) — no port forwarding, no DNS, no static IP required.
|
||||
@@ -372,14 +530,14 @@ Every **60 seconds**, the server automatically rotates to a fresh token. The pre
|
||||
|
||||
The design is informed by ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin) (USENIX Security 2025), which found 47 of the top-100 websites vulnerable to QR auth attacks due to 6 critical design flaws across 42 CVEs. Codeman addresses all six:
|
||||
|
||||
| USENIX Flaw | Mitigation |
|
||||
|-------------|------------|
|
||||
| **Flaw-1**: Missing single-use enforcement | Token atomically consumed on first scan — replays always fail |
|
||||
| **Flaw-2**: Long-lived tokens | 60s TTL with 90s grace, auto-rotation via timer |
|
||||
| **Flaw-3**: Predictable token generation | `crypto.randomBytes(32)` — 256-bit entropy. Short codes use rejection sampling to eliminate modulo bias |
|
||||
| **Flaw-4**: Client-side token generation | Server-side only — tokens never leave the server until embedded in the QR |
|
||||
| **Flaw-5**: Missing status notification | Desktop toast: *"Device [IP] authenticated via QR (Safari). Not you? [Revoke]"* — real-time QRLjacking detection |
|
||||
| **Flaw-6**: Inadequate session binding | IP + User-Agent stored for audit. Manual session revocation via API. HttpOnly + Secure + SameSite=lax cookies |
|
||||
| USENIX Flaw | Mitigation |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| **Flaw-1**: Missing single-use enforcement | Token atomically consumed on first scan — replays always fail |
|
||||
| **Flaw-2**: Long-lived tokens | 60s TTL with 90s grace, auto-rotation via timer |
|
||||
| **Flaw-3**: Predictable token generation | `crypto.randomBytes(32)` — 256-bit entropy. Short codes use rejection sampling to eliminate modulo bias |
|
||||
| **Flaw-4**: Client-side token generation | Server-side only — tokens never leave the server until embedded in the QR |
|
||||
| **Flaw-5**: Missing status notification | Desktop toast: _"Device [IP] authenticated via QR (Safari). Not you? [Revoke]"_ — real-time QRLjacking detection |
|
||||
| **Flaw-6**: Inadequate session binding | IP + User-Agent stored for audit. Manual session revocation via API. HttpOnly + Secure + SameSite=lax cookies |
|
||||
|
||||
#### Timing-Safe Lookup
|
||||
|
||||
@@ -404,23 +562,23 @@ When someone authenticates via QR, the desktop shows a notification toast with t
|
||||
|
||||
#### Threat Coverage
|
||||
|
||||
| Threat | Why it doesn't work |
|
||||
|--------|-------------------|
|
||||
| **QR screenshot shared** | Single-use: consumed on first scan. 60s TTL: expired before the attacker can act. Desktop notification alerts you immediately. |
|
||||
| **Replay attack** | Atomic single-use consumption + 60s TTL. Old URLs always return 401. |
|
||||
| **Cloudflare edge logs** | Short code is an opaque 6-char lookup key, not the real 256-bit token. Single-use means replaying from logs always fails. |
|
||||
| **Brute force** | 56.8 billion combinations, ~2 valid at any time, dual-layer rate limiting blocks well before statistical feasibility. |
|
||||
| **QRLjacking** | 60s rotation forces real-time relay. Desktop toast provides instant detection. Self-hosted single-user context makes phishing implausible. |
|
||||
| **Timing attack** | Hash-based Map lookup — no string comparison timing leak. |
|
||||
| **Session cookie theft** | HttpOnly + Secure + SameSite=lax + 24h TTL. Manual revocation at `POST /api/auth/revoke`. |
|
||||
| Threat | Why it doesn't work |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **QR screenshot shared** | Single-use: consumed on first scan. 60s TTL: expired before the attacker can act. Desktop notification alerts you immediately. |
|
||||
| **Replay attack** | Atomic single-use consumption + 60s TTL. Old URLs always return 401. |
|
||||
| **Cloudflare edge logs** | Short code is an opaque 6-char lookup key, not the real 256-bit token. Single-use means replaying from logs always fails. |
|
||||
| **Brute force** | 56.8 billion combinations, ~2 valid at any time, dual-layer rate limiting blocks well before statistical feasibility. |
|
||||
| **QRLjacking** | 60s rotation forces real-time relay. Desktop toast provides instant detection. Self-hosted single-user context makes phishing implausible. |
|
||||
| **Timing attack** | Hash-based Map lookup — no string comparison timing leak. |
|
||||
| **Session cookie theft** | HttpOnly + Secure + SameSite=lax + 24h TTL. Manual revocation at `POST /api/auth/revoke`. |
|
||||
|
||||
#### How It Compares
|
||||
|
||||
| Platform | Model | Comparison |
|
||||
|----------|-------|------------|
|
||||
| **Discord** | Long-lived token, no confirmation, [repeatedly exploited](https://owasp.org/www-community/attacks/Qrljacking) | Codeman: single-use + TTL + notification |
|
||||
| **WhatsApp Web** | Phone confirms "Link device?", ~60s rotation | Comparable rotation; WhatsApp adds explicit confirmation (acceptable tradeoff for single-user) |
|
||||
| **Signal** | Ephemeral public key, E2E encrypted channel | Stronger crypto, but [exploited by Russian state actors in 2025](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger) via social engineering despite it |
|
||||
| Platform | Model | Comparison |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Discord** | Long-lived token, no confirmation, [repeatedly exploited](https://owasp.org/www-community/attacks/Qrljacking) | Codeman: single-use + TTL + notification |
|
||||
| **WhatsApp Web** | Phone confirms "Link device?", ~60s rotation | Comparable rotation; WhatsApp adds explicit confirmation (acceptable tradeoff for single-user) |
|
||||
| **Signal** | Ephemeral public key, E2E encrypted channel | Stronger crypto, but [exploited by Russian state actors in 2025](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger) via social engineering despite it |
|
||||
|
||||
> Full design rationale, security analysis, and implementation details: [`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
|
||||
|
||||
@@ -428,27 +586,28 @@ 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** — 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`)
|
||||
- **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
|
||||
- **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)
|
||||
|
||||
These run for **every** request — before auth, even on the default no-password loopback install:
|
||||
|
||||
- **Host-header allowlist → blocks DNS rebinding.** A custom domain rebound to `127.0.0.1` is rejected with `403 host not allowed` before any handler runs. Allowed: `localhost`, any IP literal, the bind host, `.ts.net` / `.trycloudflare.com` / `.cfargotunnel.com`, the active managed tunnel, and `CODEMAN_ALLOWED_HOSTS` (add custom reverse-proxy domains here — comma-separated; exact host or leading-dot `.suffix` for subdomains)
|
||||
- **Cross-site Origin / CSRF guard.** On state-changing methods (`POST`/`PUT`/`PATCH`/`DELETE`) the `Origin` must pass the same allowlist, else `403 cross-site request blocked`. A *missing* Origin is allowed (so `curl`, the CLI, and Claude Code hooks keep working); only a present-but-foreign or opaque `null` origin is rejected
|
||||
- **Cross-site Origin / CSRF guard.** On state-changing methods (`POST`/`PUT`/`PATCH`/`DELETE`) the `Origin` must pass the same allowlist, else `403 cross-site request blocked`. A _missing_ Origin is allowed (so `curl`, the CLI, and Claude Code hooks keep working); only a present-but-foreign or opaque `null` origin is rejected
|
||||
- **Raw `text/plain` bodies.** The global parser no longer JSON-parses `text/plain`, closing the CORS "simple request" CSRF vector where a cross-site `fetch` could smuggle JSON into a write route with no preflight
|
||||
- **WebSocket origin validation.** The terminal WS upgrade runs the same Host + Origin check and closes with code `4003` on failure (anti-CSWSH)
|
||||
- **XSS-escaped agent output.** AI-derived strings (tool names, command arguments, subagent descriptions) are HTML-escaped at every injection site before rendering in the subagent / activity panels
|
||||
|
||||
### Input, files & headers
|
||||
|
||||
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` env-prefix allowlist gates which settings each CLI can receive
|
||||
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` env-prefix allowlist gates which settings each CLI can receive
|
||||
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 50 MB raw & download; `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes
|
||||
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
|
||||
|
||||
@@ -479,74 +638,180 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|
||||
|
||||
> Ctrl bindings also accept Cmd on macOS.
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl/Cmd+W` | Kill active session |
|
||||
| `Ctrl/Cmd+Tab` | Next session |
|
||||
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
|
||||
| `Alt/Option+1`-`Alt/Option+9` | Switch to tab N (physical keys, so macOS Option layouts work) |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
|
||||
| `Ctrl/Cmd+L` | Clear terminal |
|
||||
| `Ctrl+Shift+R` | Restore terminal size |
|
||||
| `Ctrl+Shift+V` | Toggle voice input |
|
||||
| `Ctrl/Cmd +` / `-` | Font size |
|
||||
| `Ctrl/Cmd+?` | Keyboard help |
|
||||
| `Shift+Enter` | Insert newline (sent to terminal) |
|
||||
| `Escape` | Close panels & modals |
|
||||
| Shortcut | Action |
|
||||
| ------------------------------- | ------------------------------------------------------------- |
|
||||
| `Ctrl/Cmd+W` | Kill active session |
|
||||
| `Ctrl/Cmd/Option+K` | Find open session or start a new one |
|
||||
| `Ctrl/Cmd+Tab` | Next session |
|
||||
| `Alt/Option+[` / `Alt/Option+]` | Previous / next session |
|
||||
| `Alt/Option+1`-`Alt/Option+9` | Switch to tab N (physical keys, so macOS Option layouts work) |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
|
||||
| `Ctrl/Cmd+L` | Clear terminal |
|
||||
| `Ctrl+Shift+R` | Restore terminal size |
|
||||
| `Ctrl+Shift+V` | Toggle voice input |
|
||||
| `Ctrl/Cmd +` / `-` | Font size |
|
||||
| `Ctrl/Cmd+?` | Keyboard help |
|
||||
| `Shift+Enter` | Insert newline (sent to terminal) |
|
||||
| `Escape` | Close panels & modals |
|
||||
|
||||
---
|
||||
|
||||
## Driving Codeman from an Agent — Programmatic Guide
|
||||
|
||||
For AI agents and automation that control Codeman without a browser: an agent that spins up worker sessions, a CI bot, or **Claude Code running _inside_ a Codeman session orchestrating other sessions**. Everything the UI does is HTTP + a CLI, so an agent can do it too.
|
||||
|
||||
### Detect that you're inside Codeman
|
||||
|
||||
When a CLI runs in a Codeman-managed session, these environment variables are set — read them instead of hardcoding anything:
|
||||
|
||||
| Variable | Meaning |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `CODEMAN_MUX=1` | You're in a managed tmux session. **Never** `tmux kill-session` / `pkill claude` / `pkill tmux` — you'll kill yourself or a sibling. |
|
||||
| `CODEMAN_API_URL` | Base URL of the API (e.g. `https://127.0.0.1:3000`). Use it for every call below. |
|
||||
| `CODEMAN_SESSION_ID` | _Your own_ session id. Use it to avoid acting on yourself. |
|
||||
| `CODEMAN_HOOK_SECRET_FILE` | Path to the hook secret (required on `/api/hook-event` while a managed tunnel is up). |
|
||||
|
||||
### Rules of the road (read before you POST)
|
||||
|
||||
1. **Single-line input only.** Programmatic input is sent as literal text **+ Enter** in one shot. Multi-line strings break the agent TUI (Ink) — send one line, or split into multiple calls.
|
||||
2. **Make input idempotent.** Include a stable `clientId` and a monotonic per-session `seq` on `POST …/input`. The server de-duplicates, so a retry after a dropped connection can't double-deliver a prompt.
|
||||
3. **Auth.** If `CODEMAN_PASSWORD` is set, send HTTP Basic auth (user `admin` or `CODEMAN_USERNAME`) or a `codeman_session` cookie. The default loopback install is passwordless. A missing `Origin` header is allowed, so plain `curl` works; cross-site browser origins are rejected (CSRF guard).
|
||||
4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`).
|
||||
5. **`/api/v1/*`** is a stable alias of `/api/*`.
|
||||
|
||||
### Recipes
|
||||
|
||||
```bash
|
||||
API="${CODEMAN_API_URL:-http://127.0.0.1:3000}"
|
||||
# (add -u admin:"$CODEMAN_PASSWORD" to each call if a password is set)
|
||||
|
||||
# 1. See what's running
|
||||
curl -s "$API/api/sessions" | jq '.data // .'
|
||||
|
||||
# 2. Spin up a worker session (a "case" = named working dir)
|
||||
curl -s -X POST "$API/api/quick-start" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"refactor-auth","mode":"claude","effort":"high"}' | jq
|
||||
|
||||
# 3. Send a prompt into a session (exactly-once: clientId + seq)
|
||||
curl -s -X POST "$API/api/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input":"Run the test suite and summarize failures","useMux":true,"clientId":"agent-1","seq":1}'
|
||||
|
||||
# 4. Read the terminal back
|
||||
curl -s "$API/api/sessions/$SID/output" | jq -r '.data // .'
|
||||
|
||||
# 5. Stream live events (session output, agent activity, status)
|
||||
curl -sN "$API/api/events" # Server-Sent Events
|
||||
|
||||
# 6. Schedule recurring work (cron-style job)
|
||||
curl -s -X POST "$API/api/cron/jobs" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"nightly-deps","agentType":"claude","workingDir":"/home/me/proj",
|
||||
"promptMode":"inline_text","promptText":"Update dependencies and open a PR",
|
||||
"inputMode":"typed","scheduleType":"daily","dailyTime":"03:00",
|
||||
"enabled":true,"concurrencyPolicy":"warn_only"}' | jq
|
||||
|
||||
# 7. Inspect background sub-agents and their transcripts
|
||||
curl -s "$API/api/subagents" | jq '.data // .'
|
||||
curl -s "$API/api/subagents/$AID/transcript" | jq -r '.data // .'
|
||||
|
||||
# 8. Whole-system snapshot (sessions, settings, respawn, stats)
|
||||
curl -s "$API/api/status" | jq
|
||||
```
|
||||
|
||||
### Or use the bundled CLI
|
||||
|
||||
The same operations are available as commands (`codeman <cmd>`, aliases in parentheses) — handy from a shell tool inside a session:
|
||||
|
||||
```bash
|
||||
codeman session start -d /path/to/repo # (s) start a session
|
||||
codeman session list # list sessions
|
||||
codeman session logs <id> # tail output
|
||||
codeman task add "fix the failing test" # (t) queue a task
|
||||
codeman ralph start --min-hours 8 # (r) launch the autonomous loop
|
||||
codeman attach <path> # attach a Claude hook context
|
||||
```
|
||||
|
||||
### Hooks (events flowing _back_ to Codeman)
|
||||
|
||||
Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_prompt`, `idle_prompt`, `stop`, `task_completed`, …) so the dashboard reacts in real time. This endpoint is auth-exempt on loopback but, under a managed tunnel, requires the `X-Codeman-Hook-Secret` header (read it from `$CODEMAN_HOOK_SECRET_FILE`). You normally don't call this by hand — Codeman wires it up — but it's how the autonomy layers "see" what the agent is doing.
|
||||
|
||||
> Full endpoint list and request/response shapes follow.
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
REST over Fastify — **~140 handlers across 15 route modules**, plus an SSE stream and a WebSocket terminal channel. 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
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/sessions` | List all |
|
||||
| `POST` | `/api/quick-start` | Create case + start session |
|
||||
| `DELETE` | `/api/sessions/:id` | Delete session |
|
||||
| `POST` | `/api/sessions/:id/input` | Send input |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | -------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/sessions` | List all |
|
||||
| `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
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ---------------------------------- | -------------------------- |
|
||||
| `POST` | `/api/sessions/:id/respawn/enable` | Enable with config + timer |
|
||||
| `POST` | `/api/sessions/:id/respawn/stop` | Stop controller |
|
||||
| `PUT` | `/api/sessions/:id/respawn/config` | Update config |
|
||||
| `POST` | `/api/sessions/:id/respawn/stop` | Stop controller |
|
||||
| `PUT` | `/api/sessions/:id/respawn/config` | Update config |
|
||||
|
||||
### Ralph / Todo
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/sessions/:id/ralph-state` | Get loop state + todos |
|
||||
| `POST` | `/api/sessions/:id/ralph-config` | Configure tracking |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------------- | ---------------------- |
|
||||
| `GET` | `/api/sessions/:id/ralph-state` | Get loop state + todos |
|
||||
| `POST` | `/api/sessions/:id/ralph-config` | Configure tracking |
|
||||
|
||||
### Orchestrator
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/orchestrator/start` | Start orchestration from a goal |
|
||||
| `POST` | `/api/orchestrator/approve` | Approve the generated plan |
|
||||
| `GET` | `/api/orchestrator/status` | Current phase + progress |
|
||||
| `POST` | `/api/orchestrator/stop` | Stop and clean up |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | --------------------------- | ------------------------------- |
|
||||
| `POST` | `/api/orchestrator/start` | Start orchestration from a goal |
|
||||
| `POST` | `/api/orchestrator/approve` | Approve the generated plan |
|
||||
| `GET` | `/api/orchestrator/status` | Current phase + progress |
|
||||
| `POST` | `/api/orchestrator/stop` | Stop and clean up |
|
||||
|
||||
### Cron (scheduled jobs)
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ---------------- | ---------------------------- | ----------------------- |
|
||||
| `GET` / `POST` | `/api/cron/jobs` | List / create cron jobs |
|
||||
| `PUT` / `DELETE` | `/api/cron/jobs/:id` | Update / delete a job |
|
||||
| `PUT` | `/api/cron/jobs/:id/enabled` | Enable / disable |
|
||||
| `POST` | `/api/cron/jobs/:id/run` | Run now |
|
||||
| `GET` | `/api/cron/jobs/:id/runs` | Run history |
|
||||
|
||||
### Subagents
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/subagents` | List all background agents |
|
||||
| `GET` | `/api/subagents/:id` | Agent info and status |
|
||||
| `GET` | `/api/subagents/:id/transcript` | Full activity transcript |
|
||||
| `DELETE` | `/api/subagents/:id` | Kill agent process |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ------------------------------- | -------------------------- |
|
||||
| `GET` | `/api/subagents` | List all background agents |
|
||||
| `GET` | `/api/subagents/:id` | Agent info and status |
|
||||
| `GET` | `/api/subagents/:id/transcript` | Full activity transcript |
|
||||
| `DELETE` | `/api/subagents/:id` | Kill agent process |
|
||||
|
||||
### System
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/events` | SSE stream |
|
||||
| `GET` | `/api/status` | Full app state |
|
||||
| `POST` | `/api/hook-event` | Hook callbacks |
|
||||
| `GET` | `/api/system/update/check` | Check for a new release |
|
||||
| `POST` | `/api/system/update` | Self-update (git-clone installs) |
|
||||
| `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) |
|
||||
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ------------------------------- | ---------------------------------------------- |
|
||||
| `GET` | `/api/events` | SSE stream |
|
||||
| `GET` | `/api/status` | Full app state |
|
||||
| `POST` | `/api/hook-event` | Hook callbacks |
|
||||
| `GET` | `/api/system/update/check` | Check for a new release |
|
||||
| `POST` | `/api/system/update` | Self-update (git-clone installs) |
|
||||
| `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) |
|
||||
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
|
||||
|
||||
---
|
||||
|
||||
@@ -581,7 +846,7 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph External["External"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex</small>"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Gemini</small>"]
|
||||
BG["Background Agents<br/><small>(Task tool)</small>"]
|
||||
end
|
||||
end
|
||||
@@ -613,7 +878,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.
|
||||
@@ -624,14 +889,14 @@ See [CLAUDE.md](./CLAUDE.md) for full documentation.
|
||||
|
||||
The codebase went through a comprehensive 7-phase refactoring that eliminated god objects, centralized configuration, and established modular architecture:
|
||||
|
||||
| Phase | What changed | Impact |
|
||||
|-------|-------------|--------|
|
||||
| **Performance** | Cached endpoints, SSE adaptive batching, buffer chunking | Sub-16ms terminal latency |
|
||||
| **Route extraction** | `server.ts` split into 15 domain route modules + auth middleware + port interfaces | **−67%** server.ts LOC (6,736 → 2,254) |
|
||||
| **Domain splitting** | `types.ts` → 16 domain files, `ralph-tracker` → 7 files, `respawn-controller` → 5 files, `session` → 6 files | No more god files |
|
||||
| **Frontend modules** | `app.js` → 18 extracted modules across infra, domain & feature layers | app.js core down to **~3.4K LOC** |
|
||||
| **Config consolidation** | ~70 scattered magic numbers → 10 domain-focused config files | Zero cross-file duplicates |
|
||||
| **Test infrastructure** | Shared mock library, 12 route test files, consolidated MockSession | Testable route handlers via `app.inject()` |
|
||||
| Phase | What changed | Impact |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------ |
|
||||
| **Performance** | Cached endpoints, SSE adaptive batching, buffer chunking | Sub-16ms terminal latency |
|
||||
| **Route extraction** | `server.ts` split into 15 domain route modules + auth middleware + port interfaces | **−67%** server.ts LOC (6,736 → 2,254) |
|
||||
| **Domain splitting** | `types.ts` → 16 domain files, `ralph-tracker` → 7 files, `respawn-controller` → 5 files, `session` → 6 files | No more god files |
|
||||
| **Frontend modules** | `app.js` → 18 extracted modules across infra, domain & feature layers | app.js core down to **~3.4K LOC** |
|
||||
| **Config consolidation** | ~70 scattered magic numbers → 10 domain-focused config files | Zero cross-file duplicates |
|
||||
| **Test infrastructure** | Shared mock library, 12 route test files, consolidated MockSession | Testable route handlers via `app.inject()` |
|
||||
|
||||
Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-structure-findings.md)
|
||||
|
||||
@@ -670,3 +935,8 @@ MIT — see [LICENSE](LICENSE)
|
||||
<p align="center">
|
||||
<strong>Track sessions. Visualize agents. Control respawn. Let it run while you sleep.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
If Codeman saves you time, <a href="https://github.com/Ark0N/Codeman/stargazers">a star</a> helps other people find it.<br>
|
||||
Bug reports and feature ideas are welcome in <a href="https://github.com/Ark0N/Codeman/issues">Issues</a>.
|
||||
</p>
|
||||
|
||||
+353
-94
@@ -5,7 +5,7 @@
|
||||
<h2 align="center">AI 编程智能体的任务控制中心</h2>
|
||||
|
||||
<p align="center">
|
||||
<em>Claude Code • OpenCode • Codex —— 统一仪表盘 • 任意设备</em>
|
||||
<em>Claude Code • OpenCode • Codex • Gemini • 终端 —— 统一仪表盘 • 任意设备</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="License: MIT"></a>
|
||||
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-18%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 18+"></a>
|
||||
<a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-22%2B-22c55e?style=flat-square&logo=node.js&logoColor=white" alt="Node.js 22+"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
|
||||
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
|
||||
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
|
||||
@@ -34,19 +34,35 @@
|
||||
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
|
||||
```
|
||||
|
||||
该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。
|
||||
该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。几点须知:
|
||||
|
||||
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai) 或 [Codex](https://developers.openai.com/codex/cli)(任意组合均可)。安装完成后:
|
||||
- **先询问,后改动。** 所有系统级改动(安装软件包、下载 AI CLI)都会先征求确认;结束时的菜单可选择:直接在本终端运行、安装为后台服务(systemd/launchd,开机自启),或暂不启动。不选就不会有任何后台进程。
|
||||
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。
|
||||
- **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
|
||||
|
||||
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli)(任意组合均可)。安装器会自动检测这四个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后:
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
# 打开 http://localhost:3000,开启你的第一个会话
|
||||
```
|
||||
|
||||
**想和小团队共用一台?** 改用多用户模式启动:每人拥有自己的登录与工作空间。
|
||||
|
||||
```bash
|
||||
codeman users add alice --admin # 创建第一个管理员账号
|
||||
codeman web --multiuser # 命名登录 + 按用户隔离的案例空间
|
||||
```
|
||||
|
||||
详见下文[多用户模式](#多用户模式可选启用)。
|
||||
|
||||
<details>
|
||||
<summary><strong>作为后台服务运行</strong></summary>
|
||||
|
||||
安装器结尾的菜单(选项 2)可以帮你完成这一步,并在宣告成功前校验服务确实已启动。如需手动配置:
|
||||
|
||||
**Linux(systemd):**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cat > ~/.config/systemd/user/codeman-web.service << EOF
|
||||
@@ -69,6 +85,7 @@ loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
**macOS(launchd):**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Library/LaunchAgents
|
||||
cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
|
||||
@@ -96,6 +113,7 @@ cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
|
||||
EOF
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -105,11 +123,79 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
|
||||
```
|
||||
|
||||
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai) 或 [Codex](https://developers.openai.com/codex/cli))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
|
||||
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 使用 Codeman —— 人类操作指南
|
||||
|
||||
从头到尾走一遍如何在浏览器里驾驭 Codeman。如果你刚装好,就从这里开始。
|
||||
|
||||
### 1. 启动服务器
|
||||
|
||||
```bash
|
||||
codeman web # localhost:3000(仅环回 —— 安全默认值)
|
||||
codeman web --port 8080 # 自定义端口(或设置 CODEMAN_PORT)
|
||||
codeman web --https # 自签名 TLS(仅远程访问时需要)
|
||||
codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_PASSWORD(见「安全」)
|
||||
```
|
||||
|
||||
打开打印出的 URL。整个页面是一个单一仪表盘;下面的一切都在这里完成。
|
||||
|
||||
### 2. 创建你的第一个会话
|
||||
|
||||
点击 **+ New Session**(或 **Quick Start**)。一个会话就是一个运行在自己 tmux 终端里的 AI CLI。你可以选择:
|
||||
|
||||
| 字段 | 作用 |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。 |
|
||||
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Gemini` 或 `Terminal`(普通 shell)。 |
|
||||
| **模型** | 每会话模型(App Settings → Claude Model)。软默认值 —— 会话内 `/model` 依然有效。 |
|
||||
| **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 |
|
||||
|
||||
点击启动 —— Codeman 通过真实 PTY 拉起 CLI,并经 SSE 流式传输到你的浏览器。
|
||||
|
||||
### 3. 读懂仪表盘
|
||||
|
||||
- **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序(标签顺序会跨设备同步)。
|
||||
- **终端(中央)** —— 真实的 `xterm.js` 终端;完整 TUI 正常渲染。直接输入并按 **Enter** 发送。`Shift+Enter` 插入换行。
|
||||
- **侧边面板** —— Respawn、Ralph、Orchestrator、Cron、Subagents、Settings(从工具栏切换)。
|
||||
|
||||
### 4. 与智能体对话
|
||||
|
||||
- **直接在终端输入提示** —— 即使跨越重连,输入也是精确一次送达(连接中断绝不会丢失或重复发送提示)。
|
||||
- **粘贴或拖放图片**,直接进入会话。
|
||||
- **语音输入** —— `Ctrl+Shift+V`(Deepgram Nova-3,自动静音停止)。
|
||||
- **附件** —— 注册外部文件/文档,并内联预览 Office/PDF。
|
||||
|
||||
### 5. 让它自主运行
|
||||
|
||||
| 模式 | 用途 | 位置 |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| **Respawn** | 长时间无人值守运行 —— 空闲/限额时自动重启 CLI,带自适应时序。预设:`solo-work`、`overnight-autonomous` 等 | Respawn 标签页 |
|
||||
| **Ralph / Todo** | 一个自驱循环,跟踪 todo 列表并持续工作直到完成。 | Ralph 标签页 |
|
||||
| **Orchestrator** | 把一个目标变成分阶段计划,并跨多个智能体推动完成。 | 编排器面板 |
|
||||
| **Cron** | 已保存的、命名的定时任务(`once`/`interval`/`daily`/`weekly`),到期时拉起会话并发送提示。 | ⏰ Cron 按钮(可选启用:App Settings → Display → Header Displays) |
|
||||
| **Auto-resume** | 订阅限额重置后自动继续。 | Respawn 标签页(顶部) |
|
||||
|
||||
### 6. 随时随地访问
|
||||
|
||||
- **手机/平板** —— UI 完全触控优化;扫描桌面上的**二维码**即可免密码登录。
|
||||
- **网络之外** —— `./scripts/tunnel.sh start` 打开一条 Cloudflare 隧道(先设置 `CODEMAN_PASSWORD`)。
|
||||
- **SSH** —— `sc` 选择器可从终端附着任意会话(`sc` 交互式,`sc 2` 快速附着,`sc -l` 列表)。
|
||||
|
||||
### 7. 运维与维护
|
||||
|
||||
- **App Settings** —— 模型、effort、权限启动模式、主题/皮肤、通知、显示开关、各 CLI 的专属选项,以及跨设备同步的自定义显示名称和按设备保存的英文/简体中文界面语言。
|
||||
- **自更新** —— git-clone 安装可在 **Settings → Updates** 中原地更新。
|
||||
- **部署你自己的改动** —— 见[开发](#开发)。
|
||||
|
||||
> ⚠️ **安全提示:** 如果你正在 Codeman 受管会话*内部*工作(`echo $CODEMAN_MUX` → `1`),绝不要直接运行 `tmux kill-session` / `pkill claude` —— 请使用 Web UI 或 `./scripts/tmux-manager.sh`。
|
||||
|
||||
---
|
||||
|
||||
## 移动端优化的 Web UI
|
||||
|
||||
在任意手机上都能获得最跟手的 AI 编程智能体体验。完整的 xterm.js 终端、本地回显、滑动导航,以及为真正的远程办公而设计的触控优化界面 —— 而不是把桌面 UI 硬塞进小屏幕。
|
||||
@@ -216,7 +302,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
|
||||
```
|
||||
|
||||
- **多层空闲检测** —— 完成消息、AI 驱动的空闲检查、输出静默、token 稳定性
|
||||
- **用量限额自动恢复**(*可选,默认关闭*)—— 当 Claude 因订阅用量限额而停止("You've hit your limit · resets 3pm")时,Codeman 会解析重置时间,等到限额刷新(外加 2 分钟安全缓冲)后自动关闭限额对话框并发送 `continue`,让通宵任务平稳跨过 5 小时窗口而不是停摆到早晨。可识别 Claude Code 各版本的全部限额消息格式;若仍受限会自动重试;计划在 Codeman 重启后依然生效;暂停期间会阻止重生循环,避免 `/clear` 清掉等待中的对话。在会话 Respawn 标签页顶部按会话启用
|
||||
- **用量限额自动恢复**(_可选,默认关闭_)—— 当 Claude 因订阅用量限额而停止("You've hit your limit · resets 3pm")时,Codeman 会解析重置时间,等到限额刷新(外加 2 分钟安全缓冲)后自动关闭限额对话框并发送 `continue`,让通宵任务平稳跨过 5 小时窗口而不是停摆到早晨。可识别 Claude Code 各版本的全部限额消息格式;若仍受限会自动重试;计划在 Codeman 重启后依然生效;暂停期间会阻止重生循环,避免 `/clear` 清掉等待中的对话。在会话 Respawn 标签页顶部按会话启用
|
||||
- **熔断器** —— 当 Claude 卡住时防止重生抖动(CLOSED → HALF_OPEN → OPEN 状态,跟踪连续无进展与重复错误)
|
||||
- **健康评分** —— 0–100 健康分,分项涵盖循环成功率、熔断器状态、迭代进展与卡死恢复
|
||||
- **内置预设** —— `solo-work`(3s 空闲,60min)、`subagent-workflow`(45s,240min)、`team-lead`(90s,480min)、`ralph-todo`(8s,480min)、`overnight-autonomous`(10s,480min)
|
||||
@@ -249,6 +335,14 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
|
||||
|
||||
每个会话都运行在 **tmux** 内 —— 会话可在服务器重启、网络中断与机器休眠后存续。启动时自动恢复,具备双重冗余。幽灵会话发现机制能找到孤立的 tmux 会话。受管会话带有环境标签,因此智能体不会杀掉自己的会话。
|
||||
|
||||
### 会话管理器与命令面板
|
||||
|
||||
`Ctrl/Cmd/Alt+K` 打开模糊搜索的会话面板;**Browse all sessions** 打开会话管理器:一份去重后的完整清单,涵盖 Codeman 所知的一切(活动会话、来自状态与生命周期历史的既往会话,以及 Claude 转录),每一行都显示其第一条与最近一条提示。
|
||||
|
||||
- **置顶(Pin)**:把会话固定到列表顶部。被置顶的会话甚至能挺过被杀掉(降级为一条轻量的已停止记录,依然可见、可恢复)。
|
||||
- **名称保留**:从会话管理器恢复既往会话时保留其原有名称,而不是生成一个新名称。
|
||||
- **跨设备标签顺序**:拖拽排序的标签顺序保存在服务端,你的排列会从桌面跟随到手机。
|
||||
|
||||
### 主机名感知的窗口标题
|
||||
|
||||
在多台主机上运行 Codeman(笔记本、开发机、NAS)?浏览器标签标题是 `codeman:<主机名>`,让你无需点进去就能分辨每个标签对应哪个后端:
|
||||
@@ -262,10 +356,10 @@ codeman web --title-hostname dev-box # codeman:dev-box(用于覆盖嘈
|
||||
|
||||
### 智能 Token 管理
|
||||
|
||||
| 阈值 | 动作 | 结果 |
|
||||
|-----------|--------|--------|
|
||||
| 阈值 | 动作 | 结果 |
|
||||
| --------------- | --------------- | ---------------------- |
|
||||
| **110k tokens** | 自动 `/compact` | 上下文被摘要,工作继续 |
|
||||
| **140k tokens** | 自动 `/clear` | 以 `/init` 全新开始 |
|
||||
| **140k tokens** | 自动 `/clear` | 以 `/init` 全新开始 |
|
||||
|
||||
### 通知
|
||||
|
||||
@@ -296,17 +390,70 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
||||
## 更多特性
|
||||
|
||||
- **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新)
|
||||
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode** 或 **Codex**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*` 与 `CODEX_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex** 或 **Gemini**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*` 与 `GEMINI_*`/`GOOGLE_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Docker 会话** —— 在隔离且加固的容器中运行案例。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一案例的多个会话共享一个容器;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||
- **远程 SSH 会话**:把案例指向另一台机器,让智能体在那里一个持久的远程 tmux 中运行:SSH 断连不中断任务、自动重连,还能发现并附着主机上已在运行的会话。详见 [`docs/remote-sessions.md`](docs/remote-sessions.md)
|
||||
- **Effort 与 Ultracode** —— 设置每会话的默认 effort(`low`–`max`),或启用 **ultracode**(动态多智能体工作流)。这些都只是软默认值 —— 会话中可随时用 `/effort` 切换。扩展思考预算也可配置
|
||||
- **语音输入** —— 用 Deepgram Nova-3 口述提示(带 Web Speech API 回退):切换录音、自动静音停止、实时音量表(`Ctrl+Shift+V`)
|
||||
- **图像输入** —— 直接把图片粘贴或拖放进会话
|
||||
- **手势控制** *(可选)* —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Display 启用
|
||||
- **多显示器横跨** *(macOS)* —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝
|
||||
- **手势控制** _(可选)_ —— 一个 MediaPipe 手部追踪叠加层,可徒手抓取/拖动会话窗口并捏合按钮。用 `CODEMAN_GESTURE=1` + App Settings → Display 启用
|
||||
- **多显示器横跨** _(macOS)_ —— 一键打开一个横跨所有显示器最大化的浏览器窗口,让浮动的智能体/手势面板可以跨越物理拼接缝
|
||||
- **文件查看器按钮** _(可选)_ —— 头部新增一个按钮,一键切换内置文件浏览器面板;在 App Settings → Display → Header Displays 中启用
|
||||
- **CJK / 输入法支持** —— 完整支持中文 / 日文 / 韩文的组合输入
|
||||
- **操作系统通知与主机名感知标题** —— 桌面提醒与标签标题以 `codeman:<host>` 为前缀,使多主机配置不再含糊
|
||||
|
||||
---
|
||||
|
||||
## 隔离的 Docker 会话
|
||||
|
||||
让案例(case)运行在专属的加固 Docker 容器里,而不是直接跑在主机上:获得安全隔离、可复现的工具链和一键可移植性。
|
||||
|
||||
- **一键启动** —— 在 **New Case → Create New** 中勾选 **🐳 Run in an isolated Docker container**。Codeman 会创建案例文件夹、用默认设置启动容器,并在容器内启动智能体。无需填写任何主机/镜像/网络字段。
|
||||
- **资源模板** —— 展开复选框可选 **Small / Medium / Large / GPU** 预设(内存、CPU、GPU),也可以完全自定义。**磁盘是弹性的** —— 存储随数据增长,没有固定上限。
|
||||
- **按案例共享容器** —— 多个会话可以 `docker exec` 进同一个容器;结束某个会话绝不会影响其他会话所在的容器。
|
||||
- **默认加固** —— 非 root、`--cap-drop ALL`、`no-new-privileges`、PID/内存上限,绝不使用 `--privileged` 或 docker socket;**密封(sealed)** 配置(不注入主机凭据、关闭网络)只需一个开关。
|
||||
- **无感认证、凭据隔离** —— 主机上的 Claude / Codex / Gemini / OpenCode 登录在容器内开箱即用:凭据在启动时以只读种子方式复制注入,onboarding/信任提示已预先答复,不会弹出登录向导。容器保留自己的副本,绝不回写主机的凭据存储;跨边界共享的只有对话转录,导出文件也绝不包含机密。
|
||||
- **迁移到另一台机器** —— 把容器的完整环境(工具链 + 工作区)导出为可移植的 `.tar.gz`,在另一台机器上导入到新案例即可继续。
|
||||
- **持久耐用** —— Codeman 重启后重连会回到同一个存活的智能体;容器停止/重启后则从绑定挂载的转录恢复对话。
|
||||
|
||||
前置条件:只需 Docker(或 Podman)。智能体基础镜像会在首次使用时自动构建,构建进度实时显示在 UI 中(也可用 `node scripts/build-agent-image.mjs` 预构建)。完整指南:[`docs/docker-cases.md`](docs/docker-cases.md)。
|
||||
|
||||
---
|
||||
|
||||
## 远程 SSH 会话
|
||||
|
||||
把案例(case)指向另一台机器,通过 SSH 让智能体**在那台机器上**运行,同时保留同样的仪表盘、移动端 UI 与自主运行特性。你的笔记本只是一扇窗口,会话本体活在远程主机上。
|
||||
|
||||
- **天生持久**:智能体运行在远程主机上一个专用的 tmux 会话里,SSH 断连、网络切换或笔记本休眠都不会中断任务。重新连接后回到同一个活跃对话。
|
||||
- **自动重连**:一个带上限退避的监视器发现 SSH 面板断开后,会静默重新附着到仍在运行的远程会话(设置中有总开关;主动杀掉的会话绝不会被复活)。
|
||||
- **发现与附着**:列出主机上已在运行的 `codeman-*` 会话(由那台机器自己的 Codeman 或其他操作者启动)并附着其一。非你所有的已附着会话在关闭标签时**只分离,绝不杀掉**。
|
||||
- **共享会话**:多个客户端可以以不同窗口尺寸同时附着同一个远程会话而互不挤压;发现列表会显示带客户端计数的「shared」徽标。
|
||||
- **注入安全**:所有 ssh 命令行都经由单一的 shell 转义构建器生成,主机/路径/身份文件字段均有模式校验。
|
||||
|
||||
在 **New Case → Remote** 中配置(主机、用户、身份文件、可选跳板机)。完整设计:[`docs/remote-sessions.md`](docs/remote-sessions.md)。
|
||||
|
||||
---
|
||||
|
||||
## 多用户模式(可选启用)
|
||||
|
||||
与一个小型互信团队共享同一个 Codeman,每人拥有自己的登录与工作空间。**默认关闭**:不加该开关时,行为与单用户完全一致。
|
||||
|
||||
用 `codeman web --multiuser`(或 `CODEMAN_MULTIUSER=1`)启用。创建第一个管理员后,可通过 CLI 或 App Settings 中的 **Users** 标签页管理用户:
|
||||
|
||||
```bash
|
||||
codeman users add alice --admin # 提示输入密码(或 --password-stdin)
|
||||
codeman users add bob # 普通用户
|
||||
codeman users list
|
||||
```
|
||||
|
||||
- **按用户的空间**:每个用户的案例位于 `~/codeman-users/<name>/cases`;会话、案例、搜索与实时事件都按属主隔离。管理员可以看到全部。
|
||||
- **可单独吊销的登录**:命名用户的密码以 scrypt 哈希保存在 `~/.codeman/users.json`;可随时禁用、重置(一次性密码)或删除账号。管理员操作审计记录在 `~/.codeman/admin-audit.jsonl`。
|
||||
- **普通用户的更安全默认值**:非管理员以 `--permission-mode auto` 运行 Claude(Anthropic 的分类器护栏模式);raw shell 会话、cron `launchCommand` 与跳过权限模式需要按用户显式授权。
|
||||
|
||||
> ⚠️ **这只是工作空间的划分,不是用户之间的沙箱。** 所有会话都以同一个操作系统账户运行,因此有心用户的智能体依然能触及他人的文件。若需要真正的隔离,请结合 **Docker 案例**,或在不同的操作系统账户下运行独立实例。参见 [`docs/multi-user-plan.md`](docs/multi-user-plan.md) 与 [`docs/security-architecture.md`](docs/security-architecture.md) 的多用户章节。
|
||||
|
||||
---
|
||||
|
||||
## 远程访问 —— Cloudflare 隧道
|
||||
|
||||
使用免费的 [Cloudflare 快速隧道](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/),从手机或本地网络外的任意设备访问 Codeman —— 无需端口转发、无需 DNS、无需静态 IP。
|
||||
@@ -374,14 +521,14 @@ loginctl enable-linger $USER
|
||||
|
||||
该设计参考了 ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin)(USENIX Security 2025),该研究发现 Top-100 网站中有 47 个因横跨 42 个 CVE 的 6 个关键设计缺陷而易受二维码认证攻击。Codeman 全部六个都做了应对:
|
||||
|
||||
| USENIX 缺陷 | 缓解措施 |
|
||||
|-------------|------------|
|
||||
| **缺陷 1**:缺少一次性强制 | 令牌首次扫描即原子性消费 —— 重放永远失败 |
|
||||
| **缺陷 2**:长生命周期令牌 | 60s TTL + 90s 宽限,由定时器自动轮换 |
|
||||
| **缺陷 3**:可预测的令牌生成 | `crypto.randomBytes(32)` —— 256 位熵。短码采用拒绝采样以消除取模偏差 |
|
||||
| **缺陷 4**:客户端令牌生成 | 仅服务端 —— 令牌在嵌入二维码前绝不离开服务器 |
|
||||
| **缺陷 5**:缺少状态通知 | 桌面提示:*「设备 [IP] 已通过二维码认证(Safari)。不是你?[吊销]」* —— 实时 QRLjacking 检测 |
|
||||
| **缺陷 6**:会话绑定不足 | 存储 IP + User-Agent 以供审计。通过 API 手动吊销会话。HttpOnly + Secure + SameSite=lax cookie |
|
||||
| USENIX 缺陷 | 缓解措施 |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| **缺陷 1**:缺少一次性强制 | 令牌首次扫描即原子性消费 —— 重放永远失败 |
|
||||
| **缺陷 2**:长生命周期令牌 | 60s TTL + 90s 宽限,由定时器自动轮换 |
|
||||
| **缺陷 3**:可预测的令牌生成 | `crypto.randomBytes(32)` —— 256 位熵。短码采用拒绝采样以消除取模偏差 |
|
||||
| **缺陷 4**:客户端令牌生成 | 仅服务端 —— 令牌在嵌入二维码前绝不离开服务器 |
|
||||
| **缺陷 5**:缺少状态通知 | 桌面提示:_「设备 [IP] 已通过二维码认证(Safari)。不是你?[吊销]」_ —— 实时 QRLjacking 检测 |
|
||||
| **缺陷 6**:会话绑定不足 | 存储 IP + User-Agent 以供审计。通过 API 手动吊销会话。HttpOnly + Secure + SameSite=lax cookie |
|
||||
|
||||
#### 时序安全的查找
|
||||
|
||||
@@ -406,23 +553,23 @@ URL 被刻意保持精简(`/q/` 路径 + 6 字符码 ≈ 53–56 个字符)
|
||||
|
||||
#### 威胁覆盖
|
||||
|
||||
| 威胁 | 为何无效 |
|
||||
|--------|-------------------|
|
||||
| **二维码截图被分享** | 一次性:首次扫描即消费。60s TTL:攻击者动手前已过期。桌面通知会立即提醒你。 |
|
||||
| **重放攻击** | 原子性一次性消费 + 60s TTL。旧 URL 始终返回 401。 |
|
||||
| 威胁 | 为何无效 |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------ |
|
||||
| **二维码截图被分享** | 一次性:首次扫描即消费。60s TTL:攻击者动手前已过期。桌面通知会立即提醒你。 |
|
||||
| **重放攻击** | 原子性一次性消费 + 60s TTL。旧 URL 始终返回 401。 |
|
||||
| **Cloudflare 边缘日志** | 短码是不透明的 6 字符查找键,而非真正的 256 位令牌。一次性意味着从日志重放永远失败。 |
|
||||
| **暴力破解** | 568 亿种组合、任意时刻约 2 个有效、双层速率限制,早在统计可行性之前就已拦截。 |
|
||||
| **QRLjacking** | 60s 轮换迫使实时转发。桌面提示提供即时检测。自托管单用户场景使钓鱼难以成立。 |
|
||||
| **时序攻击** | 基于哈希的 Map 查找 —— 无字符串比较时序泄露。 |
|
||||
| **会话 cookie 窃取** | HttpOnly + Secure + SameSite=lax + 24h TTL。可在 `POST /api/auth/revoke` 手动吊销。 |
|
||||
| **暴力破解** | 568 亿种组合、任意时刻约 2 个有效、双层速率限制,早在统计可行性之前就已拦截。 |
|
||||
| **QRLjacking** | 60s 轮换迫使实时转发。桌面提示提供即时检测。自托管单用户场景使钓鱼难以成立。 |
|
||||
| **时序攻击** | 基于哈希的 Map 查找 —— 无字符串比较时序泄露。 |
|
||||
| **会话 cookie 窃取** | HttpOnly + Secure + SameSite=lax + 24h TTL。可在 `POST /api/auth/revoke` 手动吊销。 |
|
||||
|
||||
#### 横向对比
|
||||
|
||||
| 平台 | 模型 | 对比 |
|
||||
|----------|-------|------------|
|
||||
| **Discord** | 长生命周期令牌、无确认、[屡被利用](https://owasp.org/www-community/attacks/Qrljacking) | Codeman:一次性 + TTL + 通知 |
|
||||
| **WhatsApp Web** | 手机确认「关联设备?」,约 60s 轮换 | 轮换相当;WhatsApp 额外加了显式确认(对单用户而言是可接受的取舍) |
|
||||
| **Signal** | 临时公钥、端到端加密信道 | 加密更强,但 [2025 年仍被俄罗斯国家级行为者](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger)通过社会工程攻破 |
|
||||
| 平台 | 模型 | 对比 |
|
||||
| ---------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Discord** | 长生命周期令牌、无确认、[屡被利用](https://owasp.org/www-community/attacks/Qrljacking) | Codeman:一次性 + TTL + 通知 |
|
||||
| **WhatsApp Web** | 手机确认「关联设备?」,约 60s 轮换 | 轮换相当;WhatsApp 额外加了显式确认(对单用户而言是可接受的取舍) |
|
||||
| **Signal** | 临时公钥、端到端加密信道 | 加密更强,但 [2025 年仍被俄罗斯国家级行为者](https://cloud.google.com/blog/topics/threat-intelligence/russia-targeting-signal-messenger)通过社会工程攻破 |
|
||||
|
||||
> 完整设计理由、安全分析与实现细节:[`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
|
||||
|
||||
@@ -430,13 +577,14 @@ URL 被刻意保持精简(`/q/` 路径 + 6 字符码 ≈ 53–56 个字符)
|
||||
|
||||
## 安全
|
||||
|
||||
Codeman 用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设计上对任何能访问到它的人都是一个远程代码执行面 —— 整套安全模型的存在就是为了控制*谁*能访问。近期加固(v0.9.0 + v0.9.5)封堵了那些常困扰自托管开发工具的浏览器驱动攻击路径。完整模型:[`docs/security-architecture.md`](docs/security-architecture.md)。
|
||||
Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设计上对任何能访问到它的人都是一个远程代码执行面 —— 整套安全模型的存在就是为了控制*谁*能访问。(启动权限模式可配置,见下文。)近期加固(v0.9.0 + v0.9.5)封堵了那些常困扰自托管开发工具的浏览器驱动攻击路径。完整模型:[`docs/security-architecture.md`](docs/security-architecture.md)。**发现了漏洞?** 私下披露方式与已知限制清单见 [`SECURITY.md`](SECURITY.md)。
|
||||
|
||||
### 网络与访问
|
||||
|
||||
- **默认仅环回** —— 绑定 `127.0.0.1`,仅可从本机访问,因此「无密码」默认配置开箱即安全。在未设置 `CODEMAN_PASSWORD` 的情况下绑定非环回主机会*启动但打印一条醒目警告*,并给出三个具体修复方案(设置密码、环回 + 一个带认证的隧道,或用 `--allow-unauthenticated-network` 显式确认)
|
||||
- **可选认证,真实会话** —— 通过 `CODEMAN_USERNAME`(默认 `admin`)/ `CODEMAN_PASSWORD` 的 HTTP Basic 认证。成功后签发一个不透明的 256 位 `codeman_session` cookie(`randomBytes(32)`)—— 服务端校验,而非客户端签名,因此无法离线伪造(24h TTL、自动延长、设备上下文审计日志)
|
||||
- **按 IP 速率限制** —— 失败 10 次 → `429` 并带 `Retry-After`(15 分钟衰减)。即便攻击者在同一 IP 上猛攻,有效 cookie 或正确密码也能*立即*恢复 —— 这很重要,因为所有隧道流量共享同一个环回 IP。二维码认证有自己独立的限制器
|
||||
- **可配置的权限模式**:`--dangerously-skip-permissions` 只是默认值。**App Settings → Claude CLI → Startup Mode** 可以把新会话切换为 Anthropic 的分类器护栏 `auto` 模式(低打扰,需要 Claude Code 2.1.207+)、`normal` 提示模式,或一份显式的允许工具列表。多用户模式下,未获授权的用户会被强制为 `auto`,shell 会话与跳过权限需要按用户显式授权
|
||||
|
||||
### 始终开启的浏览器加固(v0.9.5)
|
||||
|
||||
@@ -450,7 +598,7 @@ Codeman 用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设
|
||||
|
||||
### 输入、文件与响应头
|
||||
|
||||
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置
|
||||
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置
|
||||
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 50 MB 原始与下载;`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行
|
||||
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
|
||||
|
||||
@@ -481,73 +629,180 @@ sc -l # 列出会话
|
||||
|
||||
> Ctrl 绑定在 macOS 上也接受 Cmd。
|
||||
|
||||
| 快捷键 | 动作 |
|
||||
|----------|--------|
|
||||
| `Ctrl/Cmd+W` | 杀掉当前会话 |
|
||||
| `Ctrl/Cmd+Tab` | 下一个会话 |
|
||||
| `Alt+1`–`Alt+9` | 切换到第 N 个标签 |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | 将当前标签左移 / 右移 |
|
||||
| `Ctrl/Cmd+L` | 清屏 |
|
||||
| `Ctrl+Shift+R` | 恢复终端尺寸 |
|
||||
| `Ctrl+Shift+V` | 切换语音输入 |
|
||||
| `Ctrl/Cmd +` / `-` | 字体大小 |
|
||||
| `Ctrl/Cmd+?` | 键盘帮助 |
|
||||
| `Shift+Enter` | 插入换行(发送到终端) |
|
||||
| `Escape` | 关闭面板与模态框 |
|
||||
| 快捷键 | 动作 |
|
||||
| ------------------------------- | -------------------------------------------------------- |
|
||||
| `Ctrl/Cmd+W` | 杀掉当前会话 |
|
||||
| `Ctrl/Cmd/Option+K` | 查找已打开的会话或新建一个 |
|
||||
| `Ctrl/Cmd+Tab` | 下一个会话 |
|
||||
| `Alt/Option+[` / `Alt/Option+]` | 上一个 / 下一个会话 |
|
||||
| `Alt/Option+1`–`Alt/Option+9` | 切换到第 N 个标签(按物理键位,macOS Option 布局也适用) |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | 将当前标签左移 / 右移 |
|
||||
| `Ctrl/Cmd+L` | 清屏 |
|
||||
| `Ctrl+Shift+R` | 恢复终端尺寸 |
|
||||
| `Ctrl+Shift+V` | 切换语音输入 |
|
||||
| `Ctrl/Cmd +` / `-` | 字体大小 |
|
||||
| `Ctrl/Cmd+?` | 键盘帮助 |
|
||||
| `Shift+Enter` | 插入换行(发送到终端) |
|
||||
| `Escape` | 关闭面板与模态框 |
|
||||
|
||||
---
|
||||
|
||||
## 从智能体驱动 Codeman —— 编程指南
|
||||
|
||||
面向不经浏览器控制 Codeman 的 AI 智能体与自动化:一个拉起工作会话的智能体、一个 CI 机器人,或是**运行在 Codeman 会话*内部*、编排其他会话的 Claude Code**。UI 能做的一切都是 HTTP + CLI,因此智能体也能做。
|
||||
|
||||
### 检测自己身处 Codeman 内部
|
||||
|
||||
当 CLI 运行在 Codeman 受管会话中时,以下环境变量会被设置 —— 读取它们,别硬编码任何东西:
|
||||
|
||||
| 变量 | 含义 |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `CODEMAN_MUX=1` | 你在一个受管 tmux 会话里。**绝不要** `tmux kill-session` / `pkill claude` / `pkill tmux` —— 你会杀掉自己或兄弟会话。 |
|
||||
| `CODEMAN_API_URL` | API 的基础 URL(例如 `https://127.0.0.1:3000`)。下面每个调用都用它。 |
|
||||
| `CODEMAN_SESSION_ID` | *你自己的*会话 id。用它避免对自己下手。 |
|
||||
| `CODEMAN_HOOK_SECRET_FILE` | hook 密钥文件的路径(受管隧道开启时调用 `/api/hook-event` 必需)。 |
|
||||
|
||||
### 行路规则(POST 之前先读)
|
||||
|
||||
1. **只发单行输入。** 编程输入会作为字面文本 **+ Enter** 一次性发送。多行字符串会破坏智能体 TUI(Ink)—— 发送一行,或拆成多次调用。
|
||||
2. **让输入幂等。** 在 `POST …/input` 上带上稳定的 `clientId` 和按会话单调递增的 `seq`。服务端会去重,因此连接中断后的重试不会重复投递提示。
|
||||
3. **认证。** 若设置了 `CODEMAN_PASSWORD`,发送 HTTP Basic 认证(用户 `admin` 或 `CODEMAN_USERNAME`)或 `codeman_session` cookie。默认的环回安装无密码。缺失的 `Origin` 头被允许,因此普通 `curl` 可用;跨站的浏览器 origin 会被拒绝(CSRF 防护)。
|
||||
4. **响应信封。** 多数端点返回 `{ "success": true, "data": … }`(错误:`{ "success": false, "error", "errorCode" }`)。少数遗留 GET 返回裸响应体 —— **两种都要处理**(`body.data ?? body`)。
|
||||
5. **`/api/v1/*`** 是 `/api/*` 的稳定别名。
|
||||
|
||||
### 常用配方
|
||||
|
||||
```bash
|
||||
API="${CODEMAN_API_URL:-http://127.0.0.1:3000}"
|
||||
# (若设置了密码,给每个调用加上 -u admin:"$CODEMAN_PASSWORD")
|
||||
|
||||
# 1. 看看有什么在运行
|
||||
curl -s "$API/api/sessions" | jq '.data // .'
|
||||
|
||||
# 2. 拉起一个工作会话(「case」= 命名工作目录)
|
||||
curl -s -X POST "$API/api/quick-start" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"refactor-auth","mode":"claude","effort":"high"}' | jq
|
||||
|
||||
# 3. 向会话发送提示(精确一次:clientId + seq)
|
||||
curl -s -X POST "$API/api/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"input":"Run the test suite and summarize failures","useMux":true,"clientId":"agent-1","seq":1}'
|
||||
|
||||
# 4. 读回终端内容
|
||||
curl -s "$API/api/sessions/$SID/output" | jq -r '.data // .'
|
||||
|
||||
# 5. 流式接收实时事件(会话输出、智能体活动、状态)
|
||||
curl -sN "$API/api/events" # Server-Sent Events
|
||||
|
||||
# 6. 调度周期性工作(cron 风格任务)
|
||||
curl -s -X POST "$API/api/cron/jobs" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"nightly-deps","agentType":"claude","workingDir":"/home/me/proj",
|
||||
"promptMode":"inline_text","promptText":"Update dependencies and open a PR",
|
||||
"inputMode":"typed","scheduleType":"daily","dailyTime":"03:00",
|
||||
"enabled":true,"concurrencyPolicy":"warn_only"}' | jq
|
||||
|
||||
# 7. 查看后台子智能体及其活动记录
|
||||
curl -s "$API/api/subagents" | jq '.data // .'
|
||||
curl -s "$API/api/subagents/$AID/transcript" | jq -r '.data // .'
|
||||
|
||||
# 8. 全系统快照(会话、设置、重生、统计)
|
||||
curl -s "$API/api/status" | jq
|
||||
```
|
||||
|
||||
### 或使用内置 CLI
|
||||
|
||||
同样的操作也有命令形式(`codeman <cmd>`,括号内为别名)—— 在会话内的 shell 工具里很顺手:
|
||||
|
||||
```bash
|
||||
codeman session start -d /path/to/repo # (s) 启动会话
|
||||
codeman session list # 列出会话
|
||||
codeman session logs <id> # 查看输出
|
||||
codeman task add "fix the failing test" # (t) 排入任务
|
||||
codeman ralph start --min-hours 8 # (r) 启动自主循环
|
||||
codeman attach <path> # 附着 Claude hook 上下文
|
||||
```
|
||||
|
||||
### Hook(事件*回流*到 Codeman)
|
||||
|
||||
Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission_prompt`、`idle_prompt`、`stop`、`task_completed` 等),让仪表盘实时响应。该端点在环回上免认证,但在受管隧道下需要 `X-Codeman-Hook-Secret` 头(从 `$CODEMAN_HOOK_SECRET_FILE` 读取)。通常你不需要手动调用它 —— Codeman 会自动接好 —— 但自主层正是靠它「看见」智能体在做什么。
|
||||
|
||||
> 完整端点列表与请求/响应形状见下文。
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
基于 Fastify 的 REST —— **15 个路由模块中约 140 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。以下是一个有代表性的子集:
|
||||
基于 Fastify 的 REST —— **20 个路由模块中约 190 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。所有响应都使用 `ApiResponse<T>` 信封(`{success, data}` / `{success, error, errorCode}`);`/api/v1/*` 是稳定别名。以下是一个有代表性的子集:
|
||||
|
||||
### 会话(Sessions)
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/sessions` | 列出全部 |
|
||||
| `POST` | `/api/quick-start` | 创建 case 并启动会话 |
|
||||
| `DELETE` | `/api/sessions/:id` | 删除会话 |
|
||||
| `POST` | `/api/sessions/:id/input` | 发送输入 |
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| -------- | -------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `GET` | `/api/sessions` | 列出全部 |
|
||||
| `POST` | `/api/quick-start` | 创建 case + 启动会话(`{caseName?, mode?, effort?, envOverrides?}`) |
|
||||
| `POST` | `/api/sessions/:id/input` | 发送输入(`{input, useMux?, clientId?, seq?}` —— `clientId`+`seq` = 精确一次) |
|
||||
| `GET` | `/api/sessions/:id/output` | 读取终端输出 |
|
||||
| `GET` | `/api/sessions/unified` | 统一的活动 + 历史清单(会话管理器):`?q=&limit=` |
|
||||
| `POST` | `/api/sessions/:id/pin` | 在会话管理器中置顶 / 取消置顶(`{pinned}`) |
|
||||
| `PUT` | `/api/session-order` | 跨设备同步标签顺序(`{order: [ids]}`) |
|
||||
| `DELETE` | `/api/sessions/:id` | 删除会话 |
|
||||
|
||||
### 重生(Respawn)
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| ------ | ---------------------------------- | -------------------- |
|
||||
| `POST` | `/api/sessions/:id/respawn/enable` | 启用,带配置与定时器 |
|
||||
| `POST` | `/api/sessions/:id/respawn/stop` | 停止控制器 |
|
||||
| `PUT` | `/api/sessions/:id/respawn/config` | 更新配置 |
|
||||
| `POST` | `/api/sessions/:id/respawn/stop` | 停止控制器 |
|
||||
| `PUT` | `/api/sessions/:id/respawn/config` | 更新配置 |
|
||||
|
||||
### Ralph / Todo
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/sessions/:id/ralph-state` | 获取循环状态 + todos |
|
||||
| `POST` | `/api/sessions/:id/ralph-config` | 配置跟踪 |
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| ------ | -------------------------------- | -------------------- |
|
||||
| `GET` | `/api/sessions/:id/ralph-state` | 获取循环状态 + todos |
|
||||
| `POST` | `/api/sessions/:id/ralph-config` | 配置跟踪 |
|
||||
|
||||
### 编排器(Orchestrator)
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/orchestrator/start` | 从目标启动编排 |
|
||||
| `POST` | `/api/orchestrator/approve` | 批准生成的计划 |
|
||||
| `GET` | `/api/orchestrator/status` | 当前阶段 + 进度 |
|
||||
| `POST` | `/api/orchestrator/stop` | 停止并清理 |
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| ------ | --------------------------- | --------------- |
|
||||
| `POST` | `/api/orchestrator/start` | 从目标启动编排 |
|
||||
| `POST` | `/api/orchestrator/approve` | 批准生成的计划 |
|
||||
| `GET` | `/api/orchestrator/status` | 当前阶段 + 进度 |
|
||||
| `POST` | `/api/orchestrator/stop` | 停止并清理 |
|
||||
|
||||
### Cron(定时任务)
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| ---------------- | ---------------------------- | --------------------- |
|
||||
| `GET` / `POST` | `/api/cron/jobs` | 列出 / 创建 cron 任务 |
|
||||
| `PUT` / `DELETE` | `/api/cron/jobs/:id` | 更新 / 删除任务 |
|
||||
| `PUT` | `/api/cron/jobs/:id/enabled` | 启用 / 禁用 |
|
||||
| `POST` | `/api/cron/jobs/:id/run` | 立即运行 |
|
||||
| `GET` | `/api/cron/jobs/:id/runs` | 运行历史 |
|
||||
|
||||
### 子智能体(Subagents)
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/subagents` | 列出所有后台智能体 |
|
||||
| `GET` | `/api/subagents/:id` | 智能体信息与状态 |
|
||||
| `GET` | `/api/subagents/:id/transcript` | 完整活动记录 |
|
||||
| `DELETE` | `/api/subagents/:id` | 杀掉智能体进程 |
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| -------- | ------------------------------- | ------------------ |
|
||||
| `GET` | `/api/subagents` | 列出所有后台智能体 |
|
||||
| `GET` | `/api/subagents/:id` | 智能体信息与状态 |
|
||||
| `GET` | `/api/subagents/:id/transcript` | 完整活动记录 |
|
||||
| `DELETE` | `/api/subagents/:id` | 杀掉智能体进程 |
|
||||
|
||||
### 系统(System)
|
||||
| 方法 | 端点 | 说明 |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/events` | SSE 流 |
|
||||
| `GET` | `/api/status` | 完整应用状态 |
|
||||
| `POST` | `/api/hook-event` | Hook 回调 |
|
||||
| `GET` | `/api/system/update/check` | 检查新发行版 |
|
||||
| `POST` | `/api/system/update` | 自更新(git-clone 安装) |
|
||||
| `POST` | `/api/clipboard` | 把文本推送到所有已连接浏览器(`{text}`) |
|
||||
| `GET` | `/api/sessions/:id/run-summary` | 时间线 + 统计 |
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| ------ | ------------------------------- | ---------------------------------------- |
|
||||
| `GET` | `/api/events` | SSE 流 |
|
||||
| `GET` | `/api/status` | 完整应用状态 |
|
||||
| `POST` | `/api/hook-event` | Hook 回调 |
|
||||
| `GET` | `/api/system/update/check` | 检查新发行版 |
|
||||
| `POST` | `/api/system/update` | 自更新(git-clone 安装) |
|
||||
| `POST` | `/api/clipboard` | 把文本推送到所有已连接浏览器(`{text}`) |
|
||||
| `GET` | `/api/sessions/:id/run-summary` | 时间线 + 统计 |
|
||||
|
||||
---
|
||||
|
||||
@@ -582,7 +837,7 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph External["外部"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex</small>"]
|
||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Gemini</small>"]
|
||||
BG["后台智能体<br/><small>(Task 工具)</small>"]
|
||||
end
|
||||
end
|
||||
@@ -614,7 +869,7 @@ flowchart TB
|
||||
npm install
|
||||
npx tsx src/index.ts web # 开发模式
|
||||
npm run build # 生产构建
|
||||
npm test # 运行测试
|
||||
npm run test:ci # 运行测试(CI 套件;浏览器套件需要额外环境)
|
||||
```
|
||||
|
||||
完整文档见 [CLAUDE.md](./CLAUDE.md)。
|
||||
@@ -625,14 +880,14 @@ npm test # 运行测试
|
||||
|
||||
本代码库经历了一次全面的 7 阶段重构,消除了上帝对象、集中了配置,并建立了模块化架构:
|
||||
|
||||
| 阶段 | 改了什么 | 影响 |
|
||||
|-------|-------------|--------|
|
||||
| **性能** | 缓存端点、SSE 自适应批处理、缓冲区分块 | 终端延迟低于 16ms |
|
||||
| **路由抽取** | `server.ts` 拆分为 15 个领域路由模块 + 认证中间件 + 端口接口 | server.ts 代码量 **−67%**(6,736 → 2,254) |
|
||||
| **领域拆分** | `types.ts` → 16 个领域文件、`ralph-tracker` → 7 个文件、`respawn-controller` → 5 个文件、`session` → 6 个文件 | 不再有上帝文件 |
|
||||
| **前端模块** | `app.js` → 18 个抽取模块,横跨基础设施、领域与特性层 | app.js 核心降至 **约 3.4K 行** |
|
||||
| **配置合并** | 约 70 个散落的魔法数字 → 10 个领域聚焦的配置文件 | 零跨文件重复 |
|
||||
| **测试基础设施** | 共享 mock 库、12 个路由测试文件、统一的 MockSession | 路由处理器可通过 `app.inject()` 测试 |
|
||||
| 阶段 | 改了什么 | 影响 |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
|
||||
| **性能** | 缓存端点、SSE 自适应批处理、缓冲区分块 | 终端延迟低于 16ms |
|
||||
| **路由抽取** | `server.ts` 拆分为 15 个领域路由模块 + 认证中间件 + 端口接口 | server.ts 代码量 **−67%**(6,736 → 2,254) |
|
||||
| **领域拆分** | `types.ts` → 16 个领域文件、`ralph-tracker` → 7 个文件、`respawn-controller` → 5 个文件、`session` → 6 个文件 | 不再有上帝文件 |
|
||||
| **前端模块** | `app.js` → 18 个抽取模块,横跨基础设施、领域与特性层 | app.js 核心降至 **约 3.4K 行** |
|
||||
| **配置合并** | 约 70 个散落的魔法数字 → 10 个领域聚焦的配置文件 | 零跨文件重复 |
|
||||
| **测试基础设施** | 共享 mock 库、12 个路由测试文件、统一的 MockSession | 路由处理器可通过 `app.inject()` 测试 |
|
||||
|
||||
完整细节:[`docs/archive/code-structure-findings.md`](docs/archive/code-structure-findings.md)
|
||||
|
||||
@@ -654,6 +909,10 @@ npm install xterm-zerolag-input
|
||||
|
||||
---
|
||||
|
||||
## 版本策略
|
||||
|
||||
Codeman 遵循 [SemVer](https://semver.org/)。版本号真正承诺的内容,以及哪些算内部实现(HTTP/SSE API、磁盘上的状态、实验性特性),都写在 [`docs/versioning-policy.md`](docs/versioning-policy.md) 中。如果你的脚本依赖 HTTP API,请锁定到确切版本。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT —— 见 [LICENSE](LICENSE)
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
# SPEEDRUN.md — Fast-execution protocol for Claude
|
||||
|
||||
Read this when the goal is **throughput**: get correct, verified work done with
|
||||
minimum ceremony. This does **not** relax correctness or the safety rules in
|
||||
`CLAUDE.md` — those still win. It removes _waste_, not _rigor_.
|
||||
|
||||
> Precedence: `CLAUDE.md` > explicit user instructions > this file. If anything
|
||||
> here conflicts with `CLAUDE.md`, `CLAUDE.md` wins.
|
||||
|
||||
---
|
||||
|
||||
## The mindset
|
||||
|
||||
- **Act, don't announce.** No "I'm going to now…" preamble. Do the thing, report
|
||||
the result.
|
||||
- **Cheapest proof that the change works.** Pick the smallest check that actually
|
||||
demonstrates correctness — not the biggest.
|
||||
- **Batch aggressively.** Independent reads, greps, and edits go in **one**
|
||||
message with parallel tool calls. Never serialize work that has no dependency.
|
||||
- **Momentum over perfection.** Land a correct increment, verify it, move on.
|
||||
Don't gold-plate untouched code.
|
||||
|
||||
---
|
||||
|
||||
## Loop (repeat until done)
|
||||
|
||||
1. **Orient once** — one parallel burst of reads/greps to load the context you
|
||||
need. Don't re-read files the harness says are already current.
|
||||
2. **Change** — make the edit(s). Batch independent edits.
|
||||
3. **Verify cheaply** — the smallest check that proves _this_ change (see below).
|
||||
4. **Advance** — next item. Only re-verify what you touched.
|
||||
5. **Stop** at: list empty, a hard blocker, or a decision that's genuinely the
|
||||
user's to make.
|
||||
|
||||
---
|
||||
|
||||
## Verification ladder — climb only as high as the change needs
|
||||
|
||||
| Change kind | Cheapest sufficient check |
|
||||
|-------------|---------------------------|
|
||||
| Types / signatures / imports | `tsc --noEmit` (or `--watch` already running) |
|
||||
| One module's logic | `npm test -- test/<file>.test.ts` (the **one** relevant file) |
|
||||
| A named behavior | `npm test -- -t "pattern"` |
|
||||
| Route/handler | `app.inject()` route test, or one `curl` against the running dev server |
|
||||
| Frontend render | Playwright load + assert (`waitUntil: 'domcontentloaded'`, wait 3–4s) |
|
||||
| Broad / pre-merge | `npm run test:ci` (the CI-equivalent sweep) |
|
||||
|
||||
**Hard rules (never skip, even in a rush):**
|
||||
- ⚠️ **Never run bare `npm test`** — it pulls in browser/visual suites that hang
|
||||
or fail locally. Always pass a file or `-t`, or use `test:ci`.
|
||||
- ⚠️ **Never COM without verifying the change actually works** first (curl the
|
||||
endpoint / Playwright the UI). "Compiles" ≠ "works".
|
||||
- ⚠️ **Session safety** — check `$CODEMAN_MUX`; never `tmux kill-session` /
|
||||
`pkill claude` in a managed session.
|
||||
- ⚠️ **Single-line prompts** for any programmatic session input.
|
||||
|
||||
---
|
||||
|
||||
## Speed tactics that pay off here
|
||||
|
||||
- **Parallel exploration**: dispatch `Explore` subagents (or one parallel grep
|
||||
burst) instead of serial file-by-file reading when scope is uncertain.
|
||||
- **`tsc --noEmit --watch`** in the background — instant type feedback, no repeat
|
||||
cold starts.
|
||||
- **Target one test file** — `fileParallelism: false` means the suite is serial;
|
||||
running one file is dramatically faster than the sweep.
|
||||
- **`curl localhost:3000/api/...`** beats spinning up a browser for backend
|
||||
checks. Reserve Playwright for actual UI rendering.
|
||||
- **Trust the harness** — if it says a file you just edited is current, don't
|
||||
re-Read it to "confirm". The Edit already succeeded or it would have errored.
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns (these masquerade as speed, but cost time)
|
||||
|
||||
- Running the full test suite to check a one-file change.
|
||||
- Re-reading files you already have in context.
|
||||
- Narrating a plan you're about to execute anyway.
|
||||
- Serial tool calls that have no dependency between them.
|
||||
- Claiming "done / fixed / passing" **before** running the check that proves it.
|
||||
- Deploying (COM) on green typecheck alone, without exercising the real flow.
|
||||
|
||||
---
|
||||
|
||||
## Stop-conditions (don't rush past these)
|
||||
|
||||
Stop and surface, don't guess, when you hit:
|
||||
- A **destructive / hard-to-reverse** action (delete, overwrite, force-push).
|
||||
- An **outward-facing** action (publishing, sending, deploying) not already
|
||||
authorized.
|
||||
- A **genuine product decision** the code can't answer.
|
||||
- A **failing verification you can't explain** — debug it (see
|
||||
`superpowers:systematic-debugging`), don't paper over it.
|
||||
|
||||
---
|
||||
|
||||
## Definition of done
|
||||
|
||||
A task is done when **all** hold:
|
||||
- The change is made.
|
||||
- The cheapest sufficient check **ran** and **passed** — evidence, not assertion.
|
||||
- No new type errors / lint errors introduced (`tsc --noEmit`, `npm run lint`).
|
||||
- You state plainly what was done and what proved it. If a step was skipped or a
|
||||
test failed, say so — don't hedge, don't overclaim.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Codeman agent base image (built locally by scripts/build-agent-image.mjs).
|
||||
#
|
||||
# Contains the agent toolchain (node + the CLIs + git/tmux/ripgrep) but NO
|
||||
# secrets: credentials are delivered at RUNTIME via bind mounts (~/.claude etc.)
|
||||
# or name-only `docker exec --env`, never baked in, so `docker save` exports stay
|
||||
# secret-free. tmux is a HARD prerequisite (the in-container tmux is what makes a
|
||||
# reconnect durable), so it is installed here and probed before launch.
|
||||
#
|
||||
# HOME is made writable by an ARBITRARY host uid via the OpenShift "gid 0,
|
||||
# group-writable" convention: on Linux we run `--user <hostUid>:0`, so the agent
|
||||
# uid is the host uid (workspace files stay host-owned) while gid 0 keeps $HOME
|
||||
# writable even though the uid is not the baked 1000.
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
# Base toolchain. `curl` is needed for the hook callbacks (`curl -sk $CODEMAN_API_URL`),
|
||||
# `procps` for `ps`, `tmux` for the durable in-container session.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
tmux \
|
||||
ripgrep \
|
||||
curl \
|
||||
ca-certificates \
|
||||
less \
|
||||
procps \
|
||||
openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# The agent CLIs (all four backends Codeman supports). Pinning is left to the
|
||||
# rebuild cadence (see docs/docker-cases-plan.md, user-decision 2).
|
||||
RUN npm install -g \
|
||||
@anthropic-ai/claude-code \
|
||||
@openai/codex \
|
||||
@google/gemini-cli \
|
||||
opencode-ai \
|
||||
&& npm cache clean --force
|
||||
|
||||
# `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is
|
||||
# auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at
|
||||
# runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid
|
||||
# only matters for a hand-run / Docker Desktop container. gid 0 + group-writable
|
||||
# HOME (OpenShift arbitrary-uid convention) keeps $HOME writable for any uid.
|
||||
# UTF-8 locale so tmux/Ink render Unicode box-drawing instead of VT100 ACS `q`
|
||||
# glyphs (C.UTF-8 is built into glibc; no locales package needed). Codeman also
|
||||
# sets these at run time so containers built before this line still get UTF-8.
|
||||
ENV LANG=C.UTF-8 LC_ALL=C.UTF-8
|
||||
ENV HOME=/home/agent
|
||||
# `.claude` (+ `.claude/projects` mount point) and `.codex` (+ `.codex/sessions`) are
|
||||
# pre-created gid-0 group-writable so the container owns its OWN credential config
|
||||
# dirs: tokens/settings/config are seeded in as writable copies and each CLI's runtime
|
||||
# state (backups, tasks, refreshed tokens) stays container-local, while ONLY the shared
|
||||
# transcript/rollout dirs (`.claude/projects`, `.codex/sessions`) are bind-mounted from
|
||||
# the host. (gemini/gcloud/opencode are whole seed-copies and need no pre-created dir.)
|
||||
RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
|
||||
&& mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \
|
||||
/home/agent/.claude/projects /home/agent/.codex/sessions \
|
||||
&& chgrp -R 0 /home/agent \
|
||||
&& chmod -R g=u /home/agent
|
||||
|
||||
USER agent
|
||||
WORKDIR /home/agent
|
||||
|
||||
# Codeman overrides the command with `sleep infinity` at create time; this is the
|
||||
# fallback so a hand-run container also idles rather than exiting.
|
||||
CMD ["sleep", "infinity"]
|
||||
@@ -0,0 +1,588 @@
|
||||
# Claude Code Build Brief: Add Scheduling to Codeman
|
||||
|
||||
## 0. Purpose of This Brief
|
||||
|
||||
You are Claude Code working inside the Codeman repository.
|
||||
|
||||
Your task is to add a **small, reliable scheduling layer** to Codeman while preserving Codeman's existing architecture and session-management behavior.
|
||||
|
||||
This is not a greenfield rewrite. This is not a full product rebuild. This is a focused extension.
|
||||
|
||||
The target user wants Codeman-like tmux/web/session management, but with first-class scheduled jobs for Claude, Codex, OpenCode, Terminal, or any other configurable coding-agent harness.
|
||||
|
||||
---
|
||||
|
||||
## 1. Non-Negotiable Goal
|
||||
|
||||
Add scheduling to Codeman so a user can define a scheduled coding-agent job that:
|
||||
|
||||
1. Has a name.
|
||||
2. Uses an existing Codeman-supported agent/session type where possible.
|
||||
3. Has a working directory.
|
||||
4. Has a prompt or prompt file.
|
||||
5. Has a schedule.
|
||||
6. Can be enabled or disabled.
|
||||
7. Can be manually run now.
|
||||
8. When due, creates a Codeman/tmux session.
|
||||
9. Sends the configured prompt into that session.
|
||||
10. Records last run, next run, status, and run history.
|
||||
|
||||
The first working version should prioritize **scheduling correctness and reuse of Codeman's existing tmux/session system** over UI polish.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Architectural Rule
|
||||
|
||||
Do **not** rebuild Codeman's session layer.
|
||||
|
||||
Reuse existing Codeman functionality for:
|
||||
|
||||
- Creating sessions.
|
||||
- Naming sessions.
|
||||
- Launching Claude/Codex/OpenCode/Terminal sessions.
|
||||
- Sending input into sessions.
|
||||
- Displaying sessions in the web UI.
|
||||
- Killing sessions.
|
||||
- Tracking session status if already supported.
|
||||
|
||||
If an internal API/service/function already exists, reuse it.
|
||||
|
||||
If no reusable function exists, create a thin wrapper around the existing implementation rather than duplicating logic.
|
||||
|
||||
---
|
||||
|
||||
## 3. Product Boundary
|
||||
|
||||
This build is **Codeman + Scheduler**.
|
||||
|
||||
It is not yet:
|
||||
|
||||
- A full quota engine.
|
||||
- A full lock manager.
|
||||
- A replacement for Codeman's terminal UI.
|
||||
- A new FastAPI application.
|
||||
- A multi-tenant SaaS platform.
|
||||
- A complex cron-management product.
|
||||
- A full agent autonomy framework.
|
||||
|
||||
Keep the build small and shippable.
|
||||
|
||||
---
|
||||
|
||||
## 4. Required Working Scope for v0.1
|
||||
|
||||
Implement the following minimum features.
|
||||
|
||||
### 4.1 Scheduled Jobs List
|
||||
|
||||
Create a UI page showing all scheduled jobs.
|
||||
|
||||
Each row/card should show:
|
||||
|
||||
- Job name.
|
||||
- Agent/session type.
|
||||
- Working directory.
|
||||
- Schedule type.
|
||||
- Enabled/disabled state.
|
||||
- Last run time.
|
||||
- Next run time.
|
||||
- Last run status.
|
||||
- Actions:
|
||||
- Run Now.
|
||||
- Enable/Disable.
|
||||
- Edit.
|
||||
- Delete.
|
||||
|
||||
### 4.2 Create/Edit Scheduled Job
|
||||
|
||||
Create a form for scheduled jobs with these fields:
|
||||
|
||||
- `name`
|
||||
- `agent_type`
|
||||
- Reuse Codeman's existing session/agent types where possible.
|
||||
- Include at least Terminal/custom command if supported.
|
||||
- `working_directory`
|
||||
- `launch_command` if needed by Codeman's model.
|
||||
- `prompt_mode`
|
||||
- `inline_text`
|
||||
- `prompt_file_path`
|
||||
- `prompt_text`
|
||||
- `prompt_file_path`
|
||||
- `input_mode`
|
||||
- `paste`
|
||||
- `typed`
|
||||
- `schedule_type`
|
||||
- `once`
|
||||
- `interval_minutes`
|
||||
- `daily_time`
|
||||
- `weekly_time`
|
||||
- `run_at` for one-time jobs.
|
||||
- `interval_minutes` for interval jobs.
|
||||
- `daily_time` for daily jobs.
|
||||
- `weekly_days` and `weekly_time` for weekly jobs.
|
||||
- `enabled`
|
||||
- `notes` optional.
|
||||
|
||||
Do not build a complex visual cron editor in v0.1.
|
||||
|
||||
### 4.3 Run Now
|
||||
|
||||
Every scheduled job must support a `Run Now` action.
|
||||
|
||||
Run Now should:
|
||||
|
||||
1. Create a new session through Codeman's existing session creation logic.
|
||||
2. Send the configured prompt into the session using Codeman's existing input mechanism.
|
||||
3. Create a run-history record.
|
||||
4. Update last-run fields.
|
||||
5. Redirect or link the user to the created Codeman session.
|
||||
|
||||
### 4.4 Background Scheduler Loop
|
||||
|
||||
Add a small background scheduler loop that runs inside the Codeman backend process.
|
||||
|
||||
The loop should:
|
||||
|
||||
1. Wake every 15-60 seconds.
|
||||
2. Load enabled schedules.
|
||||
3. Find schedules where `next_run_at <= now`.
|
||||
4. Create a scheduled run.
|
||||
5. Launch the session using existing Codeman session logic.
|
||||
6. Send the prompt.
|
||||
7. Record run history.
|
||||
8. Compute the next run time.
|
||||
9. Avoid duplicate launches if the loop overlaps or restarts.
|
||||
|
||||
Keep this simple and robust.
|
||||
|
||||
### 4.5 Run History
|
||||
|
||||
Every scheduled execution should create a run-history record.
|
||||
|
||||
Track:
|
||||
|
||||
- `id`
|
||||
- `scheduled_job_id`
|
||||
- `session_id` or Codeman session reference.
|
||||
- `session_name` if applicable.
|
||||
- `started_at`
|
||||
- `finished_at` optional.
|
||||
- `status`
|
||||
- `created`
|
||||
- `session_started`
|
||||
- `prompt_sent`
|
||||
- `failed`
|
||||
- `error_message` optional.
|
||||
- `trigger_type`
|
||||
- `scheduled`
|
||||
- `manual_run_now`
|
||||
- `created_session_url` or route reference if easy.
|
||||
|
||||
---
|
||||
|
||||
## 5. Scheduling Rules
|
||||
|
||||
### 5.1 Once
|
||||
|
||||
Run at a specific date/time.
|
||||
|
||||
After successful launch:
|
||||
|
||||
- Set `enabled = false`, or mark as completed.
|
||||
|
||||
### 5.2 Interval
|
||||
|
||||
Run every N minutes.
|
||||
|
||||
Example:
|
||||
|
||||
- Every 60 minutes.
|
||||
- Every 240 minutes.
|
||||
|
||||
After launch:
|
||||
|
||||
- `next_run_at = now + interval_minutes`.
|
||||
|
||||
### 5.3 Daily
|
||||
|
||||
Run every day at HH:MM.
|
||||
|
||||
After launch:
|
||||
|
||||
- Compute the next occurrence of HH:MM after now.
|
||||
|
||||
### 5.4 Weekly
|
||||
|
||||
Run on selected weekdays at HH:MM.
|
||||
|
||||
After launch:
|
||||
|
||||
- Compute the next selected weekday/time after now.
|
||||
|
||||
### 5.5 Timezone
|
||||
|
||||
Use the server's local timezone for v0.1 unless Codeman already has timezone handling.
|
||||
|
||||
Add a visible note in the UI:
|
||||
|
||||
> Times use the server's local timezone.
|
||||
|
||||
Do not overbuild timezone support in v0.1.
|
||||
|
||||
---
|
||||
|
||||
## 6. Data Storage Decision
|
||||
|
||||
First inspect Codeman's existing persistence model.
|
||||
|
||||
If Codeman already has a database or persistence layer:
|
||||
|
||||
- Reuse it.
|
||||
- Add scheduled job and scheduled run models/tables/records using the existing pattern.
|
||||
|
||||
If Codeman uses files or JSON state:
|
||||
|
||||
- Use the same style for v0.1.
|
||||
- Prefer simple persistence over introducing a heavy new dependency.
|
||||
|
||||
If there is no appropriate persistence layer:
|
||||
|
||||
- Add SQLite only if it fits the codebase cleanly.
|
||||
- Otherwise use a JSON file store for the first version.
|
||||
|
||||
Do not introduce Postgres, Redis, Celery, or a separate scheduler service.
|
||||
|
||||
---
|
||||
|
||||
## 7. Concurrency and Duplicate-Run Guard
|
||||
|
||||
Implement a basic duplicate-run guard.
|
||||
|
||||
A schedule should not launch twice for the same due time.
|
||||
|
||||
Minimum acceptable approach:
|
||||
|
||||
- Before launching, create/update a run record with a `created` or `launching` state.
|
||||
- Use a schedule-level `last_triggered_at` or `last_due_key` to avoid double launching.
|
||||
- If launch fails, record failure clearly.
|
||||
|
||||
Do not build distributed locks. Codeman is expected to be local/single-instance for v0.1.
|
||||
|
||||
---
|
||||
|
||||
## 8. Multi-Session Warning
|
||||
|
||||
When the user clicks `Run Now`, show a warning if there are already active sessions for the same agent type.
|
||||
|
||||
Minimum behavior:
|
||||
|
||||
- If active sessions exist, show a confirmation warning.
|
||||
- User can continue anyway.
|
||||
|
||||
For scheduled automatic runs:
|
||||
|
||||
- Add a setting on the scheduled job:
|
||||
- `warn_only`
|
||||
- `skip_if_same_agent_running`
|
||||
|
||||
Default:
|
||||
|
||||
- `warn_only` for manual runs.
|
||||
- `skip_if_same_agent_running = false` for automatic runs unless easy to implement.
|
||||
|
||||
Do not build a complete quota engine in v0.1.
|
||||
|
||||
---
|
||||
|
||||
## 9. Prompt Sending Rules
|
||||
|
||||
The scheduler must support sending the configured prompt into the created session.
|
||||
|
||||
Prompt source:
|
||||
|
||||
1. Inline prompt text.
|
||||
2. Prompt file path.
|
||||
|
||||
Input mode:
|
||||
|
||||
1. Paste mode.
|
||||
2. Typed mode.
|
||||
|
||||
If only one input mode is easy with Codeman's current internals, implement that first and structure the code so the other can be added later.
|
||||
|
||||
Important:
|
||||
|
||||
- Do not send prompts to a session if session creation failed.
|
||||
- Record prompt-send success/failure in run history.
|
||||
- Save enough metadata to understand what prompt was used.
|
||||
|
||||
---
|
||||
|
||||
## 10. UI Bifurcation
|
||||
|
||||
Keep UI changes cleanly separated.
|
||||
|
||||
Add scheduler UI under a clear navigation item:
|
||||
|
||||
- `Scheduled Jobs`
|
||||
|
||||
Do not clutter the existing session dashboard.
|
||||
|
||||
The existing session dashboard may show sessions created by scheduled jobs, but the scheduling controls should live in their own section.
|
||||
|
||||
Recommended pages/routes:
|
||||
|
||||
- `/schedules`
|
||||
- `/schedules/new`
|
||||
- `/schedules/:id`
|
||||
- `/schedules/:id/edit`
|
||||
- `/schedules/:id/run-now`
|
||||
- `/schedules/:id/enable`
|
||||
- `/schedules/:id/disable`
|
||||
- `/schedules/:id/delete`
|
||||
|
||||
Use Codeman's existing frontend conventions and routing style.
|
||||
|
||||
---
|
||||
|
||||
## 11. Backend Bifurcation
|
||||
|
||||
Keep scheduler code separate from existing session code.
|
||||
|
||||
Recommended logical modules, adapted to Codeman's actual structure:
|
||||
|
||||
- `scheduler/model` or equivalent.
|
||||
- `scheduler/store` or equivalent.
|
||||
- `scheduler/service` for schedule calculations and launch logic.
|
||||
- `scheduler/loop` for the background due-job checker.
|
||||
- `scheduler/routes` for API/UI endpoints.
|
||||
- `scheduler/time` for next-run calculations.
|
||||
|
||||
Do not mix scheduling logic directly into terminal rendering, xterm handling, or low-level tmux code.
|
||||
|
||||
The scheduler service should call session services; it should not own tmux directly unless Codeman has no session abstraction.
|
||||
|
||||
---
|
||||
|
||||
## 12. Required Discovery Phase Before Coding
|
||||
|
||||
Before implementing, inspect the Codeman repo and produce a short architecture note in the terminal or in a file called:
|
||||
|
||||
`docs/cron-discovery.md`
|
||||
|
||||
This note must identify:
|
||||
|
||||
1. Where session creation happens.
|
||||
2. Where agent/session types are defined.
|
||||
3. Where input is sent into a session.
|
||||
4. Where active sessions are listed.
|
||||
5. Where session kill/delete is handled.
|
||||
6. How session state is stored.
|
||||
7. Whether there is existing persistence.
|
||||
8. Where backend routes live.
|
||||
9. Where frontend pages/components live.
|
||||
10. The smallest integration points for scheduling.
|
||||
|
||||
Do not start coding until this discovery is complete.
|
||||
|
||||
---
|
||||
|
||||
## 13. Implementation Phases
|
||||
|
||||
### Phase 1: Discovery
|
||||
|
||||
Deliverable:
|
||||
|
||||
- `docs/cron-discovery.md`
|
||||
|
||||
Must answer the 10 discovery questions above.
|
||||
|
||||
### Phase 2: Data Model / Persistence
|
||||
|
||||
Deliverable:
|
||||
|
||||
- Scheduled job persistence.
|
||||
- Scheduled run history persistence.
|
||||
- Basic create/read/update/delete operations.
|
||||
|
||||
### Phase 3: Scheduler Calculation Logic
|
||||
|
||||
Deliverable:
|
||||
|
||||
- Functions to compute `next_run_at` for:
|
||||
- once
|
||||
- interval
|
||||
- daily
|
||||
- weekly
|
||||
|
||||
Add tests if the repo has an existing test setup.
|
||||
|
||||
### Phase 4: Manual Run Now
|
||||
|
||||
Deliverable:
|
||||
|
||||
- Create scheduled job.
|
||||
- Click Run Now.
|
||||
- Codeman session is created.
|
||||
- Prompt is sent.
|
||||
- Run history is recorded.
|
||||
- UI links to the session.
|
||||
|
||||
This is the most important milestone.
|
||||
|
||||
### Phase 5: Background Scheduler Loop
|
||||
|
||||
Deliverable:
|
||||
|
||||
- Enabled schedules launch automatically when due.
|
||||
- Run history is recorded.
|
||||
- `last_run_at` and `next_run_at` update.
|
||||
- Duplicate launch guard exists.
|
||||
|
||||
### Phase 6: UI Polish Only After Functionality
|
||||
|
||||
Deliverable:
|
||||
|
||||
- Scheduled jobs list is readable.
|
||||
- Create/edit form is usable.
|
||||
- Status labels are clear.
|
||||
- Errors are visible.
|
||||
|
||||
Do not polish before Phase 4 works.
|
||||
|
||||
---
|
||||
|
||||
## 14. Acceptance Criteria
|
||||
|
||||
The build is acceptable when all these pass.
|
||||
|
||||
### Manual Run
|
||||
|
||||
1. Create a schedule/job with inline prompt.
|
||||
2. Click Run Now.
|
||||
3. A new Codeman/tmux session starts.
|
||||
4. Prompt is sent into that session.
|
||||
5. The created session is visible in Codeman's normal session UI.
|
||||
6. Run history shows success or failure.
|
||||
|
||||
### One-Time Schedule
|
||||
|
||||
1. Create a one-time schedule 2 minutes in the future.
|
||||
2. Wait for it to become due.
|
||||
3. Scheduler launches a session.
|
||||
4. Prompt is sent.
|
||||
5. Schedule does not repeatedly launch forever.
|
||||
|
||||
### Interval Schedule
|
||||
|
||||
1. Create interval schedule every 2 minutes.
|
||||
2. It launches once when due.
|
||||
3. It computes the next due time.
|
||||
4. It does not launch duplicates for the same due time.
|
||||
|
||||
### Daily Schedule
|
||||
|
||||
1. Create daily schedule at a time a few minutes ahead.
|
||||
2. It launches when due.
|
||||
3. Next run becomes tomorrow at the same time.
|
||||
|
||||
### Disable Schedule
|
||||
|
||||
1. Disable a schedule.
|
||||
2. It does not launch even when due.
|
||||
|
||||
### Error Handling
|
||||
|
||||
1. Invalid working directory produces visible error.
|
||||
2. Invalid prompt file produces visible error.
|
||||
3. Failed session launch creates failed run-history entry.
|
||||
|
||||
---
|
||||
|
||||
## 15. Explicitly Out of Scope for v0.1
|
||||
|
||||
Do not implement these unless all required scope is already working:
|
||||
|
||||
- Full quota engine.
|
||||
- Advanced lock manager.
|
||||
- Post-run git inspection reports.
|
||||
- Complex recurring calendar UI.
|
||||
- User accounts / RBAC.
|
||||
- External distributed workers.
|
||||
- Redis.
|
||||
- Postgres.
|
||||
- Celery.
|
||||
- Kubernetes.
|
||||
- A separate Python service.
|
||||
- Full visual cron editor.
|
||||
- AI-generated follow-up prompts.
|
||||
- Automatic continuation after idle.
|
||||
- Any attempt to bypass agent quotas or platform limits.
|
||||
|
||||
---
|
||||
|
||||
## 16. Quality Rules
|
||||
|
||||
Follow these rules while coding:
|
||||
|
||||
1. Reuse existing Codeman services and conventions.
|
||||
2. Keep scheduler code isolated.
|
||||
3. Prefer boring, readable code over clever abstractions.
|
||||
4. Add error messages that a human can understand.
|
||||
5. Do not break existing Codeman sessions.
|
||||
6. Do not rename existing core concepts unnecessarily.
|
||||
7. Do not introduce large dependencies without strong reason.
|
||||
8. Keep v0.1 local-first and single-instance.
|
||||
9. Commit in logical chunks if git is available.
|
||||
10. After coding, provide a final implementation summary.
|
||||
|
||||
---
|
||||
|
||||
## 17. Final Response Required from Claude Code
|
||||
|
||||
At the end, report:
|
||||
|
||||
1. Files changed.
|
||||
2. New routes/pages added.
|
||||
3. New data structures added.
|
||||
4. How the scheduler loop works.
|
||||
5. How to run the app.
|
||||
6. How to test manual Run Now.
|
||||
7. How to test scheduled execution.
|
||||
8. Known limitations.
|
||||
9. Suggested v0.2 improvements.
|
||||
|
||||
---
|
||||
|
||||
## 18. v0.2 Ideas, Not for Current Build
|
||||
|
||||
Keep these in mind but do not build unless v0.1 is complete:
|
||||
|
||||
- Quota-aware scheduling.
|
||||
- Manual takeover locks.
|
||||
- Post-idle inspection.
|
||||
- Git diff reports.
|
||||
- Schedule groups.
|
||||
- Prompt templates.
|
||||
- Agent-specific concurrency rules.
|
||||
- Better timezone support.
|
||||
- Audit events.
|
||||
- More advanced cron expressions.
|
||||
|
||||
---
|
||||
|
||||
## 19. Final Reminder
|
||||
|
||||
The goal is to add **scheduling** to Codeman quickly and cleanly.
|
||||
|
||||
Do not drift into building a new platform.
|
||||
|
||||
The highest-priority path is:
|
||||
|
||||
1. Discover existing Codeman integration points.
|
||||
2. Add scheduled job persistence.
|
||||
3. Add Run Now.
|
||||
4. Add background due-job loop.
|
||||
5. Add minimal UI.
|
||||
6. Verify that scheduled jobs create real Codeman/tmux sessions and send prompts.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# CRON_DISCOVERY.md
|
||||
|
||||
Phase 1 deliverable for the "Add Scheduling to Codeman" build brief.
|
||||
This documents the existing Codeman architecture and the smallest integration
|
||||
points for a cron. **No session/tmux logic will be rebuilt** —
|
||||
the new code is purely a trigger + persistence + history layer on top of the
|
||||
existing primitives.
|
||||
|
||||
Stack: `aicodeman` v1.2.1 — Fastify 5 backend, `node-pty` + tmux sessions,
|
||||
vanilla-JS SPA frontend served as static assets, JSON file state store, zod
|
||||
validation, ports-based dependency injection.
|
||||
|
||||
---
|
||||
|
||||
## 0. Critical finding: an existing `ScheduledRun` is NOT a cron
|
||||
|
||||
Codeman already has a `ScheduledRun` concept (`/api/scheduled`,
|
||||
`src/web/ports/infra-port.ts:14-26`, `src/web/server.ts:1480-1605`). It is a
|
||||
**run-now, duration-bounded autonomous loop**: given `{prompt, workingDir,
|
||||
durationMinutes}` it immediately spawns/kills throwaway sessions in a loop until
|
||||
the duration elapses. It has **no** time-based triggering, recurrence
|
||||
(once/interval/daily/weekly), enable/disable, next-run calculation, run history,
|
||||
or persistence across restarts.
|
||||
|
||||
Therefore the brief's core (the calendar/cron trigger layer) does **not** exist
|
||||
and must be built. The execution primitives it sits on top of **do** exist and
|
||||
will be reused. To honor brief §16 ("do not rename existing core concepts"), the
|
||||
new feature is named **`CronJob`** (with **`CronJobRun`** history
|
||||
records), kept distinct from the existing `ScheduledRun`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Where session creation happens
|
||||
|
||||
- Canonical create flow: `POST /api/sessions`,
|
||||
`src/web/routes/session-routes.ts:262-438`.
|
||||
- `new Session({ workingDir, mode, ... })` (`src/session.ts:421-570`)
|
||||
- `ctx.addSession(session)` → `ctx.setupSessionListeners(session)` →
|
||||
`ctx.persistSessionState(session)` (all via `SessionPort`).
|
||||
- `SessionPort` interface: `src/web/ports/session-port.ts:8-16`.
|
||||
- **Integration point:** the cron service will mirror this exact sequence
|
||||
(create → addSession → setupSessionListeners → start) via `SessionPort`,
|
||||
not reimplement it.
|
||||
|
||||
## 2. Where agent/session types are defined
|
||||
|
||||
- `type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini'`
|
||||
(`src/types/session.ts:43-44`). `shell` covers the brief's "Terminal/custom".
|
||||
- CLI availability resolvers in `src/utils/{claude,codex,gemini,opencode}-cli-resolver.ts`.
|
||||
- **Integration point:** the job's `agentType` reuses `SessionMode` verbatim.
|
||||
|
||||
## 3. Where input is sent into a session
|
||||
|
||||
- Raw / paste: `session.write(data)` (`src/session.ts:2243-2247`) — direct PTY write.
|
||||
- Typed (recommended): `session.writeViaMux(data)` (`src/session.ts:2301-2311`)
|
||||
— tmux `send-keys`, falls back to PTY. Submit requires trailing `\r`.
|
||||
- **Integration point:** prompt delivery uses `writeViaMux` (typed) by default,
|
||||
`write` (paste) as the alternate `input_mode`.
|
||||
|
||||
## 4. Where active sessions are listed
|
||||
|
||||
- `ctx.sessions: ReadonlyMap<string, Session>` (`SessionPort`).
|
||||
- Filters: `Array.from(ctx.sessions.values()).filter(s => s.mode === X)` and
|
||||
`.isBusy()` / `.isIdle()` (`src/session-manager.ts:220-247`).
|
||||
- **Integration point:** the §8 multi-session warning queries this map.
|
||||
|
||||
## 5. Where session kill/delete is handled
|
||||
|
||||
- `ctx.cleanupSession(sessionId, killMux?, reason?)`
|
||||
(`SessionPort`; impl `src/web/server.ts:997-1152`). Underlying
|
||||
`session.stop(killMux)` at `src/session.ts:2498-2585`.
|
||||
- The cron does **not** kill sessions it launches (the brief wants them
|
||||
visible in the normal session UI); cleanup stays user-driven.
|
||||
_Superseded post-review:_ recurring jobs now default to
|
||||
`autoClosePreviousSession: true` — the previous run's still-open session is
|
||||
closed via `cleanupSession` when the next run fires (see
|
||||
`docs/cron-guide.md` §8); opt out per job for fully user-driven cleanup.
|
||||
|
||||
## 6. How session state is stored / 7. Existing persistence
|
||||
|
||||
- JSON file store: `~/.codeman/state.json` (+ `state-inner.json` for Ralph).
|
||||
`StateStore` class `src/state-store.ts:71`; `AppState` interface
|
||||
`src/types/app-state.ts:99-114`.
|
||||
- Pattern: declare a field on `AppState`, add typed get/set methods on
|
||||
`StateStore` that mutate in-memory state and call the debounced `save()`
|
||||
(500ms debounce, atomic temp-file+rename, `.bak` backup, circuit breaker).
|
||||
- **Integration point:** add `cronJobs?: Record<string, CronJob>` and
|
||||
`cronJobRuns?: Record<string, CronJobRun>` to `AppState`, with
|
||||
matching `StateStore` accessors. No new DB (brief §6 forbids Postgres/Redis).
|
||||
|
||||
## 8. Where backend routes live
|
||||
|
||||
- Route modules: `src/web/routes/*.ts`; barrel `src/web/routes/index.ts`;
|
||||
registered in `WebServer.setupRoutes()` `src/web/server.ts:858-876` with a
|
||||
single `ctx` object from `createRouteContext()` (`src/web/server.ts:553-613`)
|
||||
that satisfies all port interfaces.
|
||||
- Validation: zod schemas in `src/web/schemas.ts`, applied via
|
||||
`parseBody(Schema, req.body)` (`src/web/route-helpers.ts:101-111`).
|
||||
- Errors: `createErrorResponse(ApiErrorCode.X, msg)` / `ApiResponse`
|
||||
(`src/types/api.ts`), auto-mapped to HTTP status by a `preSerialization` hook
|
||||
(`src/web/server.ts:644-659`).
|
||||
- SSE: `ctx.broadcast(SseEvent.X, data)` (`EventPort`,
|
||||
`src/web/sse-events.ts`); frontend mirror in `src/web/public/constants.js`.
|
||||
- **Integration point:** new `cron-routes.ts` registered alongside the
|
||||
others; new zod schema; new `SseEvent` constants for job list/run changes.
|
||||
|
||||
## 9. Where frontend pages/components live
|
||||
|
||||
- Vanilla-JS SPA: single `src/web/public/index.html` + feature mixin files
|
||||
(`Object.assign(CodemanApp.prototype, {...})`). API via `api-client.js`
|
||||
(`_apiJson/_apiPost/_apiDelete`). Build = esbuild minify + content-hash, no
|
||||
bundler (`scripts/build.mjs`).
|
||||
- UI is panels/modals toggled by JS classes; forms use `.form-row` / `.modal`
|
||||
conventions (`styles.css`). SSE handler map in `app.js`.
|
||||
- **Integration point:** add a new `cron-ui.js` mixin + a panel/modal in
|
||||
`index.html` + nav entry, following the orchestrator/respawn panel pattern.
|
||||
|
||||
## 10. Background-loop pattern (for the due-checker)
|
||||
|
||||
- Established pattern: `this.cleanup.setInterval(fn, intervalMs, {description})`
|
||||
in `WebServer.start()` (`src/web/server.ts:~1942-1966`), auto-disposed in
|
||||
`WebServer.stop()` via `this.cleanup.dispose()` (`src/web/server.ts:2336`).
|
||||
RalphLoop (`src/ralph-loop.ts:268-286`) shows the self-rescheduling guard idiom.
|
||||
- **Integration point:** register a 30s cron tick via `cleanup.setInterval`;
|
||||
no manual shutdown wiring needed.
|
||||
|
||||
---
|
||||
|
||||
## Smallest integration points (summary)
|
||||
|
||||
| New piece | Reuses | Location |
|
||||
| --- | --- | --- |
|
||||
| `CronJob` / `CronJobRun` types | — (new) | `src/types/cron.ts` |
|
||||
| Persistence | `StateStore` / `AppState` | `src/types/app-state.ts`, `src/state-store.ts` |
|
||||
| Next-run time math | — (new, pure, unit-tested) | `src/cron/cron-time.ts` |
|
||||
| Launch + send prompt | `SessionPort` (`addSession`/listeners/`writeViaMux`) | `src/cron/cron-service.ts` |
|
||||
| Background due loop | `cleanup.setInterval` pattern | `src/cron/cron-loop.ts` |
|
||||
| Routes + schema | route/ports/zod/SSE patterns | `src/web/routes/cron-routes.ts`, `src/web/schemas.ts`, `src/web/sse-events.ts` |
|
||||
| UI | panel/modal/mixin conventions | `src/web/public/cron-ui.js`, `index.html` |
|
||||
|
||||
Nothing in the session, tmux, persistence, routing, or SSE subsystems is
|
||||
rewritten — the cron is additive and calls existing services.
|
||||
@@ -0,0 +1,426 @@
|
||||
# Cron Jobs — User & Operator Guide
|
||||
|
||||
Codeman's **Cron** feature lets you save named, recurring jobs that automatically
|
||||
spin up a Claude (or shell / OpenCode / Codex / Gemini) session on a schedule and
|
||||
feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a
|
||||
Claude session in `~/proj` and tell it to update dependencies and open a PR."_
|
||||
|
||||
- **UI**: the **⏰ Cron** button in the header → the Cron Jobs modal (`#cronModal`).
|
||||
- **API**: `/api/cron/jobs*` and `/api/cron/runs`.
|
||||
- **Code**: `src/cron/cron-service.ts`, `src/cron/cron-time.ts`, `src/cron/cron-input.ts`,
|
||||
types in `src/types/cron.ts`, routes in `src/web/routes/cron-routes.ts`,
|
||||
frontend in `src/web/public/cron-ui.js`.
|
||||
|
||||
> **Not to be confused with `ScheduledRun` (`/api/scheduled`).** That older,
|
||||
> deliberately-separate concept is a _run-now, duration-bounded autonomous loop_
|
||||
> (`{prompt, workingDir, durationMinutes}` → spawn/kill throwaway sessions until
|
||||
> the duration elapses). It has no recurrence, no saved jobs, and no next-run
|
||||
> calculation. The two systems never interact. This guide is only about **Cron
|
||||
> jobs** (`Cron*`). See `docs/cron-discovery.md` §0.
|
||||
|
||||
---
|
||||
|
||||
## 1. Quick start
|
||||
|
||||
### In the browser
|
||||
|
||||
1. Click **⏰ Cron** in the header.
|
||||
2. Click **+ New Job**.
|
||||
3. Fill in a **name**, pick an **agent type** and **working directory**, choose a
|
||||
**prompt** (inline text or a file path), pick a **schedule**, and leave
|
||||
**Enabled** on.
|
||||
4. **Save**. The job appears in the list with its computed **next run**.
|
||||
5. Use **Run Now** to fire it immediately without waiting for the schedule.
|
||||
|
||||
### With curl
|
||||
|
||||
```bash
|
||||
API=http://localhost:3000
|
||||
|
||||
# Create a daily job (03:00 server-local time)
|
||||
curl -s -X POST "$API/api/cron/jobs" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "nightly-deps",
|
||||
"agentType": "claude",
|
||||
"workingDir": "/home/me/proj",
|
||||
"promptMode": "inline_text",
|
||||
"promptText": "Update dependencies and open a PR",
|
||||
"inputMode": "typed",
|
||||
"scheduleType": "daily",
|
||||
"dailyTime": "03:00",
|
||||
"enabled": true,
|
||||
"concurrencyPolicy": "warn_only"
|
||||
}' | jq
|
||||
|
||||
# List jobs
|
||||
curl -s "$API/api/cron/jobs" | jq
|
||||
|
||||
# Run one immediately
|
||||
curl -s -X POST "$API/api/cron/jobs/<jobId>/run" | jq
|
||||
|
||||
# See a job's run history
|
||||
curl -s "$API/api/cron/jobs/<jobId>/runs" | jq
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Concepts
|
||||
|
||||
| Term | Meaning |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| **Cron job** (`CronJob`) | A saved, named definition: what agent to launch, where, with what prompt, on what schedule. |
|
||||
| **Run** (`CronJobRun`) | One execution of a job — a history record with a status and a link to the session it created. |
|
||||
| **Schedule type** | How fire times are computed: `once`, `interval`, `daily`, or `weekly`. |
|
||||
| **Next run** (`nextRunAt`) | Server-computed epoch-ms of the next fire. `null` when the job is disabled or has no future run. |
|
||||
| **Due tick** | A background loop (every 30s) that launches any enabled job whose `nextRunAt` has passed. |
|
||||
|
||||
A job is essentially a **trigger + persistence + history layer on top of the
|
||||
existing session primitives**. When a job fires, the cron service does exactly
|
||||
what the "quick start" route does — `new Session(...)` → `addSession` →
|
||||
`setupSessionListeners` → `startInteractive()`/`startShell()` → deliver the
|
||||
prompt. It does **not** reimplement any tmux/PTY logic.
|
||||
|
||||
---
|
||||
|
||||
## 3. The job form — every field
|
||||
|
||||
These map 1:1 to `CronJobSchema` (`src/web/schemas.ts`) and the `CronJob` type
|
||||
(`src/types/cron.ts`).
|
||||
|
||||
| Field | Required | Values / limits | Notes |
|
||||
| -------------------------- | ----------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `name` | ✅ | 1–200 chars | Display name; also used as the created session's name. |
|
||||
| `agentType` | ✅ | `claude` \| `shell` \| `opencode` \| `codex` \| `gemini` | Reuses Codeman's `SessionMode`. `shell` = a plain terminal. |
|
||||
| `workingDir` | ✅ | valid path (allowlist-validated) | Validated at **create/update** (must exist, be a directory, and not resolve into a blocked tree — `/etc`, `/root`, `/proc`, `/sys`, `/dev`, or `/` itself) and again **at fire time**. |
|
||||
| `launchCommand` | — | ≤ 2000 chars, single line | `shell` mode only: sent as the **first input line** once the shell is up, before the prompt. Ignored for other agent types. |
|
||||
| `promptMode` | ✅ | `inline_text` \| `prompt_file_path` | See §5. |
|
||||
| `promptText` | conditional | ≤ 100000 chars, **single line** | Required when `promptMode = inline_text`. Newlines are rejected (see §6). |
|
||||
| `promptFilePath` | conditional | valid path | Required when `promptMode = prompt_file_path`. Confined to `workingDir` (see §5). |
|
||||
| `inputMode` | ✅ | `paste` \| `typed` | How the prompt is delivered. See §6. |
|
||||
| `scheduleType` | ✅ | `once` \| `interval` \| `daily` \| `weekly` | See §4. |
|
||||
| `runAt` | conditional | epoch-ms (positive int) | Required for `once`. |
|
||||
| `intervalMinutes` | conditional | 1–525600 (≤ 1 year) | Required for `interval`. |
|
||||
| `dailyTime` | conditional | `HH:MM` (24h) | Required for `daily`. Server-local time. |
|
||||
| `weeklyDays` | conditional | array of 1–7 ints, each 0–6 (0 = Sunday) | Required for `weekly`. |
|
||||
| `weeklyTime` | conditional | `HH:MM` (24h) | Required for `weekly`. Server-local time. |
|
||||
| `enabled` | ✅ | boolean | Disabled jobs never auto-fire (but **Run Now** still works). |
|
||||
| `notes` | — | ≤ 2000 chars | Free-form. |
|
||||
| `concurrencyPolicy` | ✅ | `warn_only` \| `skip_if_same_agent_running` | Applies to **automatic** runs only. See §7. |
|
||||
| `autoClosePreviousSession` | — | boolean (default **true**) | Recurring schedules only (ignored for `once`): when the next run fires, the still-open session created by this job's **previous** run is closed first via the normal cleanup path. See §8. |
|
||||
|
||||
**Cross-field validation** (`refineCronJob` in `schemas.ts`): the conditional
|
||||
fields above are enforced by a Zod `superRefine` on create. A missing dependent
|
||||
field (e.g. `scheduleType: "once"` with no `runAt`) is rejected with
|
||||
`INVALID_INPUT` and a field-specific message.
|
||||
|
||||
> ⚠️ **Update caveat.** `PUT /api/cron/jobs/:id` uses a `.partial()` schema that
|
||||
> does **not** re-run the cross-field `superRefine`. To keep partial edits safe,
|
||||
> `updateJob()` re-validates the **merged** job against the full `CronJobSchema`
|
||||
> and throws `400` if the result is inconsistent (e.g. switching to `once`
|
||||
> without a `runAt`). So the store is never left with a half-valid job.
|
||||
|
||||
---
|
||||
|
||||
## 4. Schedule types
|
||||
|
||||
Next-run math lives in `src/cron/cron-time.ts` (pure, unit-tested in
|
||||
`test/cron-time.test.ts`). **All wall-clock times use the server's local
|
||||
timezone** (v0.1 decision).
|
||||
|
||||
### `once`
|
||||
|
||||
- Fires a single time at the absolute `runAt` epoch-ms.
|
||||
- A **missed** one-time job (server was down at `runAt`) **still fires once** on
|
||||
the next tick — `computeNextRunAt` returns `runAt` even if it's in the past,
|
||||
until the job has fired.
|
||||
- After firing, the job **self-disables**: `completedOnce = true`, `enabled =
|
||||
false`, `nextRunAt = null`.
|
||||
|
||||
### `interval`
|
||||
|
||||
- Fires every `intervalMinutes`, computed as `fireTime + intervalMinutes`.
|
||||
- ⚠️ **Drift**: the next run re-anchors to the actual fire time, not to an ideal
|
||||
cadence — a slow tick or restart shifts subsequent runs slightly later. This is
|
||||
an accepted limitation.
|
||||
|
||||
### `daily`
|
||||
|
||||
- Fires at `dailyTime` (`HH:MM`) every day, server-local.
|
||||
- If today's time has already passed, the next run is tomorrow at that time.
|
||||
|
||||
### `weekly`
|
||||
|
||||
- Fires at `weeklyTime` on each weekday in `weeklyDays` (0 = Sunday … 6 =
|
||||
Saturday), server-local.
|
||||
- The next run is the soonest upcoming matching weekday/time within the next 7
|
||||
days.
|
||||
|
||||
---
|
||||
|
||||
## 5. Prompt source (`promptMode`)
|
||||
|
||||
### `inline_text`
|
||||
|
||||
The prompt is the literal `promptText`. Simplest option.
|
||||
|
||||
### `prompt_file_path`
|
||||
|
||||
The prompt is read from a file at fire time. **This path is security-hardened**
|
||||
because a job config is attacker-controllable and the file's contents are
|
||||
injected into an agent session (an exfiltration sink over SSE/terminal).
|
||||
`resolveSafePromptPath()` enforces, in order:
|
||||
|
||||
1. **`realpath` resolution** — symlinks are resolved to their true target, for
|
||||
the prompt file **and for `workingDir` itself**.
|
||||
2. **`workingDir` is not a trust boundary** — because it is user-supplied, the
|
||||
resolved `workingDir` is itself rejected if it is `/` or resolves into a
|
||||
blocked tree (`/etc`, `/root`, operator extras) or a pseudo-filesystem
|
||||
(`/proc`, `/sys`, `/dev`). This closes the `workingDir: '/proc'` +
|
||||
`promptFilePath: '/proc/self/environ'` env-exfil trick. The same rule is
|
||||
enforced earlier, at job create/update.
|
||||
3. **Blocklist** (defense-in-depth) — sensitive trees (`/etc`, `/root`,
|
||||
`/proc`, `/sys`, `/dev`, known secret locations) are rejected for the
|
||||
resolved prompt file.
|
||||
4. **Allowlist (primary gate)** — the resolved path **must live inside the job's
|
||||
(resolved) `workingDir`** (`validateSessionFilePath`). A symlink escaping the
|
||||
workspace fails here.
|
||||
5. **Regular-file check** — directories, FIFOs, and `/dev/*` character devices
|
||||
are rejected (they would hang or OOM an unbounded read).
|
||||
6. **Size cap** — files larger than **1 MiB** (`MAX_PROMPT_FILE_BYTES`) are
|
||||
rejected.
|
||||
7. **Single-line check** — after trailing newlines are stripped, the file
|
||||
content must be a single line (see §6).
|
||||
|
||||
If any check fails, the run is recorded as **`failed`** with the reason; no
|
||||
session is created.
|
||||
|
||||
---
|
||||
|
||||
## 6. Prompt delivery (`inputMode`)
|
||||
|
||||
Once the CLI is ready (see §8), the prompt is written to the session with a
|
||||
trailing carriage return:
|
||||
|
||||
| Mode | Mechanism | Use when |
|
||||
| ------- | --------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| `typed` | `session.writeViaMux()` — tmux `send-keys -l` (literal) + Enter | Default; behaves like a human typing the prompt. |
|
||||
| `paste` | `session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. |
|
||||
|
||||
> ⚠️ **Single-line only — enforced.** Like all programmatic input in Codeman,
|
||||
> multi-line delivery would be silently corrupted (Ink-based TUIs treat a
|
||||
> newline as submit; typed mode fuses lines). So newlines are **rejected**: the
|
||||
> schema and the form refuse a multi-line `promptText`, and at fire time a
|
||||
> prompt file whose content is multi-line (after stripping trailing newlines)
|
||||
> fails the run with a clear `errorMessage`. Put multi-line instructions in a
|
||||
> file the agent is told to read itself (e.g. "read TASKS.md and do it").
|
||||
|
||||
---
|
||||
|
||||
## 7. Concurrency policy (automatic runs)
|
||||
|
||||
`concurrencyPolicy` governs what happens when a **scheduled** run is due and
|
||||
sessions of the same `agentType` already exist:
|
||||
|
||||
| Policy | Behavior |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `warn_only` | Always launch. (The count is surfaced but not blocking.) |
|
||||
| `skip_if_same_agent_running` | If ≥ 1 **other, live** session of that mode is active, **skip** this fire — record a `skipped` run and (for recurring schedules) advance the schedule without launching. |
|
||||
|
||||
Notes on `skip_if_same_agent_running`:
|
||||
|
||||
- Only **live** sessions block: a tab whose CLI already exited (status
|
||||
`stopped`/`error`) does not count.
|
||||
- Sessions created by **this job's own previous runs never block it** —
|
||||
otherwise a recurring job would deadlock on the session it created last time
|
||||
and fire exactly once.
|
||||
- A skipped **`once`** job is **not consumed**: it stays armed and retries on
|
||||
the next tick until the blocking session goes away, then fires its single run.
|
||||
- A skip is **not** a run: it sets `lastStatus = 'skipped'` but does **not**
|
||||
advance `lastRunAt`.
|
||||
- Consecutive skips are **coalesced** — a perpetually-skipped interval job writes
|
||||
**one** skip record per streak, not one every tick, so it can't bloat
|
||||
`state.json`.
|
||||
|
||||
**Run Now ignores this policy on the server.** The browser shows a `confirm()`
|
||||
warning if same-type sessions are active, but if you proceed (or call the API
|
||||
directly), the job launches unconditionally.
|
||||
|
||||
---
|
||||
|
||||
## 8. What happens when a job fires
|
||||
|
||||
Sequence in `CronService.launch()`:
|
||||
|
||||
1. A `CronJobRun` is created with status **`created`** and broadcast
|
||||
(`cron:runCreated`).
|
||||
2. The prompt is resolved (inline or file, single-line enforced). Failure →
|
||||
**`failed`**.
|
||||
3. `workingDir` is checked (`statSync().isDirectory()`). Missing/not-a-dir →
|
||||
**`failed`**.
|
||||
4. **Auto-close previous session** (recurring schedules, unless
|
||||
`autoClosePreviousSession: false`): any still-open session created by this
|
||||
job's previous runs is closed via the normal session-cleanup path.
|
||||
5. The global session cap is checked (`MAX_CONCURRENT_SESSIONS = 50`). At cap →
|
||||
**`failed`**.
|
||||
6. A `Session` is created **with `useMux: true`** (so it runs inside tmux),
|
||||
registered, listeners attached, and started via `startInteractive()`
|
||||
(`startShell()` for `shell` mode). Model/claudeMode come from global config.
|
||||
Run status → **`session_started`**.
|
||||
7. **Readiness wait** (async, non-blocking): for non-shell agents the service
|
||||
polls the terminal buffer up to **60 × 500ms** for a `❯` prompt or the string
|
||||
`tokens`, then settles **2000ms** (`CRON_READY_SETTLE_MS`). Shell mode waits
|
||||
1000ms, then sends the optional `launchCommand` as the first input line
|
||||
(+1000ms settle).
|
||||
8. The prompt is delivered (`typed`/`paste`, trailing `\r`). Run status →
|
||||
**`prompt_sent`**; `finishedAt` stamped. Delivery failure (e.g. the mux
|
||||
session is gone) → **`failed`**.
|
||||
|
||||
The created session is a **normal, persistent interactive session** — it appears
|
||||
as its own tab and keeps running after the prompt is sent. The run's
|
||||
`createdSessionUrl` is a deep link (`/?session=<id>`); the UI focuses it
|
||||
automatically after **Run Now**.
|
||||
|
||||
> ⚠️ **Session-cap math if you disable auto-close.** With
|
||||
> `autoClosePreviousSession: false`, nothing ever closes the sessions a
|
||||
> recurring job creates — an interval job every 30 min creates 48 tabs/day and
|
||||
> hits the global 50-session cap in ~25 hours (sooner with existing tabs), after
|
||||
> which **every** fire of **every** job fails with "Maximum concurrent sessions
|
||||
> reached" until you delete tabs by hand. Leave auto-close on for unattended
|
||||
> recurring jobs, or clean up sessions yourself.
|
||||
|
||||
### The background tick
|
||||
|
||||
`tickDueJobs()` runs every **30s** (`CRON_TICK_INTERVAL`, registered in
|
||||
`server.ts`). For each enabled job whose `nextRunAt ≤ now`:
|
||||
|
||||
- **Duplicate-launch guard**: `lastDueKey = jobId:fireTime`. If this due time was
|
||||
already consumed (overlap/restart), the job is just advanced, not relaunched.
|
||||
- The schedule is **advanced _before_ launching** so a slow launch can't be
|
||||
re-triggered by the next tick.
|
||||
- On boot, `init()` recomputes `nextRunAt` for loaded jobs (dead `once` jobs stay
|
||||
dead).
|
||||
|
||||
---
|
||||
|
||||
## 9. Run history & statuses
|
||||
|
||||
Each job keeps a history of `CronJobRun` records. Statuses (`CronJobRunStatus`):
|
||||
|
||||
| Status | Meaning |
|
||||
| ----------------- | ------------------------------------------------------------- |
|
||||
| `created` | Run record created; prompt/session not yet started. |
|
||||
| `session_started` | Session launched successfully. |
|
||||
| `prompt_sent` | Prompt delivered — the happy-path terminal state. |
|
||||
| `failed` | Something went wrong (see `errorMessage`). |
|
||||
| `skipped` | A scheduled fire was skipped by `skip_if_same_agent_running`. |
|
||||
|
||||
Each run also records `triggerType` (`scheduled` or `manual_run_now`),
|
||||
`sessionId`/`sessionName`, timestamps, and `createdSessionUrl`.
|
||||
|
||||
**History is capped globally** at **500 records** (`MAX_CRON_RUN_HISTORY`); the
|
||||
oldest are pruned first. Deleting a job also deletes its run records.
|
||||
|
||||
---
|
||||
|
||||
## 10. API reference
|
||||
|
||||
All responses use the standard `ApiResponse<T>` envelope (`{success, data}` /
|
||||
`{success, error, errorCode}`). `/api/v1/*` is a stable alias.
|
||||
|
||||
| Method | Endpoint | Body | Returns |
|
||||
| -------- | ---------------------------- | ---------------------- | --------------------------------- |
|
||||
| `GET` | `/api/cron/jobs` | — | `CronJob[]` |
|
||||
| `POST` | `/api/cron/jobs` | `CronJobSchema` | `{ job }` |
|
||||
| `GET` | `/api/cron/jobs/:id` | — | `CronJob` (404 if missing) |
|
||||
| `PUT` | `/api/cron/jobs/:id` | partial `CronJob` | `{ job }` (400 if merge invalid) |
|
||||
| `DELETE` | `/api/cron/jobs/:id` | — | `{}` |
|
||||
| `PUT` | `/api/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job }` |
|
||||
| `POST` | `/api/cron/jobs/:id/run` | — | `{ run, activeAgents }` |
|
||||
| `GET` | `/api/cron/jobs/:id/runs` | — | `CronJobRun[]` (newest first) |
|
||||
| `GET` | `/api/cron/runs` | — | all `CronJobRun[]` (newest first) |
|
||||
|
||||
---
|
||||
|
||||
## 11. SSE events
|
||||
|
||||
Emitted on `/api/events`, mirrored in `SSE_EVENTS` (`constants.js`):
|
||||
|
||||
| Event | Payload | When |
|
||||
| ------------------ | ------------ | -------------------------------------------------------------------- |
|
||||
| `cron:jobsChanged` | `{ jobs }` | Any job created / updated / enabled / status change. |
|
||||
| `cron:jobDeleted` | `{ id }` | A job was deleted. |
|
||||
| `cron:runCreated` | `CronJobRun` | A run (incl. skips) started. |
|
||||
| `cron:runUpdated` | `CronJobRun` | A run advanced state (`session_started` / `prompt_sent` / `failed`). |
|
||||
|
||||
---
|
||||
|
||||
## 12. State & persistence
|
||||
|
||||
Persisted in `~/.codeman/state.json` via `StateStore`:
|
||||
|
||||
- `AppState.cronJobs` — map of `id → CronJob`.
|
||||
- `AppState.cronJobRuns` — map of `id → CronJobRun`.
|
||||
|
||||
Jobs and their schedules survive restarts; `init()` recomputes `nextRunAt` on
|
||||
boot. Sessions the jobs create persist through the normal session-recovery path.
|
||||
|
||||
---
|
||||
|
||||
## 13. Limits & constants
|
||||
|
||||
| Constant | Value | Source |
|
||||
| ------------------------ | --------------------- | ------------------------------------------------ |
|
||||
| Due-tick interval | 30s | `CRON_TICK_INTERVAL` (`config/server-timing.ts`) |
|
||||
| Readiness poll | 60 × 500ms | `CRON_READY_MAX_ATTEMPTS` |
|
||||
| Readiness settle | 2000ms | `CRON_READY_SETTLE_MS` |
|
||||
| Run-history cap (global) | 500 | `MAX_CRON_RUN_HISTORY` (`config/map-limits.ts`) |
|
||||
| Saved-jobs cap | 100 | `MAX_CRON_JOBS` (`config/map-limits.ts`) |
|
||||
| Concurrent-session cap | 50 | `MAX_CONCURRENT_SESSIONS` |
|
||||
| Prompt-file size cap | 1 MiB | `MAX_PROMPT_FILE_BYTES` (`cron-service.ts`) |
|
||||
| `name` length | 1–200 | `CronJobSchema` |
|
||||
| `promptText` length | ≤ 100000 | `CronJobSchema` |
|
||||
| `intervalMinutes` | 1–525600 | `CronJobSchema` |
|
||||
| `weeklyDays` | 1–7 entries, each 0–6 | `CronJobSchema` |
|
||||
|
||||
---
|
||||
|
||||
## 14. Known limitations
|
||||
|
||||
- **Server-local timezone only** — `daily`/`weekly` times are interpreted in the
|
||||
host's local time; there is no per-job timezone.
|
||||
- **Interval drift** — `interval` re-anchors to the actual fire time; long-running
|
||||
intervals slowly shift.
|
||||
- **Single-line prompts** — multi-line prompts are rejected (schema, form, and
|
||||
at fire time for prompt files); tell the agent to read a file itself for
|
||||
multi-line instructions.
|
||||
- **`runNow` / tick race** — a manual Run Now firing at the same instant as a
|
||||
scheduled tick is theoretically possible; benign (you may get two sessions).
|
||||
- **`{enabled:true}` on a dead `once` job** — re-enabling a fired one-time job
|
||||
without changing its schedule leaves it enabled-but-dead (won't fire); change
|
||||
the schedule to re-arm.
|
||||
|
||||
---
|
||||
|
||||
## 15. Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| Job never fires | Disabled, or `nextRunAt: null` | Check **Enabled**; verify the schedule fields are complete. |
|
||||
| Run shows `failed` immediately | Bad `workingDir`, prompt-file rejected, or session cap hit | Read `errorMessage` on the run; confirm the dir exists and the prompt file is inside it and < 1 MiB. |
|
||||
| Run shows `skipped` | `skip_if_same_agent_running` + another live same-type session (this job's own sessions and dead tabs don't count) | Switch to `warn_only`, or wait for the other session to end. |
|
||||
| Run fails with "single line" | Multi-line prompt text / prompt file | Keep the prompt to one line; point the agent at a file to read for long instructions. |
|
||||
| Sessions pile up between runs | `autoClosePreviousSession: false` | Re-enable auto-close, or delete old tabs before the 50-session cap bites (see §8). |
|
||||
| Wrong fire time | Timezone assumption | Times are **server-local** — check the host clock/TZ. |
|
||||
| One-time job won't re-fire | `completedOnce` set | Edit the schedule (any real schedule change re-arms it). |
|
||||
|
||||
---
|
||||
|
||||
## 16. Related docs
|
||||
|
||||
- `docs/cron-discovery.md` — architecture / integration-point analysis (why the
|
||||
feature reuses the session layer and stays distinct from `ScheduledRun`).
|
||||
- `docs/cron-build-brief.md` — the original build brief / requirements.
|
||||
- `CLAUDE.md` → **Key Patterns → Cron** — the one-paragraph engineering summary.
|
||||
- Tests: `test/cron-time.test.ts` (schedule math), `test/cron-service.test.ts`
|
||||
(CRUD, tick, concurrency, security).
|
||||
@@ -0,0 +1,433 @@
|
||||
<!-- Design doc generated via ultracode multi-agent workflow (wf_e3a7498b-26f): 3 architecture proposals -> judge panel -> synthesis -> completeness critic. -->
|
||||
|
||||
# Docker Session Mode, Implementation Plan
|
||||
|
||||
## Decisions (locked 2026-07-19, by repo owner)
|
||||
|
||||
1. **Isolation posture**: CONVENIENT default (bind-mount host `~/.claude` etc. read-write so the existing login just works; network on; still hardened non-root + cap-drop + resource caps). SEALED profile (`mountCredentials:false` + `network:none`) is a per-case opt-in.
|
||||
2. **Export**: offer BOTH full-image (`commit`+`save`+workspace tar) AND workspace-only, side by side, no default (ask each time).
|
||||
3. **Base image**: BUILD LOCALLY on first use via `scripts/build-agent-image.mjs` from a repo `docker/agent.Dockerfile`. No registry required. (GHCR pull can be added later.)
|
||||
4. **Hooks**: WIRE HOOKS NOW. Codeman scaffolds `.claude/settings.local.json` + CLAUDE.md into the linked host workspace dir (same as local cases), enabling in-container permission prompts, hook-idle detection, and the Claude Model picker.
|
||||
|
||||
Adopted defaults for the remaining open items (Section 10): resume-on-restart ON; container is per-CASE and shared by multiple sessions (killing one session only kills its in-container tmux session, never `docker stop` while siblings remain; stop/remove only on explicit teardown or case-delete); rootless caps = ship-with-warning (`capsEnforced` surfaced); remote docker daemon = local-first; podman = docker-first best-effort.
|
||||
|
||||
## Implementation status (branch `feat/docker-session-mode`)
|
||||
|
||||
DONE and END-TO-END VERIFIED against a real docker daemon (create host, link case, quick-start shell in a real container, workspace bind-mount round-trip, hook scaffolding, session-delete keeps the shared container up, case-delete `docker rm`s it):
|
||||
|
||||
- Phase 0-1: types (`DockerHost`/`DockerCase`/`SessionDocker`), `src/docker-hosts.ts` (storage, pure `buildDockerBaseArgs`/`buildDockerCreateArgs`, `containerApiUrl`, `hostGatewayAlias`, config-hash, credential-mount resolution, daemon probes), `DockerHostSchema`/`DockerCaseLinkSchema`. 26 unit tests.
|
||||
- Phase 2: `tmux-manager` `buildDockerLaunchCommand` (image-check -> ensure -> start -> exec, resume-aware), `buildDockerKillCommand` (in-container tmux only, multi-session safe), stop/remove; wired into `createSession`/`respawnPane`/`killSession`. 14 unit tests.
|
||||
- Phase 3: `Session` threading (`_docker`, toState, option builders, in-container cliVersion probe, `resolveMuxAttachCwd`), `server.ts` recovery round-trip.
|
||||
- Phase 4: `case-routes` `/api/docker-hosts` CRUD + `/api/cases/docker-link` + listing + docker-unlink; `session-routes` `/api/quick-start` docker branch (rejects per-session config, probes availability + tmux, scaffolds hooks, seeds resume id).
|
||||
- Phase 5 (partial): `docker/agent.Dockerfile` + `scripts/build-agent-image.mjs` (built + verified: node 22, tmux, claude/codex/gemini/opencode, arbitrary-uid HOME). Host-guard allowlists `host.docker.internal`/`host.containers.internal` for in-container hooks.
|
||||
- Full CI green (3445 tests).
|
||||
|
||||
REMAINING:
|
||||
|
||||
- Phase 6: export / import (`docker commit` + `save | gzip` + workspace tar + manifest; `load` + quarantined re-tag), GC / boot reaper, disk-safety prechecks, drift-recreate route, SSE `docker:*` events. THE "move to a new machine" feature.
|
||||
- Phase 7: frontend Create Case "Docker" tab + `linkDockerCase` + run wiring + case-picker labels + export/import UI.
|
||||
- Phase 8: CLAUDE.md "Docker cases" Key Pattern + `docs/docker-cases.md` + COM.
|
||||
- Deferred refinements: in-container model-picker via `settings.local.json`; live mid-run resume-id capture into `DockerCase.lastClaudeSessionId`; rootless/Desktop uid probe (currently a platform heuristic).
|
||||
|
||||
## 1. Goal & user stories
|
||||
|
||||
Add "Docker cases" to Codeman: a case can point at a container instead of a local or remote-SSH path, and any of the five CLI backends (`claude` / `shell` / `opencode` / `codex` / `gemini`) runs inside that container. It is modeled as a LOCATION OVERLAY on cases, exactly like the remote-SSH feature (COD-94/#145), never as a sixth `SessionMode`.
|
||||
|
||||
User stories:
|
||||
|
||||
- As the repo owner, I link a case to a per-project container so an autonomous Claude/Ralph run executes in a hardened sandbox (cap-drop, non-root, resource caps) instead of directly on my host, while keeping my existing OAuth login and transcript history working with zero extra setup.
|
||||
- I set default, per-case-changeable container settings (image, network mode, memory/cpu/pids caps) at link time and edit them later, and edits actually take effect through a recreate-on-drift path (see Section 4).
|
||||
- I reconnect after a Codeman restart and land back in the SAME running agent with the conversation intact. When the CONTAINER itself was stopped/rebooted/OOM-killed (which destroys the in-container tmux), the next launch RESUMES the last conversation from the bind-mounted transcript rather than starting fresh (durability model in Section 2, Key decision 1).
|
||||
- I export a finished run's whole environment (toolchain plus workspace) to a portable, secret-free `.tar.gz`, move it to another machine, and import it back into a fresh case in one click.
|
||||
- The container never accumulates: killing the session stops it, deleting the case removes it, and an instance-scoped boot reaper reaps containers whose case is gone.
|
||||
|
||||
Non-goals for the MVP: multi-tenant untrusted-code isolation guarantees (Codeman is loopback-default and single-operator, and the agent already runs `--dangerously-skip-permissions` on the host today), Kubernetes/compose orchestration, and per-command ephemeral containers.
|
||||
|
||||
## 2. Chosen architecture and why
|
||||
|
||||
The design grafts the strongest idea from each of the three proposals:
|
||||
|
||||
- Overlay-not-a-mode + faithful remote-SSH mirror (from "Docker Cases as a Location Overlay"): lowest churn, rides the existing quick-start / mux-sessions / state / recovery plumbing.
|
||||
- Convenient-but-hardened default with an opt-in sealed profile, plus exec-time name-only secret env (from "Sealed Sandbox"): a strict security improvement over today's on-host execution without the UX tax of forcing an in-container re-login.
|
||||
- One-artifact export + in-app import route (from "Container-as-Cargo"): the genuinely new, high-value capability Codeman lacks.
|
||||
|
||||
### Key decision 1: persistent per-CASE container, durable in-container tmux, AND resume-on-restart (the two-layer durability model)
|
||||
|
||||
Exactly one long-lived container per Docker case, named as a pure slug function `codeman-case-<slug>` (Docker charset `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`; Codeman already slugs case names for tmux), so create-if-missing and boot recovery are idempotent. PID1 is `sleep infinity` under `--init` (tini reaps zombies and forwards `docker stop`'s SIGTERM); the CLI is NOT the container command. The CLI runs inside a DURABLE in-container tmux on a dedicated socket `-L codeman-docker`, session `codeman-dkr-<id8>`, the direct analog of remote's `-L codeman-remote` / `codeman-ssh-<id8>`.
|
||||
|
||||
Two DIFFERENT failure surfaces need two DIFFERENT recovery layers, and conflating them is the central flaw the critic caught:
|
||||
|
||||
1. Codeman-PROCESS restart while the container stays up: the in-container tmux is still alive, so `tmux new-session -A` (attach-or-create) reattaches the SAME live agent and the paneCommand is ignored. This is the remote-SSH durability idiom and it works unchanged.
|
||||
2. CONTAINER stop / daemon restart / host reboot / OOM-kill: the in-container tmux is GONE (fresh PID1). `new-session -A` will now CREATE a fresh session and run the paneCommand, which would start a brand-new conversation. This is the case the raw plan silently lost. Because the transcript directory is bind-mounted from the host (Key decision 3), the fix is to launch with RESUME: the paneCommand becomes `exec claude --dangerously-skip-permissions --resume <claudeSessionId>` (codex uses `resume <id>`, gemini `--resume <id>`) whenever a captured `claudeSessionId` exists. The `-A` semantics make this self-selecting: the resume flag only ever executes when tmux is actually re-created, which is exactly when the live session was lost. When tmux is still alive (case 1), attach wins and the flag is inert.
|
||||
|
||||
Capturing / persisting / reusing the resume id (the missing mechanism the critic flagged): Codeman already learns `Session.claudeSessionId` from transcript correlation (which works here because projHash matches, Key decision 3) and persists it in `SessionState`. We thread that value into `createSessionOptions` / `respawnPaneOptions` for docker so `buildDockerLaunchCommand` can inject the resume flag on any relaunch. To make a NEW Codeman session (new `id8`) re-launched against the same case resume its predecessor's conversation, we ALSO persist `lastClaudeSessionId` on the `DockerCase` record; the quick-start docker branch seeds the new `Session` with it when the `dockerResumeOnStart` setting is on. First-ever launch has no id, so it starts fresh. This is user-decision 7 (default resume behavior).
|
||||
|
||||
Reconciling with stop-on-kill and with the `--restart` policy (the internal inconsistency the critic found): the container is created with `--restart no` uniformly (Codeman's idempotent create-if-missing plus boot recovery is the single recovery mechanism; a restart policy would not preserve the conversation anyway because a restarted container gets a fresh PID1/tmux). Boot recovery re-runs `buildDockerLaunchCommand` from the restored `MuxSession.docker` (`docker inspect || docker create; docker start`, then exec with resume), so a host reboot or daemon restart recreates+starts the container and resumes the conversation instead of the session vanishing. `reconcileSessions` (tmux-manager.ts ~1800-1815) must NOT hard-delete a docker session merely because no LOCAL pane exists after the local `-L codeman` server died; docker (like remote) sessions are restored from `mux-sessions.json` and relaunched. This relaunch path is explicitly part of Phase 4/Phase 3 recovery work, not assumed.
|
||||
|
||||
Why this over the alternatives: `docker exec` gets SIGHUP and dies when its client TTY closes, so a bare `docker exec claude` restarts the CLI on every reconnect/respawn. The inner tmux plus resume is what makes reconnect idempotent across BOTH failure surfaces. Because this durability is the single most important design point, tmux-in-image is a HARD gated prerequisite (`checkDockerTmuxAvailable`), never a silent fallback to bare exec. Rejected alternatives: ephemeral-per-run or bare-exec containers (no reattach durability); a literal `'docker'` `SessionMode` (touches dozens of switch/enum sites and diverges from the remote overlay precedent, since Docker is a LOCATION orthogonal to the 5 CLI backends).
|
||||
|
||||
### Key decision 2: CLI + auth delivery
|
||||
|
||||
One prebuilt base image (built once, contains NO secrets): `node:22-bookworm-slim` + `git tmux ripgrep ca-certificates`, `npm i -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai`, an `agent` user, HOME dirs made writable by an arbitrary host uid via the OpenShift "gid 0, group-writable" convention (Key decision 6). Because the toolchain is baked, export is reproducible and needs no network at import time. The image name/namespace/registry and its refresh cadence are user-decision 2 (the `codeman/agent:base` placeholder implies a Docker Hub org the project may not own).
|
||||
|
||||
Credentials are delivered ONLY at runtime, two commit-safe channels, default convenient:
|
||||
|
||||
- OAuth/config-file CLIs (Claude Max/Pro, gcloud, opencode): bind-mount the host credential dirs read-write (`~/.claude`, `~/.codex`, `~/.gemini` + `~/.config/gcloud`, `~/.config/opencode`) so the common user "just works" with no in-container login. Because these are bind mounts, `docker commit` (which captures only the container's own writable layer, never bind mounts) physically cannot capture them, so exports stay secret-free.
|
||||
- API-key CLIs (codex/gemini): exec-time NAME-ONLY `docker exec --env OPENAI_API_KEY --env GEMINI_API_KEY ...` (no `=value`), sourced from Codeman's own process env. Only the key NAME appears in argv (no `ps` leak), and per-exec env is never captured by `docker commit`. This is the technique Codeman already uses via `tmux setenv` for the local Codex/Gemini panes, so it composes with existing machinery.
|
||||
|
||||
Per-host `DockerHost.mountCredentials` defaults `true` (convenient); setting it `false` yields a SEALED profile (no host cred mounts, in-container login only) for genuinely untrusted work. CRITICAL sealed-mode export rule (the leak the critic caught): in sealed mode the in-container login writes tokens into the container's OWN writable layer, which `docker commit` DOES capture, so a full-image export of a sealed container would ship credentials. Therefore full-image export is REFUSED for `mountCredentials:false` containers by default; the user may either take a workspace-only export (always safe) or opt into a pre-commit scrub that `docker exec`s `rm -rf ~/.claude ~/.codex ~/.gemini ~/.config/gcloud ~/.config/opencode` inside the container before commit (destructive to the in-container login, which is the point). This is enforced in the export route, not left to a manifest assertion.
|
||||
|
||||
Per-session `envOverrides` / `effort` / `codexConfig` / `geminiConfig` / `openCodeConfig` are REJECTED at quick-start exactly like the remote branch (session-routes.ts ~1698-1710). `modelOverride` is the one deliberate difference from remote: because the docker workspace is a REAL bind-mounted host dir that Codeman scaffolds (Key decision 5 and Section 6), `updateCaseModel()` can write the `model` key into `<workspace>/.claude/settings.local.json` and the in-container `claude` reads it, so the App Settings Claude Model picker works for docker cases. `effort` is a `--effort` CLI arg applied only by the local-spawn path we bypass, so it stays rejected (surfaced honestly in the UI, not silently inert). Per-mode command customization goes through `DockerHost.commands.<mode>` (`defaultDockerCommandForMode`, mirror of `defaultRemoteCommandForMode` at remote-hosts.ts:60). NEVER bake secrets into an image layer and NEVER pass a secret via create-time `-e` (both are committed).
|
||||
|
||||
Rejected alternative: sealed-by-default. For a single-operator loopback tool where the agent already runs skip-permissions on the host, forcing an in-container OAuth re-login is a UX regression with little real gain. We keep sealed as an opt-in. Rejected alternative: baking a login into the image, which leaks the instant you `docker save`.
|
||||
|
||||
### Key decision 3: workspace mount, container CWD, and transcript correlation
|
||||
|
||||
Bind-mount the host workspace dir into the container at the SAME absolute path (`dst == src`, mirror the host path), and set both `Session.workingDir` and the container workdir to that host path.
|
||||
|
||||
Two problems this solves that the raw proposals got wrong:
|
||||
|
||||
- File features: `DockerCase.hostWorkspacePath` is a REAL host directory, so `Session.workingDir = hostWorkspacePath` keeps file-routes, attachments, image-watcher, and previews working on real host bytes (unlike remote, where the path is remote-only and those features no-op). All three proposals wired `casePath = <container path>`; we deliberately diverge and use the host path.
|
||||
- Transcript correlation: Claude writes transcripts under `~/.claude/projects/<hash-of-CWD>/`. By mirroring the host path as the container CWD, the projHash computed inside the container equals the host-side hash Codeman's transcript/subagent/workflow watchers expect, so correlation keeps working (and, in turn, feeds the resume-id capture in Key decision 1). A `/workspace`-style fixed dst would break it. Mirror-vs-fixed is user-decision 3.
|
||||
|
||||
`resolveMuxAttachCwd` still returns `/tmp` for docker sessions (the LOCAL bash pane only runs `docker exec`; it never needs the workspace as its cwd), mirroring remote.
|
||||
|
||||
### Key decision 4: network default and the engine-specific host gateway
|
||||
|
||||
Default `bridge` (own netns, NAT egress, no inbound), per-case changeable to `none` (offline shell sandbox; warned because it breaks the API CLIs) or `custom` (a user-defined bridge `codeman-net-<slug>`, the chokepoint for a future egress allowlist). `host` networking and any `-p` inbound publish are structurally unrepresentable in the flag builder and schema. Rationale: every API-backed CLI (Claude, Codex, Gemini) plus npm/git needs egress, so `bridge` is the only sane functional default; `none` is reserved for `shell`.
|
||||
|
||||
The host-callback gateway alias is ENGINE-SPECIFIC (the critic's podman finding): Docker uses `host.docker.internal`, Podman uses `host.containers.internal` (Docker's alias only exists on recent podman). A helper `hostGatewayAlias(engine)` returns the right name; Section 2.5, the create args, the `CODEMAN_API_URL` rewrite, and the host-guard allowlist all consume it, and BOTH aliases are added to the allowlist so a mixed fleet keeps working.
|
||||
|
||||
### Key decision 5: hooks actually reach the host AND are actually installed
|
||||
|
||||
Two independent things must both be true for a hook to fire, and the raw plan wired only the first:
|
||||
|
||||
1. Network reachability. Claude Code hooks POST to `$CODEMAN_API_URL` (`curl -sk`). Inside a bridge container `localhost` is the container and prod binds `127.0.0.1`, so we set `--add-host <gatewayAlias>:host-gateway` on create (skipped on Docker Desktop, where the alias is native), add the gateway alias to the host guard, and provide `CODEMAN_API_URL` and the hook secret (below).
|
||||
2. Hook INSTALLATION. Hooks live in `<workspace>/.claude/settings.local.json`, written by the quick-start scaffolding block (around session-routes.ts ~1776) that calls `writeHooksConfig()` / `updateCaseModel()`. The raw plan extended the `!remote` guard to `!remote && !docker`, which would SKIP that block and silently disable ALL hooks regardless of networking. For docker the workspace is a REAL bind-mounted host dir, so the scaffolding block MUST run. Precise fix: extend to `!remote && !docker` ONLY the LOCAL-CLI-availability and local-spawn guards (the ones that stat the local binary or build the local spawn command); leave the workspace-scaffolding guard at `!remote` so it runs for docker. This same decision is what makes `modelOverride` work (Key decision 2). Consequence, surfaced as user-decision 4: linking a docker case now WRITES `.claude/settings.local.json` (and the CLAUDE.md scaffold, matching local-case behavior) into the user's real host directory, a behavioral shift from "link a dir" to "link and scaffold a dir."
|
||||
|
||||
`CODEMAN_API_URL` derivation (the wrong-scheme bug the critic caught): prod is HTTPS-only on 3000, and `server.ts` (~2000) auto-sets `process.env.CODEMAN_API_URL = ${protocol}://${apiHost}:${port}`. Hardcoding `http://host.docker.internal:3000` fails every hook. Instead a pure helper `containerApiUrl(process.env.CODEMAN_API_URL, engine)` parses the running URL and substitutes ONLY the hostname with `hostGatewayAlias(engine)`, preserving scheme and port (`https://host.docker.internal:3000`). Unit-tested against http, https, non-default ports, and both engines. Passed as create-time `--env CODEMAN_API_URL=<derived>` (case-stable, non-secret).
|
||||
|
||||
Hook secret and session attribution:
|
||||
- `~/.codeman/hook-secret` is bind-mounted read-only to a container path; `--env CODEMAN_HOOK_SECRET_FILE=<that path>` is create-time (a path is non-secret; the bytes ride the bind mount and are never committed).
|
||||
- `CODEMAN_SESSION_ID` (which the generated hooks reference at hooks-config.ts:78-80 to attribute events) plus `CODEMAN_MUX=1` are SESSION-scoped, so they are passed at EXEC time via `docker exec --env CODEMAN_SESSION_ID=<id> --env CODEMAN_MUX=1` (non-secret, value inline is fine, and exec env is not committed). Because a `tmux` session started fresh only inherits the invoking env when it starts the SERVER, the launch chain ALSO runs `tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID <id>` (and `CODEMAN_MUX`) so reattaches and newly created panes see the same values. This mirrors how Codeman already injects per-session env into tmux for the external CLIs.
|
||||
|
||||
Hooks-in-MVP-vs-deferred stays user-decision 4; if deferred, docker ships as explicitly hook-degraded and we lean on output-based idle detection through the docker-exec PTY.
|
||||
|
||||
### Key decision 6: uid / HOME / rootless enforcement / macOS Docker Desktop
|
||||
|
||||
The raw plan showed `--user 1000:1000` in one place and `--user "$(id -u):$(id -g)"` in another and never resolved HOME writability; this section fixes all of it.
|
||||
|
||||
- Linux native (docker rootful or rootless): run `--user <hostUid>:0` (host uid, GID 0). The image follows the OpenShift arbitrary-uid convention: `HOME=/home/agent`, and `/home/agent` plus the tool cache dirs (`~/.npm`, `~/.cache`, `~/.config`) are owned `root:0` and group-writable (`chmod -R g+w`, `g+s` on dirs) so a process with GID 0 can write HOME even though its UID is not 1000. This keeps workspace files host-owned (the agent's UID is the host UID) AND keeps HOME writable, so the CLIs actually start.
|
||||
- Podman rootless: use `--userns=keep-id` (maps the host uid to the image's `agent` uid inside the container) instead of `--user`, so `/home/agent` is owned by the running user and workspace files are host-owned. This is a real per-engine branch in `buildDockerCreateArgs`.
|
||||
- macOS Docker Desktop: `--user <macUid>` (e.g. 501) does not own the image's `/home/agent`, so non-bind HOME writes fail EACCES and the CLIs may not start; Desktop also does its own bind-mount uid translation, provides `host.docker.internal` natively (no `--add-host`), and its VM memory ceiling can cap `--memory`. Detect Desktop via `docker info` (Server OS `linuxkit` / `OperatingString` contains "Docker Desktop") and take a dedicated path: do NOT pass `--user` (run as the image's baked `agent` uid and rely on Desktop's translation for workspace access), skip `--add-host`, and note in the UI that memory caps are subject to the VM ceiling.
|
||||
|
||||
Rootless resource-cap enforcement (the silently-inert risk): rootless Docker without cgroup-v2 systemd delegation (`Delegate=yes`) silently IGNORES `--memory`/`--cpus`/`--pids-limit`. The probe checks `docker info` for `CgroupVersion=2` plus rootless plus delegation; if caps cannot be enforced, `checkDockerAvailable` returns `capsEnforced:false` and the link/probe surfaces "resource caps are advisory on this engine." Whether to REQUIRE delegation or ship-with-warning is user-decision 6.
|
||||
|
||||
## 3. Data model
|
||||
|
||||
New TypeScript types in `src/types/session.ts`, added right after the remote types (lines 46-99). SessionMode (line 44) is UNCHANGED.
|
||||
|
||||
```ts
|
||||
export type DockerCommandMode = Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
|
||||
export type DockerEngine = 'docker' | 'podman';
|
||||
export type DockerNetworkMode = 'bridge' | 'none' | 'custom'; // never 'host'
|
||||
|
||||
export interface DockerResourceLimits {
|
||||
memory?: string; // '4g' -> --memory 4g --memory-swap 4g (swap==memory: real OOM cap)
|
||||
cpus?: string; // '2'
|
||||
pidsLimit?: number; // 512 (fork-bomb guard)
|
||||
nofile?: string; // '4096:8192'
|
||||
shmSize?: string; // optional; only when a tool needs /dev/shm
|
||||
}
|
||||
|
||||
export interface DockerHost {
|
||||
id: string;
|
||||
label: string;
|
||||
engine?: DockerEngine; // default resolved by probe (docker, else podman)
|
||||
image: string; // default resolved image ref (see user-decision 2)
|
||||
daemonHost?: string; // advanced: -H ssh://user@host / DOCKER_HOST
|
||||
context?: string; // advanced: --context <ctx>
|
||||
network?: DockerNetworkMode; // default 'bridge'
|
||||
networkName?: string; // when network === 'custom'
|
||||
resources?: DockerResourceLimits;
|
||||
mountCredentials?: boolean; // default true (false = sealed; blocks full-image export)
|
||||
hooksEnabled?: boolean; // default true (host-gateway callback wiring)
|
||||
resumeOnStart?: boolean; // default true (see Key decision 1 / user-decision 7)
|
||||
commands?: Partial<Record<DockerCommandMode, string>>;
|
||||
extraCreateArgs?: string[]; // validated like extraSshOptions
|
||||
extraExecArgs?: string[];
|
||||
}
|
||||
|
||||
export interface DockerCase {
|
||||
name: string;
|
||||
type: 'docker';
|
||||
hostId: string;
|
||||
hostWorkspacePath: string; // absolute HOST dir: bind src + Session.workingDir
|
||||
containerWorkdir?: string; // container path; default = hostWorkspacePath (mirror -> projHash match)
|
||||
container?: string; // default codeman-case-<slug>
|
||||
lastClaudeSessionId?: string; // captured resume id (Key decision 1)
|
||||
}
|
||||
|
||||
export interface SessionDocker { // flattened, round-trips through mux/state (mirror SessionRemote at 91)
|
||||
hostId: string;
|
||||
label: string;
|
||||
engine: DockerEngine;
|
||||
image: string;
|
||||
containerName: string;
|
||||
hostWorkspacePath: string;
|
||||
containerWorkdir: string;
|
||||
network: DockerNetworkMode;
|
||||
networkName?: string;
|
||||
resources?: DockerResourceLimits;
|
||||
mountCredentials: boolean;
|
||||
hooksEnabled: boolean;
|
||||
resumeOnStart: boolean;
|
||||
daemonHost?: string;
|
||||
context?: string;
|
||||
commands?: Partial<Record<DockerCommandMode, string>>;
|
||||
extraCreateArgs?: string[];
|
||||
extraExecArgs?: string[];
|
||||
configHash?: string; // drift detection (Key decision, Section 4)
|
||||
}
|
||||
```
|
||||
|
||||
- `SessionState` gains `docker?: SessionDocker` immediately after `remote?` (line 219). It persists automatically because `SessionState` is structural and `state-store.ts` stores `toState()` verbatim.
|
||||
- `src/mux-interface.ts`: add `docker?: SessionDocker` to `MuxSession` (after line 38), `CreateSessionOptions` (after 81), `RespawnPaneOptions` (after 105). `MuxSession.docker` round-trips through `mux-sessions.json` automatically.
|
||||
- `src/types/api.ts` `CaseInfo`: add `'docker'` to the `location` union and a `docker?: { hostId; container; image?; path; network }` display block.
|
||||
- `src/services/unified-session-service.ts`: add a boolean `docker?` flag on `UnifiedSessionItem` and source rows, set from `MuxSession.docker` presence (mirror the `remote` flag at ~line 200 and the harvest at session-routes.ts:2313).
|
||||
|
||||
New state files (all via `dataPath()`, mirroring `remote-hosts.json` / `remote-cases.json`):
|
||||
|
||||
- `~/.codeman/docker-hosts.json` (reusable engine/image/network/resource profiles).
|
||||
- `~/.codeman/docker-cases.json` (`name -> DockerCase`, including `lastClaudeSessionId`).
|
||||
- `~/.codeman/docker-exports/` (dedicated dir for `.image.tar.gz` + `.workspace.tar.gz` + `manifest.json`; never inline in state.json; retention/pruning per Section 5).
|
||||
|
||||
No new `state.json` / `mux-sessions.json` files: `SessionState.docker` and `MuxSession.docker` ride the existing serialization.
|
||||
|
||||
## 4. Container lifecycle (exact command shapes)
|
||||
|
||||
All builders are PURE string functions (directly unit-testable). Host values interpolated into the outer `bash -c "..."` layer (container name, image, workdir, host paths) are `shellescape()`'d and, for user-supplied fields, schema-rejected for `$`/backtick via `NO_SHELL_META`. The escaping chain here is DEEPER than remote's single `ssh '<tmux ...>'`: the whole `docker inspect || docker create <dozens of --mount/--env/shellescaped host paths>` is interpolated into `bash -c "..."` then `JSON.stringify`'d into respawn-pane. This is a known place to get stuck, so it is covered by concrete escaping tests (Section 9), including host workspace paths containing spaces, not just a "we call shellescape" claim.
|
||||
|
||||
New in `src/tmux-manager.ts`:
|
||||
|
||||
```ts
|
||||
const DOCKER_TMUX_SOCKET = 'codeman-docker';
|
||||
// 'dkr' letters deliberately FAIL SAFE_MUX_NAME_PATTERN (^codeman-[a-f0-9-]+$),
|
||||
// so a Codeman running INSIDE the container never adopts/resizes/respawns our session.
|
||||
export function dockerTmuxSessionName(id: string): string { return `codeman-dkr-${id.slice(0, 8)}`; }
|
||||
```
|
||||
|
||||
`buildDockerBaseArgs(docker)` (pure, in `docker-hosts.ts`, mirror of `buildSshConnectionArgs`) emits the engine prefix tokens: `docker` (or `podman`) + optional `--context <ctx>` or `-H <daemonHost>`. `buildDockerCreateArgs(docker, sessionId)` emits the `docker create` flag array (with the per-engine uid/userns branch from Key decision 6).
|
||||
|
||||
IMAGE PRESENCE (before any create, the auto-pull footgun the critic caught): the launch chain runs `docker image inspect <image> >/dev/null 2>&1` first; on miss it exits with a distinct message ("base image <ref> not present: build with scripts/build-agent-image.mjs or pull it") rather than triggering a blocking multi-GB auto-pull inside the tmux pane. `docker create` carries `--pull=never`. The tmux-availability probe likewise uses `docker run --rm --pull=never <image> sh -lc 'command -v tmux'` and reports the same build/pull hint if the image is absent, so the 15s-bounded probe never hangs on a pull.
|
||||
|
||||
CREATE (the ensure step, embedded in the launch string):
|
||||
|
||||
```
|
||||
docker create \
|
||||
--name codeman-case-myproj --hostname myproj \
|
||||
--label codeman.managed=1 --label codeman.instance=<CODEMAN_INSTANCE> \
|
||||
--label codeman.case=myproj --label codeman.session=<id8> \
|
||||
--label codeman.confighash=<hash> \
|
||||
--pull=never --init --restart no \
|
||||
--user 1000:0 \
|
||||
--workdir '/home/arkon/cases/myproj' \
|
||||
--mount type=bind,src='/home/arkon/cases/myproj',dst='/home/arkon/cases/myproj' \
|
||||
--mount type=bind,src='/home/arkon/.claude',dst='/home/agent/.claude' \
|
||||
--mount type=bind,src='/home/arkon/.codeman/hook-secret',dst='/home/agent/.codeman/hook-secret',readonly \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--memory 4g --memory-swap 4g --cpus 2 --pids-limit 512 --ulimit nofile=4096:8192 \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
--network bridge \
|
||||
--env HOME=/home/agent --env TERM=xterm-256color --env COLORTERM=truecolor \
|
||||
--env CODEMAN_API_URL=https://host.docker.internal:3000 \
|
||||
--env CODEMAN_HOOK_SECRET_FILE=/home/agent/.codeman/hook-secret \
|
||||
codeman/agent:base \
|
||||
sleep infinity
|
||||
```
|
||||
|
||||
- `--user 1000:0` shown is the Linux-native form with GID 0 (Key decision 6); it is actually `--user <hostUid>:0`, or `--userns=keep-id` for podman rootless, or omitted on Docker Desktop. The literal is illustrative only.
|
||||
- Create-time `--env` carries only NON-SESSION, non-secret, case-stable values (safe to be committed): the DERIVED `CODEMAN_API_URL` (https-preserving, Key decision 5) and the hook-secret FILE PATH. `CODEMAN_SESSION_ID`/`CODEMAN_MUX` and the codex/gemini key NAMES are exec-time only.
|
||||
- `codeman.instance=<CODEMAN_INSTANCE>` is REQUIRED on the label set so the boot reaper is instance-scoped (a beta/second instance must never reap prod's containers).
|
||||
- `codeman.confighash` is a stable hash of the drift-relevant create args (image, resources, network, mounts, non-session env). Drift detection (user story 2, the config-never-takes-effect gap): on launch the ensure block compares the desired hash to the existing container's label; on mismatch the launch does NOT silently reuse the stale container. Instead the docker route returns a "container config changed, recreate?" action (SSE + UI confirm), and on confirm Codeman `docker rm`'s and recreates. rm destroys in-image (non-bind) state, but the workspace and transcripts survive on their bind mounts and the conversation is restored via `--resume`, so the recreate is safe. Auto-recreate-vs-prompt is a UI choice; the MVP prompts.
|
||||
- `--restart no` (resolved consistently with Key decision 1; recovery is Codeman's idempotent create-if-missing, not an engine restart policy, which also matters for Podman which has no daemon).
|
||||
|
||||
EXEC (`buildDockerLaunchCommand`, the docker analog of `buildRemoteLaunchCommand`, TTY-correct, resume-aware). The whole thing is ONE `bash -c` string that image-checks, ensures, starts, primes tmux env, then execs:
|
||||
|
||||
```
|
||||
docker image inspect codeman/agent:base >/dev/null 2>&1 || { echo 'Codeman: base image codeman/agent:base not present (build or pull it)'; exit 1; } ; \
|
||||
docker inspect codeman-case-myproj >/dev/null 2>&1 || docker create <all create args above> ; \
|
||||
docker start codeman-case-myproj >/dev/null 2>&1 || { echo 'Codeman: container codeman-case-myproj failed to start (daemon down?)'; exit 1; } ; \
|
||||
exec docker exec -it \
|
||||
--workdir '/home/arkon/cases/myproj' \
|
||||
--env TERM=xterm-256color --env COLORTERM=truecolor \
|
||||
--env CODEMAN_SESSION_ID=1a2b3c4d --env CODEMAN_MUX=1 \
|
||||
--env OPENAI_API_KEY --env GEMINI_API_KEY \
|
||||
codeman-case-myproj \
|
||||
sh -lc 'tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID 1a2b3c4d \; setenv -g CODEMAN_MUX 1 \; new-session -A -s codeman-dkr-1a2b3c4d -c '\''/home/arkon/cases/myproj'\'' '\''cd /home/arkon/cases/myproj && exec claude --dangerously-skip-permissions --resume <claudeSessionId>'\'' \; set -t codeman-dkr-1a2b3c4d status off \; set -t codeman-dkr-1a2b3c4d mouse off \; set -t codeman-dkr-1a2b3c4d prefix C-q \; set -s escape-time 0'
|
||||
```
|
||||
|
||||
- `docker exec -it`: `-t` allocates a PTY and forwards SIGWINCH into the container so the Ink TUI re-lays-out on pane resize; `TERM`/`COLORTERM` prevent degraded rendering. `--env OPENAI_API_KEY` (name only) is present only for codex/gemini and is exec-time (never committed). `CODEMAN_SESSION_ID`/`CODEMAN_MUX` are exec-time values plus a `tmux setenv -g` prime so reattaches and new panes inherit them (Key decision 5).
|
||||
- `--resume <claudeSessionId>` (codex `resume <id>`, gemini `--resume <id>`) is appended to `modeCommand` ONLY when a captured id exists; on first launch it is omitted. `new-session -A` makes the flag inert on a live-tmux reattach and effective only when tmux is re-created (Key decision 1).
|
||||
- `modeCommand = docker.commands?.[mode] || defaultDockerCommandForMode(mode)` (`exec claude --dangerously-skip-permissions`, `exec bash -l`, etc.), with the resume suffix injected by the builder.
|
||||
- Escaping survives every layer identically to remote in shape but deeper in nesting: `paneCommand` (`cd ... && exec ...`) is one shellescaped tmux arg, the whole `tmuxInvocation` is one shellescaped `sh -lc` arg, and the outer string is `JSON.stringify()`'d into `bash -c` by respawn-pane (tmux-manager.ts:1329).
|
||||
|
||||
Wire-up (extend the two existing seams to 3-way):
|
||||
|
||||
- createSession (tmux-manager.ts:1276): `const fullCmd = docker ? buildDockerLaunchCommand({ mode, docker, sessionId, resumeSessionId }) : remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd;`
|
||||
- launchCmd cd-skip (tmux-manager.ts:1327): `const launchCmd = (remote || docker) ? fullCmd : \`cd ${JSON.stringify(workingDir)} && ${fullCmd}\`;`
|
||||
- respawnPane: same two edits at lines 1524 and 1542.
|
||||
|
||||
START / reattach-after-reboot: the ensure block (image-check, `docker inspect || docker create`, `docker start`) is fully idempotent, so boot recovery just re-runs `buildDockerLaunchCommand` from the restored `MuxSession.docker` with the persisted resume id. A rebooted host recreates the container and resumes the conversation.
|
||||
|
||||
DOCKER-DOWN surfacing (the PTY-exit-breaker false-trip risk): if `docker start` or `docker exec` cannot attach (daemon down, container missing), the launch prints a docker-specific message and exits, which alone would still count toward `session-pty-exit-breaker` and show a generic "respawn breaker tripped" push. To avoid masking the cause, the docker reattach path runs a fast `checkDockerAvailable` pre-flight: if the daemon/container is unreachable, Codeman broadcasts a docker-specific error (SSE + push, "container <name> is not running / daemon down") and SKIPS the auto-reattach that would trip the breaker, rather than fast-looping `docker exec`.
|
||||
|
||||
STOP / KILL (`killSession` Strategy 3c, right after remote's Strategy 3b at tmux-manager.ts:1719, guarded by `IS_TEST_MODE`):
|
||||
|
||||
```ts
|
||||
if (session.docker) {
|
||||
// best-effort, fire-and-forget, timeout-bounded so it never blocks the local kill
|
||||
execAsync(buildDockerKillCommand({ docker: session.docker, sessionId }), { timeout: EXEC_TIMEOUT_MS }).catch(() => {});
|
||||
}
|
||||
```
|
||||
|
||||
`buildDockerKillCommand` emits: `docker exec codeman-case-<slug> tmux -L codeman-docker kill-session -t codeman-dkr-<id8> ; docker stop -t 10 codeman-case-<slug>`. Stopping frees CPU/RAM and, per Key decision 1, is safe for conversation continuity because the NEXT launch resumes from the bind-mounted transcript via `--resume`. Whether to stop at all (RAM vs instant live-agent reattach) is user-decision 6/1 (reframed honestly). The bind-mounted workspace and transcripts always survive on the host.
|
||||
|
||||
REMOVE: only on explicit case delete (`docker rm -f codeman-case-<slug>`), gated behind an "export first?" UI prompt because rm destroys any in-image (non-bind) state. Instance-scoped boot reaper (fixing the racy/cross-instance reaper): after `docker-cases.json` is loaded AND after `restoreMuxSessions` has run, enumerate `docker ps -a --filter label=codeman.managed=1 --filter label=codeman.instance=<CODEMAN_INSTANCE> --format '{{.Names}}\t{{index .Labels "codeman.case"}}'` and `docker rm -f` only containers whose case is gone from THIS instance's `docker-cases.json`. The instance filter is what stops a beta reaping prod's containers (the exact cross-instance hazard the project memory warns about).
|
||||
|
||||
AVAILABILITY PROBE (`docker-hosts.ts`, timeout-bounded like `checkRemoteTmuxAvailable`'s 15s, `IS_TEST_MODE` no-op):
|
||||
|
||||
```
|
||||
docker info --format '{{json .}}' # server up, CgroupVersion, rootless, OS (Desktop detect), cap-delegation
|
||||
docker image inspect <image> --format '{{.Id}}' # image PRESENT (no auto-pull)
|
||||
docker run --rm --pull=never <image> sh -lc 'command -v tmux' # tmux-in-image gate (hard prerequisite), only if image present
|
||||
```
|
||||
|
||||
`checkDockerAvailable()` returns `{ ok, engine, rootless, isDesktop, cgroupV2, capsEnforced }` (parse `SecurityOptions` for `name=rootless`, `CgroupVersion`, delegation, and Server OS for Desktop). `checkDockerTmuxAvailable(host)` returns a structured result with a user-facing error and correct install hint (NOT `npm install -g`; the hint is "build/pull the base image" for a missing image and "install docker or podman" for a missing engine).
|
||||
|
||||
IN-CONTAINER CLI VERSION (fixing the #154 wheel-forwarding regression): the raw plan skipped the LOCAL `cliVersion` probe for docker (correct, since it reports the HOST claude) but left `cliVersion` undefined, which disables trackpad wheel-forwarding. Instead, for docker sessions Codeman runs an IN-CONTAINER probe `docker exec <container> claude --version` (bounded, `IS_TEST_MODE` no-op) and feeds THAT into `cliVersion`. This also means a stale baked CLI is visible; combined with the rebuild-cadence in user-decision 2, agents are not silently pinned to an old claude.
|
||||
|
||||
## 5. Export / Import
|
||||
|
||||
EXPORT is a concurrency-bounded job (reuse `runWithConversionLimit` from `document-conversion-limiter.ts` so N simultaneous exports cannot fork-bomb the host). Route `POST /api/docker-cases/:name/export`.
|
||||
|
||||
Preconditions (the consistency and leak risks the critic caught):
|
||||
- Sealed guard: if `mountCredentials:false`, full-image export is REFUSED unless the caller explicitly opts into the pre-commit scrub (Key decision 2). Workspace-only export is always allowed.
|
||||
- Quiesce + free-space: require the session idle, then `docker pause` the container spanning BOTH the workspace tar AND the commit so the two artifacts are mutually consistent (the raw plan paused only the commit, leaving the bind-mount tar to run against a mid-write agent). Before any heavy step, precheck free space in the exports dir and in `/var/lib/docker`; if below `DOCKER_EXPORT_MIN_FREE_BYTES`, refuse with a clear error (a full `/var/lib/docker` wedges the daemon and breaks EVERY session on the host).
|
||||
|
||||
Steps (all cleanup in try/finally so a mid-way failure never orphans an intermediate image or leaves the container paused):
|
||||
|
||||
1. `docker commit -c 'LABEL codeman.exported=1' codeman-case-<slug> codeman/export-<slug>:<ts>` (unique tag per export defeats the stale-image trap). Optional pre-commit scrub in sealed mode as above; also blank instance-specific committed env (`-c 'ENV CODEMAN_API_URL='` etc.) so the image carries no stale host references.
|
||||
2. `docker save codeman/export-<slug>:<ts> | gzip` streamed in fixed 8192-byte chunks to `~/.codeman/docker-exports/<slug>-<ts>.image.tar.gz`. Uses `docker save` (layers + repo:tag + CMD), never `docker export` (flat rootfs), so restore is a trivial `docker load`.
|
||||
3. `tar --numeric-owner -C <hostWorkspacePath> -czf <slug>-<ts>.workspace.tar.gz .` while paused (the bind-mounted workspace is NOT in the image, so it travels separately and consistently).
|
||||
4. Write `manifest.json`: schema version, caseName, image tag, engine, containerWorkdir, resource/network config, codeman version, base-image digest, createdAt, per-member sha256, `mountCredentials`, and `secretFree` (true only for convenient-mode or scrubbed-sealed exports).
|
||||
5. `docker rmi codeman/export-<slug>:<ts>` in the `finally` (delete the intermediate committed image regardless of success), then `docker unpause`.
|
||||
|
||||
The three files are wrapped in one bundle `<slug>-<ts>.codeman-container.tgz` and offered as a downloadable artifact through the existing file-routes streaming + attachment-registry handoff.
|
||||
|
||||
Retention / disk budget (user-decision 3): `docker-exports/` is capped at `DOCKER_EXPORT_KEEP` most-recent bundles with an auto-prune on each new export, plus the free-space precheck above. Workspace scrub: the WORKSPACE tar gets a scan/warn pass for agent-created `.env` / `.git/credentials` (a distinct leak channel from container creds). A lighter "workspace-only" export (just the workspace tar, no commit/save) is the fast default for 24h+ runs; full-image is the explicit heavier option (user-decision 7 in the original list, now decision on the default button below).
|
||||
|
||||
What travels: the baked toolchain image plus any in-image writes, and the workspace tar. What does NOT travel: bind-mounted credentials (physically excluded from commit) and anything that lived only in a bind mount. Secret-free by construction in convenient mode, and enforced (refuse-or-scrub) in sealed mode.
|
||||
|
||||
IMPORT `POST /api/docker-cases/import` (untrusted-bundle containment, the traversal/overwrite risk): stream the uploaded bundle, validate every manifest checksum BEFORE any extraction or load. Extract the workspace tar with `tar --no-absolute-names -C <fresh dir>` PLUS per-entry validation rejecting any member whose normalized path escapes the destination (leading `/` or `..` components). `gunzip | docker load` the image, then RE-TAG the loaded image id into a quarantined namespace `codeman/imported-<slug>:<ts>` and NEVER allow the load to overwrite `codeman/agent:base` or any pre-existing tag (capture the loaded id, ignore the bundle's repo:tag). Create a NEW `DockerCase` pointing at the quarantined image with THIS host's mounts/creds and the manifest's resource/network config, and recreate the container hardened (cap-drop ALL, no-new-privileges, non-root, `--pull=never`, CMD overridden to `sleep infinity`). The destination supplies its own login, so credentials never cross machines. Plus `GET /api/docker-exports` (list) and `DELETE /api/docker-exports/:filename`, all behind Codeman's existing auth / loopback-default / host-guard / Origin-CSRF stack.
|
||||
|
||||
## 6. Codeman integration (file-by-file, mirroring the remote-SSH feature)
|
||||
|
||||
- `src/types/session.ts`: add `DockerCommandMode`, `DockerEngine`, `DockerNetworkMode`, `DockerResourceLimits`, `DockerHost`, `DockerCase`, `SessionDocker` (Section 3). Add `docker?: SessionDocker` to `SessionState` after line 219. SessionMode (line 44) UNCHANGED.
|
||||
- `src/mux-interface.ts`: add `docker?: SessionDocker` to `MuxSession` (38), `CreateSessionOptions` (81), `RespawnPaneOptions` (105).
|
||||
- `src/docker-hosts.ts` (NEW, direct mirror of `src/remote-hosts.ts`): `readDockerHosts`/`writeDockerHosts`/`readDockerCases`/`writeDockerCases` (via `dataPath`, including `lastClaudeSessionId` read/write), `defaultDockerCommandForMode` (mirror line 60), `dockerDisplayPath` (`container:/path`, mirror `remoteDisplayPath` at 205), `toSessionDocker(host, case)` (mirror `toSessionRemote` at 212), `buildDockerBaseArgs`/`buildDockerCreateArgs` (per-engine uid/userns branch), `hostGatewayAlias(engine)`, `containerApiUrl(processApiUrl, engine)` (scheme+port-preserving, unit-tested), `checkDockerAvailable`/`checkDockerTmuxAvailable`/`probeDockerCliVersion` (15s-bounded, `IS_TEST_MODE` no-op), a config-hash helper for drift, its own POSIX `shellescape` copy (mirror line 83). `const IS_TEST_MODE = !!process.env.VITEST;` gates every real `docker` invocation.
|
||||
- `src/tmux-manager.ts`: add `DOCKER_TMUX_SOCKET`, `dockerTmuxSessionName`, `buildDockerLaunchCommand` (resume-aware, image-check, env-prime), `buildDockerKillCommand` (Section 4). Extend the two `fullCmd` ternaries (1276, 1524) and the two `launchCmd` cd-skips (1327, 1542). Add `killSession` Strategy 3c after 1719. Ensure `reconcileSessions` (~1800-1815) does NOT hard-delete docker sessions on local-tmux death (recovery relaunch path).
|
||||
- `src/session.ts`: add `_docker?: SessionDocker` field (mirror `_remote` at 403), constructor arg (477), assignment (550). Thread `docker: this._docker` and `resumeSessionId: this._claudeSessionId` into BOTH `createSessionOptions` and `respawnPaneOptions` in `startInteractive` (1352/1370) and the second path (1740/1750). Emit `docker: this._docker` in `toState()` (1010). Replace the LOCAL cliVersion probe at 1320 for docker with the IN-CONTAINER `probeDockerCliVersion` (do not merely skip it). Extend `resolveMuxAttachCwd(workingDir, remote, docker)` (215) to return `/tmp` when `docker` is set. On claudeSessionId capture, persist it to the owning `DockerCase.lastClaudeSessionId`.
|
||||
- `src/web/server.ts`: in `restoreMuxSessions` (2160), add `docker: muxSession.docker ?? savedState?.docker` to the `new Session({...})` call (2195-2216), and skip docker in the same `isExternalCliMode`/Ralph recovery guards as remote. Register the instance-scoped boot reaper to run AFTER docker-cases load and AFTER `restoreMuxSessions`. Ensure `CODEMAN_API_URL` derivation reads the SAME `process.env.CODEMAN_API_URL` the server sets at ~2000.
|
||||
- `src/web/schemas.ts`: add `DockerHostSchema` and `DockerCaseLinkSchema` (below). The three mode enums (177/373/705) and `QuickStartSchema` (368) UNCHANGED (docker resolves by `caseName` lookup like remote).
|
||||
- `src/web/routes/session-routes.ts`: import the docker helpers from `../../docker-hosts.js`. Add a docker branch in `/api/quick-start` parallel to the remote branch (1686-1720): `readDockerCases` -> find by `caseName` -> `readDockerHosts` -> find by `hostId`; reject `envOverrides`/`effort`/`codexConfig`/`geminiConfig`/`openCodeConfig` (but ACCEPT `modelOverride`, which flows via scaffolded `settings.local.json`); run `checkDockerAvailable` + `checkDockerTmuxAvailable` (image-present, engine, caps-enforced); surface `capsEnforced:false` and Desktop notes; set `casePath = dockerCase.hostWorkspacePath` (REAL host dir), `docker = toSessionDocker(host, dockerCase)`, and seed `resumeSessionId` from `dockerCase.lastClaudeSessionId` when `resumeOnStart`. Extend the LOCAL-availability and local-spawn guards (around 1796/1810) to `!remote && !docker`, but DO NOT extend the workspace-scaffolding guard (~1776, `writeHooksConfig`/`updateCaseModel`), which MUST run for docker. Pass `docker` into `new Session` (1847); `autoConfigureRalph` (1853) gated on `!docker`. Add `docker: m.docker !== undefined ? true : undefined` to the unified harvest (2313).
|
||||
- `src/web/routes/case-routes.ts`: import the docker read/write/check helpers + schemas. Add a docker listing loop in `GET /api/cases` (mirror 94-119, `location: 'docker'`, `docker: {...}` via `dockerDisplayPath`). Add `/api/docker-hosts` GET/POST/PUT/DELETE (mirror 168-204) and `POST /api/cases/docker-link` (mirror 206-232; run `checkDockerAvailable`/`checkDockerTmuxAvailable` at link time; broadcast `CaseLinked` with `type: 'docker'`). Add a docker-unlink branch to `DELETE /api/cases/:name` (mirror 288-296; `docker rm -f`; broadcast `CaseDeleted` `type: 'docker-unlinked'`). Add the docker branch to single-case `GET` (mirror 358-368). Add `POST /api/docker-cases/:name/export`, `/import`, `GET/DELETE /api/docker-exports`, and a `POST /api/docker-cases/:name/recreate` (drift confirm) per Sections 4 and 5.
|
||||
- `src/web/sse-events.ts` + `src/web/public/constants.js`: reuse `CaseLinked`/`CaseDeleted` for CRUD. Add `docker:exportProgress`, `docker:exportComplete`, `docker:importComplete`, `docker:configDrift`, and `docker:containerError` to BOTH registries (kept in sync per CLAUDE.md).
|
||||
- Frontend `src/web/public/index.html` (~1831): add a Docker `modal-tab-btn` next to Remote; add a `#case-docker` panel mirroring `#case-remote` with `dockerCaseName`, `dockerHostWorkspacePath`, `dockerContainer`, `dockerImage`, `dockerHostId`, and an Advanced `<details>` for network mode, resource caps, `mountCredentials`, `resumeOnStart`, and remote daemon. Surface a "scaffolds .claude into this host dir" note (user-decision 4) and a "resource caps advisory on this engine" warning when `capsEnforced:false`.
|
||||
- Frontend `src/web/public/session-ui.js`: `formatCasePickerLabel` (48) + `buildCasePickerOptions` (71-73) handle `location === 'docker'` (`name @ container`, add container/image to the search haystack); `resetCaseModalFields` (~1514) add a `dockerFields` array; `switchCaseModalTab` (1573/1580/1597) handle `'case-docker'`; `submitCaseModal` add the docker branch; new `linkDockerCase()` (mirror `linkRemoteCase` at 1689) POSTing `/api/docker-hosts` then `/api/cases/docker-link`, sending omitted optionals as `undefined` (spread `...(x ? {x} : {})`, never `null`, per the Zod `.optional()`-rejects-null gotcha); `runClaude` (520) / `runShell` (702) extend the `location === 'remote'` routing to also match `'docker'`; `runOpenCode`/`runCodex`/`runGemini` (792/846/900) make the `isRemote` checks `isRemoteOrDocker` so local status probes are skipped. In the session-options Summary tab, note that `effort` is inert for docker (rejected) while `model` IS honored via `settings.local.json`.
|
||||
- Frontend `src/web/public/panels-ui.js` (425-426): add `caseItem?.docker?.path`/`container` to the case-search fields.
|
||||
|
||||
Schemas (`src/web/schemas.ts`), mirroring `RemoteHostSchema` (299) / `RemoteCaseLinkSchema` (351):
|
||||
|
||||
```ts
|
||||
export const DockerHostSchema = z.object({
|
||||
id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
label: z.string().min(1).max(100),
|
||||
engine: z.enum(['docker', 'podman']).optional(),
|
||||
image: z.string().min(1).max(512).regex(/^[a-zA-Z0-9][\w./:@-]*$/, 'Invalid image ref').regex(NO_SHELL_META),
|
||||
daemonHost: z.string().max(512).regex(NO_SHELL_META, 'Invalid daemon host').optional(),
|
||||
context: z.string().max(128).regex(/^[a-zA-Z0-9._-]+$/, 'Invalid context').optional(),
|
||||
network: z.enum(['bridge', 'none', 'custom']).optional(),
|
||||
networkName: z.string().max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/).optional(),
|
||||
resources: z.object({
|
||||
memory: z.string().regex(/^\d+[bkmg]?$/i).optional(),
|
||||
cpus: z.string().regex(/^\d+(\.\d+)?$/).optional(),
|
||||
pidsLimit: z.number().int().positive().max(100000).optional(),
|
||||
nofile: z.string().regex(/^\d+:\d+$/).optional(),
|
||||
shmSize: z.string().regex(/^\d+[bkmg]?$/i).optional(),
|
||||
}).strict().optional(),
|
||||
mountCredentials: z.boolean().optional(),
|
||||
hooksEnabled: z.boolean().optional(),
|
||||
resumeOnStart: z.boolean().optional(),
|
||||
commands: RemoteCommandOverridesSchema, // reuse the shared shape
|
||||
extraCreateArgs: z.array(z.string().min(1).max(1024).regex(NO_SHELL_INJECTION).refine(noCommandSubstitution)).max(32).optional(),
|
||||
extraExecArgs: z.array(z.string().min(1).max(1024).regex(NO_SHELL_INJECTION).refine(noCommandSubstitution)).max(32).optional(),
|
||||
});
|
||||
|
||||
export const DockerCaseLinkSchema = z.object({
|
||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
|
||||
hostWorkspacePath: z.string().min(1).max(2000).regex(/^\//, 'Path must be absolute').regex(NO_SHELL_META, 'Invalid characters in workspace path'),
|
||||
containerWorkdir: z.string().min(1).max(2000).regex(/^\//).regex(NO_SHELL_META).optional(),
|
||||
container: z.string().min(2).max(128).regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name').optional(),
|
||||
});
|
||||
```
|
||||
|
||||
`NO_SHELL_META` (rejects `$`/backtick, schemas.ts:297) is REQUIRED on `image`, `hostWorkspacePath`, `containerWorkdir`, and `container`, because all four reach the outer `bash -c "..."` double-quote layer where `$(...)`/backtick re-expose, exactly the reason `remotePath`/`identityFile` use it. `--privileged` and any `-v /var/run/docker.sock` are structurally unrepresentable (never emitted by the builder, never accepted by the schema).
|
||||
|
||||
## 7. Security model
|
||||
|
||||
- Hardening flags on every create: `--cap-drop ALL`, `--security-opt no-new-privileges` (NOT auto-set by rootless Docker or Podman, so always explicit), the uid/userns branch of Key decision 6 (never container-root; workspace files stay host-owned and HOME stays writable via GID 0), `--pids-limit` (fork-bomb guard), `--memory` with `--memory-swap == --memory` (real OOM cap), `--ulimit nofile`, `--init`, `--pull=never`. NEVER `--privileged`, NEVER mount the docker socket into the agent container. `--storage-opt size=` is emitted ONLY after the probe confirms overlay2-on-xfs-pquota or btrfs (the AICE-class silently-ignored trap); otherwise it is omitted and the UI does not advertise a size cap. Resource caps are advertised as ENFORCED only when the probe reports `capsEnforced:true`; under non-delegated rootless they are labeled advisory (user-decision 6).
|
||||
- Engine: prefer whichever the probe finds, Podman-rootless first for security (a container-root breakout lands as an unprivileged host user). Rootless bind-mount ownership uses `--userns=keep-id` (Podman) vs `--user <hostUid>:0` (Docker), so real per-engine branching lives in `buildDockerCreateArgs`. Docker Desktop takes its own uid path (Key decision 6).
|
||||
- Blast radius (the combined-posture the critic asked to surface, user-decision 5): the default convenient profile mounts an arbitrary host workspace dir RW (host-owned, mirrored path) AND host `~/.claude`/`~/.codex`/`~/.gemini`/`~/.config/gcloud`/`~/.config/opencode` RW into a NETWORK-ENABLED container. Container-run agent code can therefore read/modify those host trees and reach the network simultaneously. This is still a strict improvement over today's on-host skip-permissions execution, but the user must accept the combined posture explicitly; the sealed profile plus `network:none` is the mitigation for genuinely untrusted work.
|
||||
- Secret handling: creds arrive ONLY as bind-mounted files (default) or exec-time NAME-ONLY `--env` (codex/gemini keys), NEVER as create-time `-e` and NEVER as an image layer. Sealed-mode export is refuse-or-scrub (Section 5), closing the sealed-leak inversion.
|
||||
- CLAUDE.md "Multi-CLI prefix discipline": the exec-time name-only env is restricted to the CLI-specific keys per mode (Claude: none with OAuth mount; Codex: `OPENAI_API_KEY`/`CODEX_API_KEY`; Gemini: `GEMINI_API_KEY`/`GOOGLE_*`), never a blanket forward. `envOverrides` is rejected for docker, so the `ALLOWED_ENV_PREFIXES` allowlist is not widened.
|
||||
- hook-secret: bind-mounted read-only, referenced via `CODEMAN_HOOK_SECRET_FILE` (a path, non-secret); the secret bytes never enter env or the image. Both `host.docker.internal` and `host.containers.internal` are added to the host-guard allowlist so the in-container hook curl's Host header passes on either engine.
|
||||
- Host guard / instance isolation: the in-container tmux socket (`codeman-docker`) and name (`codeman-dkr-<id8>`) deliberately FAIL a container-internal Codeman's `SAFE_MUX_NAME_PATTERN`, so a nested Codeman never adopts our session (unit-asserted). The boot reaper is instance-scoped by the `codeman.instance` label so a beta never reaps prod. Any remote-daemon (`-H`/`--context`) mode is host-root-equivalent and stays strictly behind the existing auth/loopback/host-guard/Origin-CSRF stack.
|
||||
- Import containment: untrusted bundles are checksum-validated, extracted with traversal guards, and loaded into a quarantined image namespace (never overwriting the base image), then run with the same hardening.
|
||||
|
||||
## 8. Phased implementation (branch: `feat/docker-session-mode`)
|
||||
|
||||
Each phase is independently testable; per CLAUDE.md, end-to-end test in the real env before COM. All new docker IO paths carry `const IS_TEST_MODE = !!process.env.VITEST;` and no-op under it; the pure command builders are tested directly.
|
||||
|
||||
- Phase 0: base image + engine probe. Author `docker/agent.Dockerfile` (OpenShift arbitrary-uid HOME) and `scripts/build-agent-image.mjs` (build or pull the base image; digest recorded). Add `checkDockerAvailable`/`checkDockerTmuxAvailable`/`containerApiUrl`/`hostGatewayAlias` (IS_TEST_MODE no-op) and `GET /api/docker/status`. Test: probe stub returns available/caps/Desktop flags under VITEST; `containerApiUrl` preserves scheme+port and swaps host per engine; status route returns the envelope.
|
||||
- Phase 1: types + storage + schemas. Add all types (Section 3), `src/docker-hosts.ts`, `DockerHostSchema`/`DockerCaseLinkSchema`. Test: `docker-hosts.test.ts` (round-trip incl. `lastClaudeSessionId`, display path, config-hash stability); `docker-exec-options.test.ts` (schema rejects `$`/backtick in image/workdir/container).
|
||||
- Phase 2: tmux-manager builders. Add `DOCKER_TMUX_SOCKET`, `dockerTmuxSessionName`, `buildDockerLaunchCommand` (resume-aware, image-check, env-prime), `buildDockerKillCommand`; wire the two ternaries + two cd-skips + Strategy 3c; harden `reconcileSessions` against docker hard-delete. Test (pure strings): adopt-proof name fails `SAFE_MUX_NAME_PATTERN`; image-check precedes create; `new-session -A` idempotent; resume flag present only when a resume id is passed; `--pull=never` present; instance label present; escaping survives `bash -c` -> `docker exec` -> `sh -lc` -> tmux WITH a host workspace path containing spaces.
|
||||
- Phase 3: session.ts + mux + recovery. Add `_docker` + `resumeSessionId` threading, in-container cliVersion probe, `resolveMuxAttachCwd`, mux-interface fields, `restoreMuxSessions` passthrough, instance-scoped reaper wiring, claudeSessionId -> `DockerCase.lastClaudeSessionId` persistence, unified flag. Test: `toState()` emits docker; a persisted docker session round-trips through mux/state; a relaunch injects the persisted resume id (mock mux); reaper only targets this instance's orphaned containers.
|
||||
- Phase 4: routes + first real e2e. case-routes CRUD + listing + drift-recreate; session-routes quick-start branch (scaffolding RUNS, local-availability guards skip, model accepted, effort/config rejected). Manual e2e on a real docker host: docker-host create -> docker-link -> quick-start; confirm the pane runs `claude` in the container, files land host-owned, a Codeman restart reattaches the SAME live agent, and a `docker stop` followed by relaunch RESUMES the conversation.
|
||||
- Phase 5: hooks connectivity + installation. host-gateway (per engine), derived `CODEMAN_API_URL`, hook-secret mount, `CODEMAN_SESSION_ID`/`CODEMAN_MUX` exec-env + tmux setenv, host-guard allowlist, and the scaffolding write into the real workspace. Manual e2e: trigger a permission prompt from inside the container and confirm it surfaces; verify hook payloads carry the right session id. If deferred, ship docker as explicitly hook-degraded and verify output-based idle detection through the docker-exec PTY.
|
||||
- Phase 6: export/import + GC + disk safety. quiesce+pause span, free-space precheck, commit+save+gzip + workspace tar + manifest + streaming download; sealed-mode refuse-or-scrub; retention/auto-prune; import with checksum validation + traversal guard + quarantined re-tag; drift-recreate; boot reaper; `runWithConversionLimit` cap; `docker rmi` in finally. Manual e2e: export, `docker load` on a second machine (or fresh case), import, confirm toolchain + workspace restored and NO creds present; attempt a sealed full-image export and confirm it is refused-or-scrubbed; attempt a `../` bundle and confirm it is rejected.
|
||||
- Phase 7: frontend. Docker tab, `linkDockerCase`, run wiring, case-picker labels, panels search, caps-advisory + scaffold-warning + effort-inert notes. Verify with Playwright (`waitUntil: 'domcontentloaded'`, 3-4s settle) that the Docker tab renders and a linked docker case appears in the picker.
|
||||
- Phase 8: docs + COM. Update CLAUDE.md (a "Docker cases" Key Pattern paragraph mirroring remote-SSH, plus the new state files, routes counts, and the resume/durability model), `docs/docker-cases.md`, then COM per the standard flow.
|
||||
|
||||
## 9. Test plan
|
||||
|
||||
- Unit (pure, CI-safe, mirror `test/remote-hosts.test.ts` / `test/remote-ssh-options.test.ts`):
|
||||
- `test/docker-hosts.test.ts`: storage round-trip (incl. `lastClaudeSessionId`), `dockerDisplayPath`, `defaultDockerCommandForMode`, `toSessionDocker`, `containerApiUrl` (http/https, custom port, docker vs podman gateway), config-hash stability/drift, `buildDockerCreateArgs` flag ordering (cap-drop/no-new-privileges/memory==memory-swap/instance-label/`--pull=never` present; host/privileged/socket absent; per-engine uid vs `--userns=keep-id`).
|
||||
- `test/docker-exec-options.test.ts`: `buildDockerLaunchCommand`/`buildDockerKillCommand` string shape and escaping through `bash -c` -> `docker exec` -> `sh -lc` -> tmux, including a workspace path with spaces; resume flag present only with a resume id; image-presence check precedes create; `dockerTmuxSessionName` fails `SAFE_MUX_NAME_PATTERN`; schema rejects `$`/backtick in image/workdir/container/name; `linkDockerCase`-shaped bodies with omitted optionals validate (no `null` on the wire).
|
||||
- Probe no-op: `checkDockerAvailable`/`checkDockerTmuxAvailable`/`probeDockerCliVersion` return canned values under VITEST and never spawn.
|
||||
- Integration (route tests via `app.inject()`, docker no-op'd): `/api/docker-hosts` CRUD; `/api/cases/docker-link` dup-check + broadcast; `GET /api/cases` includes the docker case with `location: 'docker'`; `/api/quick-start` docker branch rejects `envOverrides`/`effort`/config but ACCEPTS `modelOverride`, runs the workspace-scaffolding path, and constructs a session with `docker` set + seeded resume id; `DELETE /api/cases/:name` docker-unlink; export refuse-or-scrub for sealed; import traversal rejection; reaper instance-scoping (label filter). Pick a unique port only if a live-server test is added (search `const PORT =`; 3150+).
|
||||
- Manual end-to-end (real docker daemon, the mandatory "always end-to-end test" gate): build the base image; link a docker case; quick-start `claude`; verify OAuth via the mounted `~/.claude`, transcript correlation (subagent/workflow watchers show the session), host-owned files, and a working permission-prompt hook; reattach after a Codeman PROCESS restart (SAME live agent); `docker stop` then relaunch and confirm conversation RESUME; reboot-equivalent (daemon restart) and confirm boot recovery recreates+resumes; change the host's memory/image and confirm the drift-recreate prompt fires; export (convenient) and confirm the tar `docker load`s with no creds; attempt a sealed full-image export and confirm refuse-or-scrub; import into a fresh case; delete the case and confirm `docker rm -f` plus instance-scoped reaper GC; confirm a docker-down state surfaces a docker-specific error and does NOT trip the generic PTY-exit breaker.
|
||||
|
||||
## 10. Open decisions for the user
|
||||
|
||||
1. Credential + blast-radius posture (combined). Convenient default bind-mounts host `~/.claude` etc. RW AND an arbitrary host workspace RW into a network-enabled container, so container-run agent code can read/modify those host trees and reach the network at the same time. Recommended: convenient default plus a per-host SEALED opt-in (`mountCredentials:false` + `network:none`) for untrusted work. Please confirm you accept the combined arbitrary-workspace-plus-egress-plus-host-creds posture for the default profile (it is still a net improvement over today's on-host skip-permissions execution).
|
||||
2. Base image ownership, registry, and freshness. The `codeman/agent:base` placeholder implies a Docker Hub org the project may not own. Pick the real registry/namespace (GHCR under the repo is the natural fit), decide digest pinning, and set a REBUILD CADENCE so agents are not stuck on a stale baked `claude` (the in-container version probe surfaces staleness, but something must trigger rebuilds). Choose: pull a pinned published image, build locally on first use via `scripts/build-agent-image.mjs`, or both.
|
||||
3. Container CWD strategy. Mirror the host workspace path inside the container (recommended: makes transcript projHash correlate, file features and resume capture work) vs a fixed `/workspace` (simpler mount, breaks watcher correlation). Please confirm the mirror approach.
|
||||
4. Hooks in the MVP AND workspace scaffolding. Making docker hooks fire requires WRITING `.claude/settings.local.json` (and the CLAUDE.md scaffold) into the user's REAL linked host directory, a behavioral shift from "link a dir" to "link and scaffold a dir." Choose: wire hooks + scaffolding now (Phase 5, recommended, and it also enables the model picker), or ship docker as explicitly hook-degraded (no permission prompts / hook-idle) for v1 and add later. Confirm you are OK with Codeman mutating the linked host workspace.
|
||||
5. Session-kill teardown and RESUME (reframed honestly). `docker stop` on session kill is not merely "free RAM vs instant reattach": it destroys the in-container live agent, and the conversation survives ONLY because the next launch runs `--resume` from the bind-mounted transcript. Choose: keep the container running (costs RAM, preserves the exact live in-flight agent) vs stop and rely on `--resume` (frees RAM, may lose uncommitted in-flight tool state). Case-delete always `docker rm -f`.
|
||||
6. Rootless enforcement posture. Under rootless without cgroup-v2 systemd delegation, `--memory`/`--cpus`/`--pids-limit` are SILENTLY ignored. Choose: REQUIRE delegation (refuse to link a host that cannot enforce caps) or ship-with-warning ("resource caps are advisory on your engine"). The probe reports `capsEnforced` either way.
|
||||
7. Default resume behavior. Should a re-linked or re-run docker case default to resuming its last conversation (`resumeOnStart:true`, using `DockerCase.lastClaudeSessionId`) rather than starting clean? This is the crux of making the durability story real and is the recommended default, but it changes user-visible behavior (a new session in an existing case continues the prior conversation).
|
||||
8. Export defaults and disk budget. Default export button: workspace-only (fast, small, files-only, recommended for 24h+ runs) vs full-image (reproducible env, multi-GB). Also set the retention cap (max retained exports), the auto-prune policy, and the free-space threshold below which export is refused (a full `/var/lib/docker` breaks EVERY session on the host, not just docker ones).
|
||||
9. Remote docker daemon (`-H ssh://...` / `--context`). Support in the MVP (composes with remote hosts, adds host-root trust surface) or local-daemon-only first.
|
||||
10. Podman parity depth. Full `--userns=keep-id` plus Quadlet boot-persistence, or Docker-first with Podman as best-effort and boot-persistence via Codeman's idempotent create-if-missing only. Note the podman host alias is `host.containers.internal`, already handled per engine.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Docker cases
|
||||
|
||||
Run a case inside an **isolated Docker container** instead of directly on the host. Any number of Codeman sessions can share one container (it is scoped to the case, not the session), so a whole project lives in a sandbox with its own network, resource caps, and filesystem, and you can **export the container to move it to another machine**.
|
||||
|
||||
Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` all work inside the container.
|
||||
|
||||
## One-time setup: build the base image
|
||||
|
||||
The container needs a base image with the agent toolchain (node, the CLIs, git, tmux). Build it locally once:
|
||||
|
||||
```bash
|
||||
node scripts/build-agent-image.mjs # builds codeman/agent:base
|
||||
# options: --engine docker|podman --image <ref> --no-cache
|
||||
```
|
||||
|
||||
The image is **secret-free**: credentials are delivered at runtime (bind mounts or `docker exec --env`), never baked in, so exports never leak them.
|
||||
|
||||
## Quickest path: one-click "Run in Docker"
|
||||
|
||||
On the **New case → Create New** tab there's a **🐳 Run in an isolated Docker container** checkbox. Checking it alone is enough: Codeman creates the case folder in `~/codeman-cases/<name>`, spins up a hardened container with sensible defaults (auto-provisioning a shared `default` host), and starts the session inside it. No host/image/network fields to fill in.
|
||||
|
||||
Click the checkbox's **Container settings** to optionally tweak the predefined defaults, including a **Template** picker:
|
||||
|
||||
| Template | Memory | CPUs | GPUs |
|
||||
|----------|--------|------|------|
|
||||
| Small | 2 GB | 1 | none |
|
||||
| Medium (default) | 4 GB | 2 | none |
|
||||
| Large | 8 GB | 4 | none |
|
||||
| GPU | 8 GB | 4 | all (needs the NVIDIA container toolkit) |
|
||||
|
||||
**Disk is elastic** — the container's storage grows automatically as data flows in; there is no fixed cap (bounded only by host disk). Any tweaked setting creates a dedicated per-case host so it never changes the shared `default`.
|
||||
|
||||
## Create a docker case (full control)
|
||||
|
||||
App → **New case → Docker** tab:
|
||||
|
||||
- **Case Name** / **Workspace Path**: the workspace is a real HOST directory bind-mounted into the container at the same path. Codeman scaffolds `CLAUDE.md` + `.claude/settings.local.json` (hooks) into it, and file previews / attachments work on the real bytes.
|
||||
- **Host ID**: a reusable docker host profile (image, network, resources). Reuse the same ID across cases to share settings.
|
||||
- **Network**: `bridge` (internet on, default), `none` (fully isolated), or a `custom` bridge.
|
||||
- **Advanced**: memory / CPU caps, **Mount host credentials** (on = your existing `~/.claude` login just works; off = a sealed sandbox you log into inside the container), **Resume last conversation on relaunch**.
|
||||
|
||||
Then run it like any case (Run Claude / Run Shell / …). The first launch creates the container (`codeman-case-<name>`); subsequent sessions attach to the same one.
|
||||
|
||||
Equivalent API:
|
||||
|
||||
```bash
|
||||
curl -X POST localhost:3000/api/docker-hosts -d '{"id":"local","label":"Local","image":"codeman/agent:base"}'
|
||||
curl -X POST localhost:3000/api/cases/docker-link -d '{"name":"sandbox","hostId":"local","hostWorkspacePath":"/home/you/projects/sandbox"}'
|
||||
curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"claude"}'
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- **Reconnect after a Codeman restart** lands back in the same live agent (the in-container tmux survives).
|
||||
- **Container stop / host reboot** restarts the container and **resumes** the last conversation from the bind-mounted transcript. Claude sessions launch with a pinned conversation id (`--session-id <sessionId>`, with a `--resume` fallback when the transcript already exists), and the case remembers its last conversation (`lastClaudeSessionId`), so a relaunch after the container was stopped, rebooted, or recreated continues where it left off.
|
||||
- **Killing one session** only kills that session's in-container tmux session; the shared container stays up for sibling sessions.
|
||||
- **Editing the docker host config** (image, memory, network, ...) is detected on the next launch: the desired config hash is compared against the container's `codeman.confighash` label, and a mismatch refuses the launch with a "config changed, recreate?" confirm. Confirming calls `POST /api/docker-cases/:name/recreate` (refused while sessions of the case are live), which removes the container so the next launch recreates it with the new config; the workspace and the conversation survive.
|
||||
- **Deleting the case** `docker rm -f`s the container (the bind-mounted workspace on the host survives). An instance-scoped boot reaper removes containers whose case is gone.
|
||||
|
||||
## Isolation & security
|
||||
|
||||
Every container runs hardened: `--cap-drop ALL`, `--security-opt no-new-privileges`, non-root (`--user <hostUid>:0` so workspace files stay host-owned), `--pids-limit`, `--memory` == `--memory-swap`, `--init`. Never `--privileged`, never the docker socket. The default **convenient** profile bind-mounts host credential dirs read-write so the common login just works (creds stay on the host, never captured by `docker commit`); the **sealed** profile (`mountCredentials:false` + `network:none`) is the opt-in for genuinely untrusted work.
|
||||
|
||||
Rootless engines without cgroup-v2 systemd delegation cannot enforce resource caps; linking such a host warns that caps are advisory.
|
||||
|
||||
## Export / Import (move to another machine)
|
||||
|
||||
**Export** (from the Docker tab, or `POST /api/docker-cases/:name/export`): choose
|
||||
|
||||
- **Full image + workspace**: `docker commit` the container to an image, `docker save` it, tar the workspace, and a manifest, all into one portable `<case>-<ts>.codeman-container.tgz` (the whole toolchain, installed packages, and files). Runs in the background; you are notified when the bundle is ready.
|
||||
- **Workspace only**: just the project files (fast, small).
|
||||
|
||||
The container is paused across the capture so the image and workspace are consistent; a full `/var/lib/docker` is guarded against with a free-space precheck; the intermediate image is always cleaned up.
|
||||
|
||||
**Import** (`POST /api/docker-cases/import`, or the Manage tab): copy the `.tgz` onto the new machine's `~/.codeman/docker-exports/`, then import it into a new case. The manifest and per-member SHA-256 checksums are validated, the workspace tar is extracted with a path-traversal guard, and the image is `docker load`ed and **re-tagged into a quarantined namespace** (`codeman/imported-<case>:<ts>`) so it never overwrites a local tag. The destination supplies its own credentials, so nothing secret crosses machines.
|
||||
|
||||
`GET /api/docker-exports` lists bundles; `GET /api/docker-exports/:filename` downloads one; `DELETE` removes one.
|
||||
|
||||
## Hooks require the server to be reachable from the container
|
||||
|
||||
In-container hooks (permission events, hook-based idle/stop/task notifications) POST to `CODEMAN_API_URL`, which is derived as `https://host.docker.internal:<port>` (`host.docker.internal` → the docker bridge gateway, e.g. `172.17.0.1`, via `--add-host …:host-gateway`). For that callback to succeed, the Codeman server must be **listening on an interface the container can reach**.
|
||||
|
||||
- If Codeman binds **loopback-only** (`127.0.0.1`, the default and the production systemd config), a container reaching `172.17.0.1:<port>` cannot connect, so by default **in-container hooks do not fire**. The session still works fully: idle/stop detection falls back to **output-based** detection through the `docker exec` PTY (which always works), and claude runs with `--dangerously-skip-permissions` so there are no permission prompts to forward anyway.
|
||||
- **To enable in-container hooks on a loopback-only server, set `CODEMAN_DOCKER_BRIDGE_HOOKS=1`** (env). Codeman then starts a SECOND listener bound to the docker bridge gateway (`172.17.0.1`, auto-detected; override with `CODEMAN_DOCKER_BRIDGE_HOST`) that serves **only the hook endpoints** (`/api/hook-event`, `/api/status-telemetry`) and delegates them into the same secret-gated pipeline. The bridge is host-internal (containers + host, not the LAN), and every other path returns `403`, so this does not widen your network exposure. Add `Environment=CODEMAN_DOCKER_BRIDGE_HOOKS=1` to the systemd unit and restart.
|
||||
- Alternatively, bind `0.0.0.0` **with `CODEMAN_PASSWORD` set** (exposes on the LAN too).
|
||||
|
||||
The host-gateway mapping, `CODEMAN_API_URL` derivation, host-guard allowlist, and hook-secret mount are all wired correctly; `CODEMAN_DOCKER_BRIDGE_HOOKS` closes the last gap for loopback-only servers.
|
||||
|
||||
## Notes & limits
|
||||
|
||||
- Requires Docker (or Podman) with a reachable daemon; tmux must be present in the base image (a hard prerequisite, probed at link time).
|
||||
- Per-session `envOverrides` / `effort` / per-CLI config are rejected for docker cases (they do not cross into the container); configure the container via the docker host's per-mode command override instead.
|
||||
- macOS Docker Desktop takes a dedicated uid path (the baked image uid; memory caps are subject to the VM ceiling).
|
||||
|
||||
Design + rationale: [`docker-cases-plan.md`](./docker-cases-plan.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 |
@@ -0,0 +1,282 @@
|
||||
# Multi-User Mode: Design Plan
|
||||
|
||||
Status: **IMPLEMENTED on `feat/multiuser-mode`** (phases 1-5; opt-in, off by default). Target: opt-in multi-user support behind a `--multiuser` flag, with per-user case spaces and an admin panel for user management.
|
||||
|
||||
Shipped by phase:
|
||||
|
||||
- **Phase 1** (user store + mode plumbing + CLI): `src/user-store.ts` (scrypt, atomic 0600 writes, last-admin invariants, serialized read-modify-write), `src/config/multiuser.ts`, `codeman users add|passwd|list|rm`, `--multiuser` flag, bootstrap-on-first-boot. Tests: `test/user-store.test.ts`.
|
||||
- **Phase 2** (multi-user auth): parallel async auth branch (`src/web/middleware/auth.ts`), `req.authUser`, per-username rate bucket, `mustChangePassword` lockbox, `GET /api/me` + `POST /api/me/password`, QR identity-bound minting, network-bind + tunnel exemptions, new error codes. Tests: `test/multiuser-auth.test.ts`.
|
||||
- **Phase 3** (ownership threading): `Session.owner` at every create path + recovery mirror; `findSessionOrFail` owner check + list filtering; §6.3 permission policy (`resolveClaudeModeForUser` at all spawn sites incl. one-shots via `buildPromptArgs`; shell/launchCommand grant); per-user case spaces (`resolveCasesDir`) + owner-scoped case list + admin-only host CRUD; `workingDir` confinement; `sessionCapacityState` per-user cap. Tests: `test/ownership-scoping.test.ts`.
|
||||
- **Phase 4** (event fan-out): WS owner gate; SSE per-client identity + `broadcast`/terminal-batch routing (`deriveSseHint`, fail-closed); `getLightState` per-identity filtering; file-route preview/thumbnail/history + `GET /api/search` scoping.
|
||||
- **Phase 5** (admin API + frontend): `src/web/routes/admin-routes.ts` (user CRUD, one-time passwords, last-admin guards, session revoke/kill) + `src/web/admin-audit.ts`; `public/admin-ui.js` (identity boot, change-password modal + interceptor, admin Users tab). Tests: `test/admin-routes.test.ts`, `test/admin-ui.test.ts`.
|
||||
|
||||
Deferred follow-ups (documented, non-blocking): away-digest + subagent/workflow REST-list scoping, push-subscription identity/routing, per-user screenshot subdirs, `linked-cases.json` v2 owner field, `ScheduledRun.owner`, plan-orchestrator internal one-shot mode resolution, and a Playwright browser pass. Phase 6 (login form replacing Basic) remains out of scope.
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Today Codeman is strictly single-user: one optional credential pair (`CODEMAN_USERNAME`/`CODEMAN_PASSWORD`), one shared `~/codeman-cases` folder, one global session list, and a global SSE/WS fan-out. This plan adds an opt-in **multi-user mode**:
|
||||
|
||||
- **Off by default.** Without the flag, behavior stays byte-identical to today (same auth path, same paths, same payloads). All new code is gated behind `isMultiUserMode()`.
|
||||
- **`codeman web --multiuser`** (or `CODEMAN_MULTIUSER=1`) enables named users with individually hashed passwords stored in `~/.codeman/users.json`.
|
||||
- **Each user gets their own space**: `~/codeman-users/<username>/cases/<case>` replaces the shared `~/codeman-cases` for that user. Sessions, cases, attachments, search, digests, and SSE events are scoped to their owner.
|
||||
- **Admin panel** (App Settings, admin-only "Users" tab): create/delete users, change/reset passwords, enable/disable accounts, delete a user's space, see per-user live sessions and disk usage, force logout.
|
||||
|
||||
## 2. Threat Model (read first, be honest about this)
|
||||
|
||||
Multi-user mode is **workspace separation for a trusted team, NOT security isolation between mutually distrusting users**:
|
||||
|
||||
- Every session still runs as the **same OS account** with `claude --dangerously-skip-permissions`. Any user can ask their agent to `cat /home/<host>/codeman-users/otheruser/...`. The web layer enforces scoping; the agent layer cannot.
|
||||
- **Shell sessions and custom launch commands are the bluntest holes**: `SessionMode = 'shell'` hands out a raw shell as the host account, and a cron job's `launchCommand` runs an arbitrary command; no Claude permission classifier is involved in either. These must be gated behind the same grant as bypass (section 6.3), otherwise the `auto`-mode mitigation below is theater.
|
||||
- All sessions share one tmux socket (`-L codeman`), one `~/.claude` (transcripts, credentials, plan usage), one Claude subscription.
|
||||
- Mitigation for stronger isolation: pair a user's cases with **Docker cases** (container per case, `docs/docker-cases.md`), or run separate Codeman instances per user (`CODEMAN_INSTANCE`, separate OS accounts). True per-user OS isolation is explicitly **out of scope** for this feature.
|
||||
- Partial mitigation at the agent layer: non-admin users default to Claude's `auto` permission mode (section 6.3), whose safety classifier blocks destructive actions and credential exfiltration. That reduces, but does not eliminate, cross-user snooping; the `canBypassPermissions` grant reopens it and should be given deliberately.
|
||||
|
||||
This must be stated loudly in `docs/security-architecture.md`, the README section, and the admin panel UI ("Users share the host account; this separates workspaces, it does not sandbox users from each other").
|
||||
|
||||
Also note the flip side: multi-user mode strictly _improves_ today's network posture, because it removes the single shared password and gives every person their own revocable credential.
|
||||
|
||||
## 3. Activation and Mode Rules
|
||||
|
||||
| Condition | Behavior |
|
||||
| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| No flag (default) | Exactly today's behavior. `users.json` is never read. Single-user auth via `CODEMAN_PASSWORD` if set. |
|
||||
| `--multiuser` / `CODEMAN_MULTIUSER=1`, `users.json` has users | Multi-user auth active. `CODEMAN_PASSWORD` is ignored for login (warn if set). |
|
||||
| `--multiuser`, no `users.json` (first boot) | Bootstrap: if `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` are set, create that user as the initial admin and continue. Otherwise refuse to start with instructions to run `codeman users add <name> --admin`. Never start multi-user with zero users (there would be no way in). |
|
||||
| `--multiuser` on a non-loopback bind | Allowed without `CODEMAN_PASSWORD`: `server.ts start()` treats "multi-user with >= 1 enabled user" as satisfying the auth requirement in the loud-warning check (wire into the existing `isLoopbackBindHost()` branch). |
|
||||
| Flag later removed | Single-user mode again. Sessions/state that carry `owner` fields keep working (owner is simply ignored); user spaces remain on disk untouched. |
|
||||
|
||||
Plumbing: flag in `src/cli.ts` (web command), env in a new `src/config/multiuser.ts` exporting `isMultiUserMode()`. Per-instance like everything else: a beta instance (`CODEMAN_INSTANCE=beta`) has its own `users.json` via `dataPath()`.
|
||||
|
||||
## 4. Data Model and Disk Layout
|
||||
|
||||
### 4.1 `~/.codeman/users.json` (via `dataPath('users.json')`, mode 0600, atomic write: tmp + rename)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 1,
|
||||
"users": [
|
||||
{
|
||||
"username": "alice", // canonical lowercase slug
|
||||
"role": "admin", // "admin" | "user"
|
||||
"password": {
|
||||
"algo": "scrypt", // node:crypto scrypt, no new deps
|
||||
"N": 16384,
|
||||
"r": 8,
|
||||
"p": 1,
|
||||
"salt": "<hex 32B>",
|
||||
"hash": "<hex 64B>",
|
||||
},
|
||||
"disabled": false,
|
||||
"mustChangePassword": false, // set by admin reset; gates all API access until changed
|
||||
"canBypassPermissions": false, // permission-mode grant, see section 6.3; false for new users
|
||||
"createdAt": 1752900000000,
|
||||
"lastLoginAt": 1752900000000,
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
- **Username rules**: `^[a-z0-9][a-z0-9_-]{1,31}$` (it becomes a folder name), stored lowercase, unique case-insensitively. Reserve `admin`? No: any name can be admin; role is a field, not a name.
|
||||
- **Hashing**: `scrypt` from `node:crypto` with per-user salt, compared via `timingSafeEqual`. Params stored per record so they can be raised later; verify tolerates old params and rehashes on next successful login.
|
||||
- New module `src/user-store.ts` (mirrors the `remote-hosts.ts` / `docker-hosts.ts` pattern): `readUsers()`, `writeUsers()`, `verifyPassword()`, `createUser()`, `setPassword()`, `deleteUser()`, plus pure helpers (`isValidUsername`, `hashPassword`) that are unit-testable without IO. In-process cache with short TTL like `readSettings`, invalidated on every write; the short TTL also covers the CLI (section 10) editing `users.json` while the server runs (cross-process changes picked up within the TTL).
|
||||
|
||||
### 4.2 User spaces
|
||||
|
||||
```
|
||||
~/codeman-users/
|
||||
alice/
|
||||
cases/
|
||||
my-project/ <- same layout as today's ~/codeman-cases/<case>
|
||||
bob/
|
||||
cases/
|
||||
```
|
||||
|
||||
- New helper in `route-helpers.ts`:
|
||||
`resolveCasesDir(user?: AuthUser): string`
|
||||
single-user mode: returns `CASES_DIR` (today's `~/codeman-cases`); multi-user: returns `join(USER_SPACES_DIR, user.username, 'cases')`, creating it lazily on first use.
|
||||
- `CASES_DIR` stays exported for single-user code paths, but every route usage (see 6) switches to the resolver.
|
||||
- The **user folder** (`~/codeman-users/<username>/`) is the deletion unit for "delete user + space" and leaves room for future per-user extras (uploads, exports) beside `cases/`.
|
||||
- Legacy `~/codeman-cases` in multi-user mode: surfaces to admins only, as a read-only "Unassigned (legacy)" group in the case list, with an admin action `POST /api/admin/cases/assign { case, username }` that `fs.rename`s the folder into a user's space (same-filesystem move, cheap). No automatic migration.
|
||||
|
||||
## 5. Auth Pipeline Changes (`src/web/middleware/auth.ts`)
|
||||
|
||||
Keep the existing single-user branch untouched. Add a parallel multi-user branch selected once at registration time:
|
||||
|
||||
1. **Credential check**: Basic header parsed into `username:password`, verified against the user store (scrypt + `timingSafeEqual`). Disabled users fail closed.
|
||||
2. **Cookie sessions**: same `codeman_session` cookie and `StaleExpirationMap`, but `AuthSessionRecord` gains `username` and `role`. All existing TTL/sliding/eviction logic reused. Eviction cap becomes per-user aware (evict oldest _of that user_ first) so one user cannot flush everyone's sessions by logging in 100 times.
|
||||
3. **Request identity**: decorate `req.authUser = { username, role }` (Fastify decorateRequest). In single-user mode `req.authUser` is `{ username: 'admin', role: 'admin' }` when auth is on, and a synthetic admin when auth is off, so downstream code has ONE code path.
|
||||
4. **Rate limiting**: keep the per-IP bucket; add a per-username failure bucket (same `StaleExpirationMap` pattern) so a botnet cannot brute-force one account across IPs, and one flaky user behind a NAT cannot lock out the rest.
|
||||
5. **`mustChangePassword` gate**: when set, every API request except `GET /api/me`, `POST /api/me/password`, and static assets returns 403 with `errorCode: 'PASSWORD_CHANGE_REQUIRED'`; the frontend intercepts that code and shows the change-password modal.
|
||||
6. **Password change vs Basic-auth caching**: browsers cache Basic credentials. After a password change we revoke all of that user's cookie sessions; the next request falls to Basic with stale creds, gets 401, and the browser re-prompts. Acceptable for v1; a proper login form is Phase 6 (see 15).
|
||||
7. **Unchanged**: hook-secret loopback bypass (hooks authenticate the _instance_, not a user; the event maps to a session which has an owner), host guard, Origin/CSRF guard, security headers.
|
||||
8. **WS upgrade identity** (`ws-routes.ts`): the global auth `onRequest` hook does run on the upgrade request (`@fastify/websocket` v11 runs hooks before the handshake; browsers send the session cookie), but the route handler itself only checks Host/Origin and never learns WHO authenticated. Multi-user: the handler reads the decorated `req.authUser` and closes 4003 unless owner or admin (section 6.4; identity plumbing lands in Phase 2, the owner check in Phase 4 once sessions have owners). Add a regression test that an upgrade with no credentials is rejected while auth is active: the handler-level Host/Origin gate alone must never be mistaken for auth.
|
||||
9. **QR auth** (`/q/:code` redemption in `system-routes.ts`, minting in `tunnel-manager.ts`): today there is ONE global token, auto-rotated every 60s with a 90s grace window. A globally-rotating token cannot carry an identity (every logged-in user sees the same code), so multi-user mode replaces rotation with **on-demand minting**: an authenticated `POST /api/tunnel/qr` mints a single-use, short-TTL token bound to `req.authUser.username` (field on `QrTokenRecord`); redemption creates a cookie session for that user. Existing rate-limit buckets (`qrAuthFailures`, global `QR_RATE_LIMIT_MAX`) apply unchanged. Single-user mode keeps the rotating token.
|
||||
|
||||
New error codes in `src/types/api.ts`: `FORBIDDEN`, `PASSWORD_CHANGE_REQUIRED`, `USER_EXISTS`, `USER_NOT_FOUND`, `LAST_ADMIN`.
|
||||
|
||||
Role guard helper in `route-helpers.ts`: `requireAdmin(req, reply): boolean` used as the first line of every admin handler (403 `FORBIDDEN`), plus `requireOwnerOrAdmin(req, session)`.
|
||||
|
||||
## 6. Ownership Threading (the big refactor)
|
||||
|
||||
### 6.1 Sessions
|
||||
|
||||
- `Session` gains `owner?: string` (constructor option), persisted in `SessionState.owner`, included in `toState()`, round-tripped through recovery (`mux-sessions.json` entries carry it, `restoreMuxSessions` passes it back, exactly like `remote`/`docker`).
|
||||
- Every session-creating path stamps the owner from `req.authUser`. Verified inventory of `new Session(...)` call sites: `POST /api/sessions` (session-routes.ts:444), `POST /api/quick-start` (:1956), `POST /api/run` one-shot (:1652), Ralph start (ralph-routes.ts:327), **cron** (cron-service.ts:352; `CronJob` gains `owner`, stamped at job create, launched as the job's owner), legacy `ScheduledRun` loop (server.ts:1603), plan generation + plan-orchestrator agents (plan-routes.ts:128, plan-orchestrator.ts:422/578; owner = requesting user), and recovery (server.ts:2225, next bullet). Two non-paths, also verified: **respawn never constructs a new Session** (it re-spawns the PTY on the same object, so `owner` survives automatically; no inheritance logic needed), and **orchestrator-loop creates no sessions** (it schedules work onto existing idle sessions via the task queue; its scoping requirement is different: it must only pick idle sessions owned by the goal's creator).
|
||||
- Recovery: `owner` must ALSO be mirrored on `MuxSession` (mux-sessions.json) and read back mux-first like `remote`/`docker` (`muxSession.owner ?? savedState?.owner`, the server.ts:2246-2250 pattern), or a reboot erases ownership on the next persist.
|
||||
- Every session-reading/mutating route filters: non-admin users only see and act on `session.owner === req.authUser.username`. Centralize in `findSessionOrFail` (route-helpers.ts:87; the owner check there covers the 6 route files that use it: system/session/respawn/ralph/file/plan-routes) and in the list endpoints (`GET /api/sessions`, `GET /api/sessions/unified`, `GET /api/status`). The Phase 3 audit must grep for BOTH `sessionManager.getSession` AND direct map access (`ctx.sessions.get(` / `.has(`): ws-routes and hook-event-routes reach sessions that way and bypass `findSessionOrFail`.
|
||||
- Admins see everything; every session row carries `owner` so the UI can badge it.
|
||||
|
||||
### 6.2 Cases
|
||||
|
||||
- All `CASES_DIR` call sites switch to `resolveCasesDir(req.authUser)`: `case-routes.ts` (list/create/delete/CLAUDE.md scaffolding, name-collision checks, docker quickcreate), `session-routes.ts` (quick-start case resolution, the workingDir-inside-cases env-strip check), `ralph-routes.ts` (case path resolution), and `plan-routes.ts:231` (easy to miss). Case-name-to-path resolution is currently DUPLICATED (`resolveCasePath` in case-routes.ts:82 and an inline copy in quick-start, session-routes.ts:1846-1863); consolidate into one owner-aware resolver as part of this refactor instead of patching both copies.
|
||||
- Registries that map case names to metadata become owner-scoped. `remote-cases.json`/`docker-cases.json` are arrays of objects, so entries simply gain `owner?: string` (absent = legacy: admin-only). `linked-cases.json` is a flat `Record<caseName, path>` with no room for a field: it needs a v2 shape (`{ "version": 2, "cases": { "<name>": { "path": "...", "owner": "..." } } }`) with read-time migration of the v1 form; it is read in two places (case-routes AND inline in quick-start), both must move to the new reader. Case names only need to be unique per user.
|
||||
- **Remote hosts and Docker hosts are machine-level resources**: CRUD on `/api/docker-hosts` and remote-host endpoints becomes admin-only in multi-user mode; regular users can _use_ hosts on their own cases but not define them. (Docker containers exec as the host account; letting any user define arbitrary `docker run` args is admin-equivalent.)
|
||||
- Case deletion, exports (`docker-exports/`), and imports check ownership; export filenames get an owner prefix to avoid collisions (fits the existing `^[a-zA-Z0-9._-]+\.tgz$` download guard).
|
||||
- **Workspace confinement for non-admins (the linchpin, do not skip)**: today `POST /api/sessions` accepts ANY host directory as `workingDir` (the only check is `statSync().isDirectory()`, session-routes.ts:305-318), and file-routes/attachments confine reads to `session.workingDir`. Without a new rule the whole scoping story is circular: a user points a session at `~/codeman-users/bob` (or `/home`) and the web layer itself serves that subtree, no agent needed. Rule: in multi-user mode a non-admin's `workingDir` must realpath-resolve inside their own space, enforced at `POST /api/sessions`, `POST /api/run`, cron job create AND fire time (the dir can change owners between the two), and Ralph auto-configure. Admins are unrestricted. This one rule is what makes the section 6.4 file-route line ("own space or own sessions' workingDirs") meaningful.
|
||||
|
||||
### 6.3 Per-user Claude permission-mode policy
|
||||
|
||||
Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab: `settings.claudeMode`, values `dangerously-skip-permissions` (default) | `auto` | `normal` | `allowedTools`; `auto` emits `--permission-mode auto`, Anthropic's classifier-guarded low-prompt mode). Multi-user mode layers a per-user policy on top of it:
|
||||
|
||||
- **Default for regular users: `auto` only.** A non-admin's Claude sessions are forced to `--permission-mode auto` regardless of the global `claudeMode` setting. `normal` and `allowedTools` are also permitted (they are strictly more restrictive than auto), but `dangerously-skip-permissions` is NOT.
|
||||
- **Bypass is an explicit admin grant**: `canBypassPermissions: true` on the user record (default `false`, section 4.1). Only with that grant does the global skip-permissions default (or a future per-user choice) apply to their sessions.
|
||||
- **Admins** are unrestricted; the global setting applies to them as-is.
|
||||
- **Single enforcement point**: a pure `resolveClaudeModeForUser(globalMode, user)` in `user-store.ts`, applied server-side at option-resolution time, BEFORE the Session constructor, so both downstream arg builders inherit it for free (`buildPermissionArgs` in session-cli-builder.ts for the direct-PTY path AND `buildClaudePermissionFlags` in tmux-manager.ts for tmux panes; there are two builders, not one). Call sites where `getClaudeModeConfig()` feeds a spawn: session-routes.ts:452/1964, ralph-routes.ts:334, cron-service.ts:360, and recovery (server.ts:2214/2233). Recovery re-reads the GLOBAL setting on reboot, so the resolver must run there with the RECOVERED owner, or a restart silently un-downgrades every restored session. Never resolved in the frontend, so it cannot be bypassed via payload.
|
||||
- **Downgrade, don't error**: a non-granted user whose effective mode would be bypass gets `auto` silently (logged + surfaced as a badge on the session), so shared presets keep working.
|
||||
- **Other CLIs' bypass equivalents** follow the same grant: Codex `--dangerously-bypass-approvals-and-sandbox` (`codexDangerouslyBypassApprovals`) and Gemini `--approval-mode yolo` are refused for non-granted users (Gemini falls back to `auto_edit`, Codex to its default sandbox). Whether this stays one grant or splits per-CLI is an open question (section 15).
|
||||
- **Shell mode and custom launch commands follow the grant too**: `mode: 'shell'` sessions and cron `launchCommand` are arbitrary command execution as the host account, strictly stronger than any bypass flag, and no permission-mode downgrade applies to them. Non-granted users get 403 `FORBIDDEN` on shell session/quick-start creation and on cron jobs carrying `launchCommand` (checked at create AND at fire time). Folding them under `canBypassPermissions` keeps the model one-bit; section 15 asks whether it should split.
|
||||
- **Admin UI**: a "Can skip permissions" toggle per user in the Users tab (PATCH field, section 8), with a warning echoing the section 2 threat model.
|
||||
- Revoking the grant takes effect on the user's NEXT session start; live sessions are listed so the admin can restart them.
|
||||
|
||||
### 6.4 Everything else that lists or streams
|
||||
|
||||
| Surface | Scoping rule |
|
||||
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| SSE `/api/events` | Per-connection filter (see 7) |
|
||||
| WS terminal (`ws-routes.ts`) | Handler reads `req.authUser` (section 5.8) and closes 4003 unless owner or admin; today it checks Host/Origin only and has no identity |
|
||||
| `GET /api/search` | `harvestSources()` only over owned sessions |
|
||||
| `GET /api/away-digest` | Aggregate only owned sessions/events |
|
||||
| `GET /api/subagents`, workflow runs | Filter by owning session (`claudeSessionId -> session -> owner`); agents not attributable to any session: admin-only |
|
||||
| Push (`push-routes.ts`) | Subscription records currently carry NO identity (keyed by endpoint only): `subscribe` stamps `username`. All 8 `PUSH_EVENT_MAP` events are session-scoped, so routing = resolve owner from `data.sessionId`, deliver to that owner's (plus admins') subscriptions. Legacy identity-less subscriptions: admin-only delivery |
|
||||
| Screenshots `/api/screenshots` | Per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
|
||||
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
|
||||
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
|
||||
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). Per-user server settings: out of scope v1 |
|
||||
| System ops (self-update, tunnel toggle, span-displays, docker image build) | Admin-only |
|
||||
| `getLightState` init snapshot | Filtered per connection. Actual contents to filter (verified): `sessions`, `scheduledRuns`, `respawnStatus`, `subagents`, `workflowRuns`, `planUsage` (host-plan telemetry: admin-only); `globalStats` stays coarse-global. Cron jobs are NOT in the snapshot (they have their own REST route; filter there). The snapshot is cached process-wide (`LIGHT_STATE_CACHE_TTL_MS`): either key the cache per role/user or filter AFTER the cache on each send |
|
||||
|
||||
## 7. SSE Event Filtering
|
||||
|
||||
`/api/events` currently broadcasts everything to everyone. Ground truth first (verified): `broadcast()` lives in `SseStreamManager` (`sse-stream-manager.ts`), not server.ts; clients are keyed by the raw Fastify reply (`sseClients: Map<FastifyReply, Set<string> | null>`, plus `sseClientsById` for live filter updates); the existing `?sessions=` filter is a bandwidth optimization applied ONLY to `session:terminal` batches in `flushSessionTerminalBatch()`, while `broadcast()` itself loops ALL clients unconditionally. The single-client delivery primitive already exists (`sendSSE`, used for the per-connection init snapshot). Plan:
|
||||
|
||||
- At connection time, resolve `req.authUser` and store `{ username, role }` with the client. Concretely: extend `addClient(reply, sessionFilter, isRemote, clientId)` to take the identity and change the `sseClients` map value to `{ filter, identity }` (or add a parallel `Map<reply, identity>`); there is no per-client record object today to hang it on.
|
||||
- `broadcast()` gains an optional routing hint: `broadcast(event, data, { sessionId?, adminOnly?, username? })`. Resolution order per client: admin sees all; `username` targets one user; `sessionId` resolves owner via SessionManager; `adminOnly` for machine-level events (docker image builds, tunnel, self-update); no hint = broadcast to all (connection status etc.).
|
||||
- **Enforce the identity check in BOTH `broadcast()` AND `flushSessionTerminalBatch()`**: the terminal batch path does not go through `broadcast()`, and it carries the highest-value payload (raw terminal bytes).
|
||||
- Sweep of the ~120 backend event constants in `sse-events.ts`: mechanically, everything `session:*`, `ralph:*`, `respawn:*`, `subagent:*`, `workflow:*`, `attachment:*`, `cron:*` (job owner) carries or can resolve a sessionId/owner; `docker:*`, `system:*`, tunnel and update events are adminOnly; a short tail needs case-by-case decisions during implementation.
|
||||
- The existing `?sessions=` filter and `/api/events/subscribe` compose with (never override) the ownership filter: the subscription filter can only narrow within what the identity allows.
|
||||
|
||||
## 8. Admin API (`src/web/routes/admin-routes.ts`, new module + `AdminPort`)
|
||||
|
||||
All handlers: multi-user mode only (404 otherwise), `requireAdmin`, Zod schemas in `schemas.ts`, `ApiResponse` envelope, audit-logged.
|
||||
|
||||
| Endpoint | Behavior |
|
||||
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET /api/admin/users` | List users + stats: role, disabled, createdAt, lastLoginAt, live session count, case count, space disk usage (best-effort async walk, cached 60s), active cookie-session count |
|
||||
| `POST /api/admin/users` | Create: `{ username, role, password? }`. No password given: generate a one-time password, return it ONCE in the response, set `mustChangePassword` |
|
||||
| `PATCH /api/admin/users/:username` | `{ role?, disabled?, canBypassPermissions? }`. Demoting/disabling the last enabled admin: 409 `LAST_ADMIN`. Disable also revokes cookie sessions. `canBypassPermissions` is the section 6.3 grant (default false) |
|
||||
| `POST /api/admin/users/:username/reset-password` | Generates one-time password (returned once), sets `mustChangePassword`, revokes cookie sessions |
|
||||
| `POST /api/admin/users/:username/logout` | Revoke all cookie sessions for that user. Honest limit under Basic auth: the browser silently re-sends cached credentials and gets a fresh cookie on the next request, so logout only truly ends QR-issued sessions; to actually lock someone out, disable the account or reset the password. Say so in the panel tooltip until Phase 6 |
|
||||
| `DELETE /api/admin/users/:username` | `{ deleteSpace?: boolean }` (default false). Refuses last admin. Kills the user's live sessions first (normal kill flow, incl. docker/remote teardown per case), revokes cookies, removes from store. With `deleteSpace`: guarded recursive delete of `~/codeman-users/<username>` (realpath must be inside `USER_SPACES_DIR`, top-level dir must not be a symlink), plus their registry entries and push subscriptions |
|
||||
| `POST /api/admin/cases/assign` | Move a legacy `~/codeman-cases/<case>` into a user's space (`fs.rename`) |
|
||||
| Self-service `GET /api/me` | `{ username, role, mustChangePassword }` (works in single-user mode too: synthetic admin; the frontend uses it to decide whether to render admin UI) |
|
||||
| Self-service `POST /api/me/password` | `{ currentPassword, newPassword }`, verifies current, min length 8, revokes other sessions, clears `mustChangePassword` |
|
||||
|
||||
**Audit log**: append-only `~/.codeman/admin-audit.jsonl` (same idiom as `session-lifecycle.jsonl`): timestamp, acting admin, action, target, request IP. User management without an audit trail is not acceptable even for a homelab tool.
|
||||
|
||||
SSE additions (both `sse-events.ts` and `constants.js`): `admin:usersChanged` (adminOnly; the panel re-fetches) and `auth:passwordChangeRequired` (targeted to the user).
|
||||
|
||||
## 9. Frontend
|
||||
|
||||
- **`GET /api/me` on boot** (app.js init): stores `window.__codemanUser`; everything below keys off it. Single-user mode returns the synthetic admin, so the UI needs no mode awareness beyond "am I admin".
|
||||
- **Admin panel**: new tab "Users" in the App Settings modal (settings-ui.js), rendered only for admins in multi-user mode. Table of users with actions (create, reset password showing the one-time password in a copy-to-clipboard reveal, enable/disable, role toggle, logout, delete with a typed-username confirm for the delete-space variant). No new header button (mobile header policy test stays green; the settings modal is already reachable everywhere).
|
||||
- **Change-password modal**: shown on `PASSWORD_CHANGE_REQUIRED` (fetch interceptor in api-client.js) and reachable from settings for self-service.
|
||||
- **Owner badges**: admin's session tabs and the session palette/manager show `owner` on foreign sessions; regular users see no change.
|
||||
- New module `admin-ui.js` if the settings-ui.js addition gets large (load order after settings-ui, before session-ui), else keep inside settings-ui.js. Follow the `@fileoverview` + `@loadorder` convention either way.
|
||||
|
||||
## 10. CLI Additions (`src/cli.ts`)
|
||||
|
||||
Headless bootstrap and recovery must not require the web UI:
|
||||
|
||||
```
|
||||
codeman users add <name> [--admin] # prompts for password (hidden input), or --password-stdin
|
||||
codeman users passwd <name> # reset password
|
||||
codeman users list
|
||||
codeman users rm <name> [--delete-space]
|
||||
```
|
||||
|
||||
These operate directly on `users.json` via `user-store.ts` (no server needed), honoring `CODEMAN_INSTANCE`. This is also the answer to "locked out: last admin forgot password".
|
||||
|
||||
## 11. Limits and Config
|
||||
|
||||
- New `src/config/multiuser.ts`: `isMultiUserMode()`, `USER_SPACES_DIR` (`~/codeman-users`, overridable via `CODEMAN_USER_SPACES_DIR` for tests), `MAX_USERS` (default 25), per-user session cap (default: global cap / 2, env `CODEMAN_MAX_SESSIONS_PER_USER`).
|
||||
- Cap enforcement is currently COPY-PASTED: the global `MAX_CONCURRENT_SESSIONS` (50, `config/map-limits.ts:25`) check appears at 6 independent sites (session-routes.ts:298/1622/1683, ralph-routes.ts:275, cron-service.ts:340, server.ts:1595). Do not add a 7th copy per site: extract one `assertSessionCapacity(ctx, owner?)` helper doing the global + per-user checks and use it everywhere, or the per-user cap WILL miss a path.
|
||||
- Global limits (50 sessions, SSE clients 100, terminal buffers) are unchanged and shared; the per-user session cap is the fairness lever.
|
||||
|
||||
## 12. Compatibility Matrix
|
||||
|
||||
| Concern | Guarantee |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Default (no flag) | No behavior change. No new file reads on the hot path. All new fields optional in state |
|
||||
| State round-trip | `SessionState.owner`, `MuxSession.owner`, `CronJob.owner`, registry `owner` fields are optional; old state loads clean; new state loaded by an old build ignores unknown fields (existing tolerant parsing) |
|
||||
| Instance isolation | `users.json`, audit log, screenshots subdirs all via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
|
||||
| API versioning | HTTP API is internal per `docs/versioning-policy.md`; still, all changes are additive. Ship as a **minor** version |
|
||||
| Hooks | Unchanged (instance-level hook secret; owner resolved from the session) |
|
||||
|
||||
## 13. Implementation Phases
|
||||
|
||||
Each phase is independently shippable behind the flag and ends with its tests green.
|
||||
|
||||
**Phase 1: user store + mode plumbing** (no behavior change yet)
|
||||
`src/user-store.ts`, `src/config/multiuser.ts`, CLI `users` subcommands, bootstrap-on-first-boot logic, `users.json` schema + atomic writes.
|
||||
Tests: `test/user-store.test.ts` (hashing, verify, params upgrade, username validation, atomic write, last-admin invariants; pure, no server).
|
||||
|
||||
**Phase 2: multi-user auth**
|
||||
Auth middleware branch, `req.authUser` decoration, cookie records with username/role, per-username rate bucket, `mustChangePassword` gate, WS upgrade identity plumbing + unauthenticated-upgrade regression test (section 5.8), QR on-demand minting + identity binding (section 5.9), `GET /api/me`, `POST /api/me/password`, error codes, network-bind check integration.
|
||||
Tests: `test/multiuser-auth.test.ts` (live server, unique port 3170+; wrong password, disabled user, cookie carries identity, per-user rate limit isolation, mustChangePassword lockbox, QR redemption identity). Reuse the `delete process.env.CODEMAN_PASSWORD` idiom from `test/setup.ts`.
|
||||
|
||||
**Phase 3: ownership threading**
|
||||
Session `owner` + persistence + `MuxSession` mirror + recovery; `resolveCasesDir()` refactor across case/session/ralph/plan routes (consolidating the duplicated case-path resolution); registry owner fields incl. the linked-cases v2 shape; `findSessionOrFail` owner check + the direct-`sessions.get` audit; list filtering; owner stamping across ALL create paths from 6.1; **non-admin workingDir confinement** (6.2); permission-mode/shell/launchCommand policy (6.3); `assertSessionCapacity` helper + per-user cap.
|
||||
Tests: `test/routes/ownership-scoping.test.ts` (inject-based: user A cannot read/kill/input user B's session, case lists are disjoint, admin sees both), extend `test/cron-service.test.ts` for owner stamping, recovery round-trip in the existing mux-recovery tests.
|
||||
|
||||
**Phase 4: event fan-out + remaining surfaces**
|
||||
SSE routing hints + client identity (enforced in BOTH `broadcast()` and the terminal-batch flush), WS owner gate (identity landed in Phase 2), search/digest/subagent/workflow scoping, push subscription identity + owner routing, screenshot subdirs, file-route scoping, `getLightState` filtering + per-identity caching, admin-only system ops.
|
||||
Tests: `test/sse-ownership.test.ts` (two SSE clients, event for A's session reaches only A + admin), WS upgrade rejection test, search/digest scoping tests.
|
||||
|
||||
**Phase 5: admin API + frontend**
|
||||
`admin-routes.ts` + `AdminPort` + schemas + audit log + `admin:usersChanged`; settings-ui Users tab, change-password modal, owner badges, api-client interceptor.
|
||||
Tests: `test/routes/admin-routes.test.ts` (CRUD, last-admin 409, one-time password flow, delete-space guard rails incl. symlink refusal), frontend vm-sandbox test following `test/run-mode-ui.test.ts` pattern, Playwright pass per the always-end-to-end rule before calling it done.
|
||||
|
||||
**Phase 6 (optional, later): login page**
|
||||
Replace Basic with a form + `POST /api/login` in multi-user mode only (fixes browser credential caching UX, enables logout button). Explicitly deferred; Basic works for v1.
|
||||
|
||||
**Docs**: update `docs/security-architecture.md` (new section: multi-user model + threat model from section 2), `README.md` (short opt-in section), `CLAUDE.md` (Key Patterns entry + State Files + route/SSE counts), this file gets a "shipped" status stamp per phase.
|
||||
|
||||
## 14. Key Risks / Decisions Made
|
||||
|
||||
1. **Not a security boundary at the agent layer** (section 2). Decided: ship with loud documentation; Docker cases are the isolation story.
|
||||
2. **`findSessionOrFail` as the single enforcement point** for ~30 session routes: any route that fetches sessions another way must be audited in Phase 3 (grep for `sessionManager.getSession` outside route-helpers).
|
||||
3. **SSE sweep is the riskiest surface**: a missed event leaks metadata (not terminal content, which is session-scoped, but names/paths). Phase 4 includes a checklist pass over all ~138 events with the default flipped to "owner-scoped unless explicitly global": fail closed.
|
||||
4. **Basic-auth password-change UX** is mediocre (browser re-prompt). Accepted for v1; Phase 6 fixes it properly.
|
||||
5. **Legacy case migration** is manual (admin assigns). No silent moves of user data.
|
||||
6. **Case-name uniqueness becomes per-user**; tmux session names already include the session id so no collision, but the `w<n>-<case>` tab naming and lifecycle-log rows should include the owner for disambiguation in admin views.
|
||||
7. **`workingDir` confinement (6.2) is the single most load-bearing rule**: every file-serving and agent-spawning surface downstream trusts `session.workingDir`. Review and test it as carefully as the auth branch (foreign-space path, symlink into a foreign space, `..` traversal, cron fire-time re-check).
|
||||
8. **The WS handler never sees identity today** (auth happens only in the global hook): the 5.8 wiring is new code on a security-sensitive path; cover unauthenticated, foreign-user, and admin upgrades with tests.
|
||||
|
||||
## 15. Open Questions (answer before Phase 3)
|
||||
|
||||
1. Should admins' own cases live in `~/codeman-users/<admin>/cases` (symmetric, proposed) or keep using legacy `~/codeman-cases`? Proposed: symmetric; legacy dir is a migration source only.
|
||||
2. Per-user settings (respawn presets, notification prefs): global-only in v1. Worth a `users/<name>/settings.json` overlay later?
|
||||
3. Should regular users be allowed to create Docker cases on admin-defined hosts (proposed: yes) or is Docker entirely admin-only?
|
||||
4. Session handoff: does an admin need "reassign session/case to another user"? (Cheap to add next to `cases/assign`; not in v1 scope.)
|
||||
5. Permission-mode grants (section 6.3): one `canBypassPermissions` flag covering Claude/Codex/Gemini bypass equivalents PLUS shell mode and cron `launchCommand` (proposed: one flag, keep it one-bit), or split into `canBypassPermissions` + `canRunArbitraryCommands`? And should admins be able to set a per-user DEFAULT mode (for example force `normal` for an intern) rather than just gating bypass?
|
||||
6. OpenCode has no single bypass flag (its permission config rides `OPENCODE_CONFIG_CONTENT`): decide what the grant means there before Phase 3, or exclude OpenCode mode for non-granted users in v1.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Reliable input delivery (exactly-once, durable)
|
||||
|
||||
## The bug this fixes
|
||||
|
||||
With local echo on, pressing Enter cleared the overlay and then sent the prompt
|
||||
over the WebSocket **fire-and-forget** (`ws.send({t:'i',d})`). On a flaky link
|
||||
(e.g. a moving train) the socket is frequently *half-open*: `readyState === OPEN`
|
||||
so `ws.send()` does **not** throw, but the underlying TCP is dead, so the frame is
|
||||
silently discarded. Nothing was enqueued (the send "succeeded"), the on-screen
|
||||
prompt was already wiped, and `navigator.onLine` stays `true` — so a long typed
|
||||
prompt vanished with no trace and no resend.
|
||||
|
||||
## The guarantee
|
||||
|
||||
Every byte of user input is **recorded durably before delivery** and **only
|
||||
dropped once the server ACKs it** — so a half-open socket, a reconnect, or a page
|
||||
reload can never lose input. Redelivery is **exactly-once**: the server applies
|
||||
each `(clientId, seq)` at most once, so a resend can't type the prompt twice.
|
||||
|
||||
## How it works
|
||||
|
||||
### Client (`app.js`)
|
||||
|
||||
- A stable **`clientId`** (`localStorage['codeman:clientId']`) identifies this
|
||||
browser to the server's dedup across reconnects and reloads.
|
||||
- Each input frame gets a **monotonic per-session `seq`**. Frame records
|
||||
(`{seq,data,useMux,ts,tries,sentAt}`) live in `_pendingDeliveries`
|
||||
(`Map<sessionId, record[]>`), persisted (debounced, + flushed on `pagehide`/
|
||||
`visibilitychange`) to `localStorage['codeman:pendingInput']`. The seq counters
|
||||
persist too, so seqs stay monotonic across reloads (never reset — a reset would
|
||||
let the server treat fresh input as an already-applied duplicate).
|
||||
- **Delivery** (`_drainSession`):
|
||||
- **WS path** — when the socket is `OPEN` for the session, send each not-yet-sent
|
||||
record (`sentAt === 0`) in seq order over the single ordered stream. Records
|
||||
stay pending until the server's `{t:'ia',seq}` ACK removes them.
|
||||
- **POST path** — when no WS, POST records in order, awaiting each (the HTTP 2xx
|
||||
*is* the ACK). A 404/410 (session gone) drops the record rather than retry
|
||||
forever.
|
||||
- **Half-open recovery** (`_redeliverSweep`, every 2s): if the active WS session's
|
||||
oldest record is unacked past `_reliableAckTimeoutMs` (4s), the socket is assumed
|
||||
dead — `ws.close()` forces a fast reconnect; `onopen` (`_onWsReady`) resets
|
||||
`sentAt = 0` and re-sends everything pending. Also re-drains background sessions
|
||||
over POST, and fires on SSE-reconnect / `online`.
|
||||
- The connection indicator shows pending count/bytes (`_pendingBytes`).
|
||||
|
||||
### Server
|
||||
|
||||
- **`Session.shouldApplyInput(clientId, seq)`** — returns `true` exactly once per
|
||||
`(clientId, seq)`: the first time a seq strictly greater than that client's
|
||||
last-applied is seen. A replayed/lower seq returns `false`. Bounded MRU map
|
||||
(`MAX_INPUT_DEDUP_CLIENTS = 256`).
|
||||
- **WS route** (`ws-routes.ts`) — parses optional `cid`/`seq` on `{t:'i'}`; applies
|
||||
via `shouldApplyInput` (skips a duplicate, still ACKs with `{t:'ia',seq}` so the
|
||||
client drops it). Untagged frames apply unconditionally (no behavior change).
|
||||
- **POST route** (`/api/sessions/:id/input`) — optional `seq`/`clientId` in
|
||||
`SessionInputWithLimitSchema`; a deduped duplicate returns 200 without writing
|
||||
(the 200 is the client's ACK). `curl`/legacy callers omit the fields and always
|
||||
apply.
|
||||
|
||||
## Known limitation
|
||||
|
||||
Dedup state is in-memory on the server. A **server restart** between a write and
|
||||
the client's redelivery of that same seq could re-apply it (a rare duplicate).
|
||||
This is a deliberate trade-off: favor *never losing input* over a rare duplicate
|
||||
across the narrow restart window.
|
||||
|
||||
## Tests
|
||||
|
||||
- `test/reliable-input-dedup.test.ts` — `Session.shouldApplyInput` exactly-once
|
||||
semantics (monotonic, per-client, gap-tolerant, eviction-safe).
|
||||
- `test/routes/session-routes.test.ts` — POST `/input` applies a tagged
|
||||
`(clientId, seq)` once on redelivery; untagged input always applies.
|
||||
@@ -0,0 +1,246 @@
|
||||
# Remote Sessions (SSH)
|
||||
|
||||
Codeman can run a session's agent on a **remote host over SSH** instead of the
|
||||
local machine. The agent (Claude, OpenCode, Codex, Gemini, or a plain shell)
|
||||
runs inside a `tmux` server **on the remote host**, so it survives the SSH
|
||||
connection dropping; Codeman attaches to it the same way it attaches to a local
|
||||
managed session.
|
||||
|
||||
This document covers the data model, the shell-safe SSH command construction
|
||||
(COD-107), the durable-launch design (COD-104), and the operational caveats.
|
||||
For the local session/mux machinery this builds on, see the **Mux** and
|
||||
**Session** entries in `CLAUDE.md` → Architecture.
|
||||
|
||||
## Why it exists
|
||||
|
||||
A developer box (`AA-DESKTOP`) often needs to drive an agent on another machine —
|
||||
a NAS, a build server, a host reachable only through a jump box or a
|
||||
cloudflared SOCKS5 proxy. Rather than wrap `ssh` by hand per host, Codeman
|
||||
stores reusable **remote hosts** + **remote cases** and reproduces the exact
|
||||
connection the operator already uses (`ssh-aa-desktop`-style configs:
|
||||
custom port, identity file, `-J` jump host, `-o ProxyCommand`).
|
||||
|
||||
## Data model
|
||||
|
||||
Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
|
||||
|
||||
| Type | Role |
|
||||
|------|------|
|
||||
| `RemoteSshOptions` | The **HOW-to-reach** fields, shared by host + session: `identityFile`, `socksProxy` (`host:port`), `jumpHost` (`[user@]host[:port]`), `extraSshOptions` (`KEY=VALUE[]`). Every field optional — all-absent reproduces port-22, default-identity, directly-SSH-able behavior. |
|
||||
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
|
||||
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
|
||||
| `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
|
||||
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini'>` — the modes that can run remotely. |
|
||||
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
|
||||
|
||||
Persistence is two flat JSON arrays in the instance data dir:
|
||||
|
||||
- `~/.codeman/remote-hosts.json` — `readRemoteHosts()` / `writeRemoteHosts()`
|
||||
- `~/.codeman/remote-cases.json` — `readRemoteCases()` / `writeRemoteCases()`
|
||||
|
||||
(Paths via `remoteHostsPath()` / `remoteCasesPath()`; both honor `CODEMAN_INSTANCE`
|
||||
because the config dir is the instance data dir.)
|
||||
|
||||
On the live `Session`, the remote rides as `_remote?: SessionRemote`. When
|
||||
attaching, `resolveMuxAttachCwd()` forces the cwd to `/tmp` for remote sessions —
|
||||
the local working directory is meaningless on the remote box.
|
||||
|
||||
## SSH command construction (COD-107 — the injection surface)
|
||||
|
||||
**All** SSH command lines flow through one function so user-controlled fields are
|
||||
escaped once and the launch + prereq probe can never drift apart:
|
||||
|
||||
```ts
|
||||
// src/remote-hosts.ts
|
||||
buildSshConnectionArgs(remote: RemoteSshOptions & Pick<RemoteHost, 'port'>): string[]
|
||||
```
|
||||
|
||||
It returns the **ordered leading tokens** of an ssh command line (no `-t`, no
|
||||
target, no remote command):
|
||||
|
||||
```
|
||||
ssh -o BatchMode=yes
|
||||
[-p <port>]
|
||||
[-i <abs-identity>] # ~ / $HOME expanded, then shellescaped
|
||||
[-J <jumpHost>] # shellescaped, single token
|
||||
[-o ProxyCommand=nc -X 5 -x <socks> %h %p] # ONE shellescaped -o token
|
||||
[-o <KEY=VALUE>] … # each extra option, shellescaped
|
||||
```
|
||||
|
||||
Rules that keep this safe — **do not bypass them by hand-building an ssh line elsewhere:**
|
||||
|
||||
- **Every** user-controlled value (`-i`, `-J`, `-o`, ProxyCommand) is POSIX
|
||||
single-quote `shellescape`d (`'…'` with embedded `'\''`). The helper mirrors
|
||||
the one in `tmux-manager.ts`.
|
||||
- **`~`/`$HOME` in `identityFile` is expanded at build time** (`expandIdentityPath`),
|
||||
*before* escaping — ssh does not expand `~` inside `-i`, and the escaped value
|
||||
never reaches a shell that would.
|
||||
- **The ProxyCommand is one shellescaped `-o KEY=VALUE` token**, so its spaces and
|
||||
the `%h`/`%p` placeholders reach ssh as a single argument. `%h %p` survive
|
||||
verbatim — **ssh** expands them to the real host/port, not the shell.
|
||||
- **Empty options ⇒ `['ssh', '-o BatchMode=yes']`** (+ `-p` only when set) —
|
||||
byte-identical to the historical behavior.
|
||||
|
||||
Token construction is unit-tested independently of any live connection (see
|
||||
`test/` for `buildSshConnectionArgs` / `buildRemoteTmuxCheckCommand` cases).
|
||||
|
||||
## Durable launch (COD-104)
|
||||
|
||||
`buildRemoteLaunchCommand({ mode, remote, sessionId })` in `tmux-manager.ts`
|
||||
builds the command that launches (or **reattaches** to) the remote session:
|
||||
|
||||
```
|
||||
ssh -o BatchMode=yes -t <connection-args> user@host \
|
||||
'tmux -L codeman-remote new-session -A -s codeman-ssh-<id8> -c <remotePath> "cd <remotePath> && exec <cli>" \; \
|
||||
set -t codeman-ssh-<id8> status off \; set -t codeman-ssh-<id8> mouse off \; \
|
||||
set -t codeman-ssh-<id8> prefix C-q \; set -s escape-time 0 \; \
|
||||
set -t codeman-ssh-<id8> window-size latest'
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- **`new-session -A -s codeman-ssh-<id8>`** = attach-if-exists-else-create, so a
|
||||
reconnect (same deterministic `remoteTmuxSessionName(sessionId)` — `codeman-ssh-` +
|
||||
the first 8 chars of the session id) lands back in
|
||||
the **same** remote session rather than spawning a duplicate. This is what makes
|
||||
the remote agent survive an SSH drop. The name deliberately fails
|
||||
`SAFE_MUX_NAME_PATTERN` so a Codeman running ON the remote host never adopts it.
|
||||
- **`-L codeman-remote`** = a DEDICATED socket for sessions launched by remote
|
||||
Codemans, NOT the canonical `-L codeman` socket the remote host's own Codeman
|
||||
uses. Options are set per-session (`set -t`), never `-g`, so a shared remote
|
||||
tmux server's other sessions are untouched (#145 hardening). Note the
|
||||
asymmetry: **discovery/attach (COD-105) target the canonical `-L codeman`
|
||||
socket** — they join sessions the remote's own Codeman manages, while owned
|
||||
durable launches live on `-L codeman-remote`.
|
||||
- **`exec <cli>`** replaces the pane shell with the agent, so the pane PID *is*
|
||||
the agent. The per-mode command comes from `remote.commands?.[mode]` or
|
||||
`defaultRemoteCommandForMode(mode)` (`exec claude` / `exec opencode` /
|
||||
`exec codex` / `exec gemini` / `exec bash -l`).
|
||||
- The **whole tmux invocation is a single shell-quoted ssh argument**, and the
|
||||
pane command is independently quoted, so a `remotePath` with spaces is safe.
|
||||
- Connection options come from the **same `buildSshConnectionArgs(remote)`** as
|
||||
the prereq probe; `-t` is inserted right after `ssh -o BatchMode=yes`,
|
||||
preserving historical token order.
|
||||
|
||||
### tmux prerequisite probe
|
||||
|
||||
Because durable remote sessions require tmux on the remote host,
|
||||
`checkRemoteTmuxAvailable(host)` runs `command -v tmux` over SSH **before**
|
||||
creating a remote case/session and returns a structured, never-throwing result:
|
||||
|
||||
- empty stdout / non-zero exit → *"remote host `<host>` needs tmux installed for
|
||||
durable remote sessions"*
|
||||
- stderr present → *"could not verify tmux on remote host `<host>`: `<stderr>`"*
|
||||
(a real connection failure, surfaced to the operator)
|
||||
- success → `{ ok: true, tmuxPath }`
|
||||
|
||||
It connects with the **identical** options as the launch
|
||||
(`buildRemoteTmuxCheckCommand` reuses `buildSshConnectionArgs` and inserts
|
||||
`-o ConnectTimeout=10`), so a proxied/custom-port/identity host that the launch
|
||||
can reach also passes the probe (and vice-versa).
|
||||
|
||||
**Test-mode short-circuit:** under `VITEST` the probe returns
|
||||
`{ ok: true, tmuxPath: '(test-mode)' }` without opening a socket — mirroring
|
||||
`TmuxManager`'s no-op-shell-under-VITEST (`IS_TEST_MODE`). Without it, remote-case
|
||||
create-path tests would hit a real ~10s ssh timeout. Only the live probe is
|
||||
skipped; command construction is still asserted by unit tests.
|
||||
|
||||
## Ownership: launched vs. discovered-and-attached (COD-105)
|
||||
|
||||
COD-104 (above) was Phase 1 — Codeman *launches* a remote session and owns it.
|
||||
COD-105 is Phase 2 — Codeman can also **discover** `codeman-*` tmux sessions
|
||||
already running on a remote host (created by the remote's own Codeman or another
|
||||
instance) and **attach** to one it didn't launch. Ownership decides what happens
|
||||
when the tab closes.
|
||||
|
||||
`SessionRemote.owned` carries this:
|
||||
|
||||
- **`owned: true`** (or absent — legacy/COD-104 sessions persisted before this
|
||||
field) — we launched it via `buildRemoteLaunchCommand` and may explicitly kill it.
|
||||
- **`owned: false`** — discovered + attached; another Codeman owns the remote
|
||||
session. `remoteSessionName` holds its existing tmux name. Closing the tab
|
||||
**detaches**, never kills.
|
||||
|
||||
### Discovery
|
||||
|
||||
`listRemoteCodemanSessions(host)` lists the remote's `codeman-*` sessions:
|
||||
|
||||
- `buildRemoteListSessionsCommand()` runs `tmux -L codeman list-sessions -F "…"`
|
||||
over SSH (connection args from the shared `buildSshConnectionArgs`, so discovery
|
||||
connects identically to launch/probe). `2>/dev/null` swallows tmux's "no server
|
||||
running" stderr.
|
||||
- `parseRemoteSessionList()` is a **pure, unit-tested** parser. ⚠️ Quirk: the
|
||||
remote tmux's `-F "…\t…"` format emits the **literal two-character `\t`**, not a
|
||||
real tab (verified on tmux next-3.7), so the parser splits on `/\\t|\t/` (literal
|
||||
backslash-t **or** a real tab, for builds that do expand it). It keeps only
|
||||
`codeman-*` names, coerces types, and skips malformed lines.
|
||||
- `listRemoteCodemanSessions()` **never throws** — unreachable host / no tmux / no
|
||||
sessions all map to `[]`. Like the prereq probe, it **no-ops to `[]` under
|
||||
`VITEST`** so a request path never opens a real ssh connection.
|
||||
|
||||
Discovery is **explicit** — the UI has a "Discover existing sessions" button per
|
||||
host; Codeman never auto-discovers on host select.
|
||||
|
||||
### Attach vs. launch selection
|
||||
|
||||
`buildRemoteSessionCommand(mode, remote, sessionId)` in `tmux-manager.ts` picks the
|
||||
remote command line by ownership:
|
||||
|
||||
- **`owned === false`** → `buildRemoteAttachCommand(remote, name)` — emits
|
||||
`ssh … -t … 'tmux -L codeman attach -t <remoteSessionName>'`. It uses **`attach`,
|
||||
NOT `new-session -A`**, so it only *joins* an existing session and never creates
|
||||
one.
|
||||
- **owned (default)** → `buildRemoteLaunchCommand` (the COD-104 path above).
|
||||
|
||||
### Detach-not-kill
|
||||
|
||||
`TmuxManager.killSession()` has an **early return for non-owned remote sessions**:
|
||||
it tears down **only the LOCAL pane** holding the ssh client (`tmux -L codeman
|
||||
kill-session` on *this* host's socket). Killing the local ssh sends SIGHUP to the
|
||||
remote `tmux attach`, which **detaches** — the durable remote session survives.
|
||||
The early return is a structural guarantee that **no code path can ever issue a
|
||||
remote `kill-session` for a session we don't own** — the only `kill-session` run is
|
||||
on the local socket, which never reaches the remote socket.
|
||||
|
||||
## API
|
||||
|
||||
Routes are registered in `src/web/routes/case-routes.ts`:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/remote-hosts` | List saved hosts |
|
||||
| `POST` | `/api/remote-hosts` | Create a host |
|
||||
| `PUT` | `/api/remote-hosts/:id` | Update a host |
|
||||
| `DELETE` | `/api/remote-hosts/:id` | Delete a host |
|
||||
| `GET` | `/api/remote-hosts/:hostId/sessions` | Discover `codeman-*` sessions on the host (COD-105; `listRemoteCodemanSessions`, never errors) |
|
||||
| `POST` | `/api/cases/remote-link` | Link a case to a remote host (creates the `RemoteCase`) |
|
||||
|
||||
Attaching to a discovered session is a **session-create** path, not a host route:
|
||||
`POST /api/sessions` accepts `attachRemoteSession: { hostId, remoteSessionName }`
|
||||
(schema in `schemas.ts`; `remoteSessionName` must match `^codeman-[a-zA-Z0-9._-]+$`),
|
||||
which `session-routes.ts` turns into a non-owned (`owned: false`) session.
|
||||
|
||||
Frontend touchpoints: the remote-host management UI is in `session-ui.js` /
|
||||
`panels-ui.js`; a remote session is created by picking a remote host/case in the
|
||||
session-create flow, or via the per-host **"Discover existing sessions"** button →
|
||||
**Attach** action (creates an `owned: false` session).
|
||||
|
||||
## Security notes
|
||||
|
||||
- **`identityFile` is a path only — never key bytes.** Codeman stores the path and
|
||||
passes it to `ssh -i`; the key never enters Codeman's state or the wire.
|
||||
- The injection surface is the SSH option fields. The single-source
|
||||
`buildSshConnectionArgs` + `shellescape` discipline (COD-107) is the control —
|
||||
audit any new code path that constructs an ssh command to route through it
|
||||
rather than concatenating options inline.
|
||||
- `BatchMode=yes` means **no interactive password/passphrase prompts** — remote
|
||||
hosts must be reachable with key-based or agent auth (or an unencrypted key the
|
||||
agent has loaded). A host needing a passphrase will fail the probe with an ssh
|
||||
diagnostic rather than hang.
|
||||
|
||||
## Related
|
||||
|
||||
- `CLAUDE.md` → Architecture → **Remote** row, and the **Remote sessions (SSH)**
|
||||
Key Pattern.
|
||||
- `docs/security-architecture.md` — overall network/auth model.
|
||||
- COD-104 (tmux prereq + durable launch), COD-105 (discover + attach, detach-not-kill ownership), COD-107 (shell-safe connection args).
|
||||
@@ -30,7 +30,8 @@ an explicit, guided opt‑in.
|
||||
7. [Supply‑chain & build‑asset hardening](#7-supplychain--buildasset-hardening-cod28)
|
||||
8. [Multi‑instance isolation](#8-multiinstance-isolation)
|
||||
9. [Transport security headers](#9-transport-security-headers)
|
||||
10. [Quick reference](#10-quick-reference)
|
||||
10. [Docker container isolation](#10-docker-container-isolation)
|
||||
11. [Quick reference](#11-quick-reference)
|
||||
|
||||
---
|
||||
|
||||
@@ -471,7 +472,35 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick reference
|
||||
## 10. Docker container isolation
|
||||
|
||||
Docker cases (1.4.0) run a session inside a per‑case container instead of on the host. The security posture:
|
||||
|
||||
- **Hardened create flags, always** — `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit` (fork‑bomb guard), `--memory` == `--memory-swap` (a real OOM cap), `--init`, and non‑root: `--user <hostUid>:0` on Linux (host uid → workspace files stay host‑owned; GID 0 keeps `$HOME` writable), `--userns=keep-id` on rootless Podman. **Never** `--privileged`, and **never** the docker socket — the pure builder in `docker-hosts.ts` cannot emit them and the schema cannot represent them.
|
||||
- **Credentials never enter an image** — the convenient default bind‑mounts host cred dirs (`~/.claude`, `~/.codex`, `~/.gemini`, `~/.config/{gcloud,opencode}`) read‑write. Bind mounts are physically excluded from `docker commit`, so exported images are secret‑free. API‑key CLIs get their key as an exec‑time NAME‑ONLY `--env OPENAI_API_KEY` (no `=value`, no `ps` leak, never committed); a create‑time `-e` for a secret is never used. The **sealed** profile (`mountCredentials:false` + `network:none`) drops the host mounts; full‑image export is then refused (an in‑container login would ride the committed layer) unless a pre‑commit scrub is opted into.
|
||||
- **Blast radius — accept it explicitly** — the convenient profile mounts an arbitrary host workspace RW plus the host credential dirs RW into a network‑enabled container, so container‑run agent code can read/modify those host trees and reach the network at once. Still a net improvement over today's on‑host `--dangerously-skip-permissions` execution; use the sealed profile for genuinely untrusted work.
|
||||
- **Import is untrusted‑bundle‑safe** — `/api/docker-cases/import` validates the manifest + per‑member SHA‑256 before extraction, rejects absolute / `..` tar members (traversal guard), and re‑tags the loaded image into a quarantined namespace so it can never overwrite `codeman/agent:base` or a pre‑existing tag.
|
||||
- **Host guard & the bridge‑hooks listener** — in‑container hook callbacks carry `Host: host.docker.internal` / `host.containers.internal`; both are on the always‑on host‑header allowlist (`DOCKER_HOST_GATEWAY_ALIASES`) and resolve to the host only from inside a container netns, so they are not a browser DNS‑rebinding surface. On a loopback‑only server, in‑container hooks are opt‑in via `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, which binds a SECOND listener on the docker bridge gateway serving **only** the hook endpoints (every other path → `403`) into the same hook‑secret‑gated pipeline. The bridge is host‑internal (containers + host), not the LAN, so it does not widen network exposure; the hook secret is bind‑mounted read‑only and referenced by path.
|
||||
- **Instance isolation** — every managed container is labeled `codeman.instance=<CODEMAN_INSTANCE>`; the boot reaper reaps orphans of its OWN instance only, so a beta never removes a prod container. The in‑container tmux socket (`-L codeman-docker`) + session name (`codeman-dkr-*`) deliberately fail a nested Codeman's discovery pattern.
|
||||
|
||||
Full feature guide: [`docker-cases.md`](docker-cases.md).
|
||||
|
||||
---
|
||||
|
||||
## 10a. Multi‑user mode (opt‑in)
|
||||
|
||||
`codeman web --multiuser` (or `CODEMAN_MULTIUSER=1`) turns on named users with individually scrypt‑hashed passwords in `~/.codeman/users.json` (mode 0600). OFF by default; when off, nothing here applies and behavior is byte‑identical to single‑user. Design + phase status: [`multi-user-plan.md`](multi-user-plan.md).
|
||||
|
||||
- **It is workspace separation, NOT a security boundary between users.** Every session still runs as the SAME OS account with agent code that can read the whole host. Any user can ask their agent to `cat` another user's files; the WEB layer enforces scoping, the AGENT layer cannot. Mitigations: give non‑admins the default `auto` permission mode (classifier‑guarded), pair users with **Docker cases** (container per case) for real isolation, or run separate Codeman instances under separate OS accounts. Stated loudly in the admin panel and the plan's threat model (section 2).
|
||||
- **It strictly improves network posture.** It removes the single shared `CODEMAN_PASSWORD` and gives each person a revocable credential; a non‑loopback bind and the tunnel‑enable guard are satisfied by "multi‑user with ≥1 enabled user" without a shared password.
|
||||
- **Auth is a parallel branch** (`middleware/auth.ts`) that leaves the single‑user path untouched: per‑user scrypt verify (`timingSafeEqual`, timing‑equalized against user enumeration), identity‑carrying cookies, a per‑username failure bucket (a botnet can't brute one account across IPs; one NATed user can't lock out the rest), and a `mustChangePassword` lockbox. The hook‑secret loopback bypass, host guard, and Origin/CSRF guard are unchanged (hooks authenticate the INSTANCE, not a user).
|
||||
- **Ownership is enforced server‑side only** and fails closed: `req.authUser` (a synthetic admin in single‑user), `findSessionOrFail` returns NOT_FOUND (never 403) for a foreign session, list/SSE/WS/file‑preview/search all filter by `session.owner`, and SSE routing defaults session‑scoped events to their owner (unresolved owner → withheld). The load‑bearing rule is **non‑admin `workingDir` confinement**: a non‑admin's session/one‑shot working dir must realpath‑resolve inside `~/codeman-users/<name>/cases`, checked BEFORE any disk write.
|
||||
- **Privileged actions are a one‑bit grant** (`canBypassPermissions`, default off): only granted users (and admins) get `--dangerously-skip-permissions` (others are silently downgraded to `--permission-mode auto`), shell‑mode sessions, cron `launchCommand`, and other CLIs' bypass flags. Machine‑level resources (remote/Docker host definitions, tunnel, self‑update, settings writes) are admin‑only.
|
||||
- **Admin actions are audited** append‑only to `~/.codeman/admin-audit.jsonl` (acting admin, action, target, IP). Passwords set by an admin create/reset are one‑time (returned once, force change). Under Basic auth, `logout` only truly ends QR‑issued sessions — to lock someone out, disable the account or reset the password (a proper login form is a deferred Phase 6).
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick reference
|
||||
|
||||
| Env / flag | Effect |
|
||||
|------------|--------|
|
||||
@@ -482,6 +511,8 @@ production layout (`~/.codeman`, `-L codeman`, port 3000).
|
||||
| `--https` | Enable TLS (adds HSTS) |
|
||||
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation |
|
||||
| `CODEMAN_GESTURE=1` | Make the gesture overlay available (widens CSP) |
|
||||
| `CODEMAN_DOCKER_BRIDGE_HOOKS=1` | Serve the hook endpoints on the docker bridge gateway (host‑internal, hooks‑only, `403` elsewhere) so in‑container hooks reach a loopback‑bound server — see §10 |
|
||||
| `CODEMAN_DOCKER_BRIDGE_HOST` | Override the bridge gateway IP the hooks listener binds (default: auto‑detect) |
|
||||
|
||||
**Audit log:** session lifecycle and server start are recorded in
|
||||
`~/.codeman/session-lifecycle.jsonl`.
|
||||
|
||||
+253
-42
@@ -5,7 +5,11 @@
|
||||
# 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)
|
||||
@@ -53,6 +57,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
|
||||
# ============================================================================
|
||||
@@ -336,6 +360,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 +763,44 @@ 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
|
||||
}
|
||||
|
||||
# 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 +815,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 ;;
|
||||
@@ -823,6 +930,20 @@ setup_sc_alias() {
|
||||
# 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"
|
||||
@@ -895,7 +1016,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() {
|
||||
@@ -929,8 +1058,15 @@ Environment=PATH=$PATH
|
||||
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 +1076,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 +1170,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 +1189,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 +1214,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 +1222,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 +1237,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 +1298,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 +1399,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
|
||||
# ========================================================================
|
||||
@@ -1269,12 +1442,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 +1463,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 +1485,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 +1502,15 @@ 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}"
|
||||
echo -e " http://localhost:3000"
|
||||
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 ""
|
||||
@@ -1377,10 +1559,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
|
||||
|
||||
@@ -1412,10 +1596,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 +1623,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 +1698,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 +1730,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
|
||||
|
||||
Generated
+31
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.4",
|
||||
"version": "1.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.4",
|
||||
"version": "1.7.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -28,6 +28,8 @@
|
||||
"chokidar": "^3.6.0",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^5.8.5",
|
||||
"heic-decode": "^2.1.0",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0",
|
||||
@@ -7023,6 +7025,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/heic-decode": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
|
||||
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"libheif-js": "^1.19.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||
@@ -7481,6 +7495,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/jpeg-js": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
|
||||
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
@@ -7664,6 +7684,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libheif-js": {
|
||||
"version": "1.19.8",
|
||||
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.19.8.tgz",
|
||||
"integrity": "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==",
|
||||
"license": "LGPL-3.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.1.4",
|
||||
"version": "1.7.1",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -69,6 +69,8 @@
|
||||
"chokidar": "^3.6.0",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^5.8.5",
|
||||
"heic-decode": "^2.1.0",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0",
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
// • Panel "re-grab" — pinch an existing floating panel and move it anywhere;
|
||||
// release over the tab strip to re-dock it (panel goes away, the tab stays).
|
||||
// This is the capability the old OS-window detach lost.
|
||||
// • Agent-window "grab-to-move" — pinch any floating *subagent* or *ultracode*
|
||||
// run/transcript window (the dashboard's own `.subagent-window` /
|
||||
// `.ultracode-window` floats) and move it anywhere. These windows stay owned
|
||||
// by app.js — we only nudge their `style.left/top` and ask app.js to redraw
|
||||
// the glowing connector line back to their session tab (its redraw reads live
|
||||
// rects, so the line tracks without us touching app.js internals). This is the
|
||||
// multi-monitor verb that lets these windows cross the physical monitor seam.
|
||||
// • Button "tap" — pinch over a toolbar button (Run / Run Shell) and release
|
||||
// in place → fires the button's real click handler. Drift too far first and
|
||||
// it's treated as a stray move, not a tap.
|
||||
@@ -36,12 +43,29 @@ import type { HandState } from '../gesture/types.ts';
|
||||
declare global {
|
||||
interface Window {
|
||||
__codemanGesture?: GestureBridge;
|
||||
/** The Codeman dashboard singleton (app.js, `window.app`). The gesture layer
|
||||
* reaches into it to redraw the floating-window connector lines and bump a
|
||||
* grabbed window's z-order while moving the subagent / ultracode windows.
|
||||
* Loosely typed — only the few members we touch. */
|
||||
app?: {
|
||||
updateConnectionLines?: () => void;
|
||||
saveSubagentWindowStates?: () => void;
|
||||
subagentWindowZIndex?: number;
|
||||
ultracodeWindowZIndex?: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TAB_SELECTOR = '.session-tab';
|
||||
/** An in-page floating session panel this layer spawned — re-grabbable to move. */
|
||||
const PANEL_SELECTOR = '.cg-float';
|
||||
/** The dashboard's own floating agent windows (subagent runs + ultracode run and
|
||||
* transcript windows). All three carry one of these classes, position via
|
||||
* `style.left/top`, and redraw their connector line from
|
||||
* `window.app.updateConnectionLines()` — so the hand can pick one up and move it
|
||||
* without app.js knowing. (`.ultracode-agent-window` also carries
|
||||
* `.ultracode-window`, so this matches it too.) */
|
||||
const WINDOW_SELECTOR = '.subagent-window, .ultracode-window';
|
||||
/** The session-tab strip; dropping a moved panel over it re-docks the session. */
|
||||
const DOCK_SELECTOR = '.session-tabs';
|
||||
/** Toolbar buttons a pinch can "tap": Run (#runBtn → app.run()) and Run Shell
|
||||
@@ -93,6 +117,17 @@ type Grab =
|
||||
dy: number;
|
||||
/** Cursor currently over the tab strip → releasing re-docks. */
|
||||
overDock: boolean;
|
||||
}
|
||||
| {
|
||||
/** A dashboard-owned floating agent window (subagent / ultracode) being
|
||||
* moved. We never remove or re-parent it — just reposition + redraw its
|
||||
* connector. The element ref can go stale mid-grab (SSE reconnect tears
|
||||
* ultracode windows down), so every move guards on `el.isConnected`. */
|
||||
kind: 'window';
|
||||
el: HTMLElement;
|
||||
/** Cursor→window-top-left offset at grab, so it doesn't snap. */
|
||||
dx: number;
|
||||
dy: number;
|
||||
};
|
||||
|
||||
/** Live state for one hand pinching a toolbar button (Run / Run Shell). */
|
||||
@@ -122,6 +157,8 @@ class GestureBridge {
|
||||
private taps = new Map<string, Tap>();
|
||||
/** Live floating panels, keyed by session id (idempotent per id). */
|
||||
private floats = new Map<string, FloatingPanel>();
|
||||
/** rAF coalescing for connector-line redraws while dragging an agent window. */
|
||||
private connectorRedrawScheduled = false;
|
||||
|
||||
constructor() {
|
||||
injectStyles();
|
||||
@@ -187,7 +224,7 @@ class GestureBridge {
|
||||
await this.gc.start();
|
||||
this.running = true;
|
||||
this.button.classList.add('on');
|
||||
this.status.textContent = 'on — pinch a tab or button';
|
||||
this.status.textContent = 'on — pinch a tab, window, or button';
|
||||
} catch (err) {
|
||||
// Surface the *real* cause: MediaPipe/Emscripten can throw a non-Error
|
||||
// (number/string), so `(err as Error).message` was logging "undefined".
|
||||
@@ -242,6 +279,22 @@ class GestureBridge {
|
||||
}
|
||||
}
|
||||
|
||||
// A dashboard-owned floating agent window (subagent / ultracode run or
|
||||
// transcript) → pick it up and move it. Priority below cg-float panels
|
||||
// (which sit far above), above tabs/buttons. We grab anywhere on the window
|
||||
// (not just its titlebar) since the hand is choosing the whole window.
|
||||
const win = this.hitClosest(x, y, WINDOW_SELECTOR);
|
||||
if (win) {
|
||||
const rect = win.getBoundingClientRect();
|
||||
// Match app.js's own drag: drop any bottom-anchor so left/top take effect.
|
||||
win.style.bottom = 'auto';
|
||||
win.classList.add('cg-win-grabbed');
|
||||
this.bringWindowToFront(win);
|
||||
this.grabs.set(hand, { kind: 'window', el: win, dx: x - rect.left, dy: y - rect.top });
|
||||
this.status.textContent = 'moving window';
|
||||
return;
|
||||
}
|
||||
|
||||
// A session tab → grab-and-pull-out into a floating panel (ghost follows).
|
||||
const tab = this.hitClosest(x, y, TAB_SELECTOR);
|
||||
const id = tab?.dataset.id;
|
||||
@@ -292,12 +345,16 @@ class GestureBridge {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === 'window') {
|
||||
this.moveWindow(grab.el, x - grab.dx, y - grab.dy);
|
||||
return;
|
||||
}
|
||||
// A button pinch that drifts too far is a stray move, not a tap — cancel it.
|
||||
const tap = this.taps.get(hand);
|
||||
if (tap && Math.hypot(x - tap.ox, y - tap.oy) > TAP_CANCEL_PX) {
|
||||
tap.el.classList.remove('cg-tap-armed');
|
||||
this.taps.delete(hand);
|
||||
this.status.textContent = 'on — pinch a tab or button';
|
||||
this.status.textContent = 'on — pinch a tab, window, or button';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +376,23 @@ class GestureBridge {
|
||||
else this.flash('placed');
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === 'window') {
|
||||
this.grabs.delete(hand);
|
||||
grab.el.classList.remove('cg-win-grabbed');
|
||||
// Clear the coalescer so the final placement always redraws, even if a
|
||||
// mid-drag rAF was throttled (tab briefly backgrounded) and left it latched.
|
||||
this.connectorRedrawScheduled = false;
|
||||
this.redrawWindowConnectors();
|
||||
// Persist subagent-window positions like app.js's own drag end does
|
||||
// (a no-op for ultracode windows, which aren't position-persisted).
|
||||
try {
|
||||
window.app?.saveSubagentWindowStates?.();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
this.flash('placed window');
|
||||
return;
|
||||
}
|
||||
// Release over the same button → fire its real click handler.
|
||||
const tap = this.taps.get(hand);
|
||||
if (tap) {
|
||||
@@ -373,6 +447,59 @@ class GestureBridge {
|
||||
float.el.style.top = `${t}px`;
|
||||
}
|
||||
|
||||
/** Move a dashboard-owned agent window by its top-left, clamped on-screen, then
|
||||
* redraw its connector line. The window self-positions via `style.left/top` and
|
||||
* app.js's connector redraw reads live rects, so this tracks without touching
|
||||
* app.js internals. Guards on `isConnected`: ultracode windows can be torn down
|
||||
* (SSE reconnect / auto-close) while still held. Clamps to `innerWidth/Height`,
|
||||
* which equals the *spanned* viewport in a multi-monitor window — so the window
|
||||
* can still travel across the physical monitor seam, just not off-screen. */
|
||||
private moveWindow(el: HTMLElement, left: number, top: number): void {
|
||||
if (!el.isConnected) return;
|
||||
const w = el.offsetWidth || 380;
|
||||
const h = el.offsetHeight || 320;
|
||||
const l = Math.min(Math.max(4, left), Math.max(4, window.innerWidth - w - 4));
|
||||
const t = Math.min(Math.max(4, top), Math.max(4, window.innerHeight - h - 4));
|
||||
el.style.left = `${l}px`;
|
||||
el.style.top = `${t}px`;
|
||||
this.redrawWindowConnectors();
|
||||
}
|
||||
|
||||
/** Ask app.js to redraw all connector lines (subagent + ultracode), coalesced to
|
||||
* one per frame so per-frame drags don't thrash. `updateConnectionLines()` is
|
||||
* itself debounced in app.js, but we rAF-gate too in case an older dashboard
|
||||
* build isn't, and to no-op cleanly when app.js isn't present (standalone). */
|
||||
private redrawWindowConnectors(): void {
|
||||
if (this.connectorRedrawScheduled) return;
|
||||
this.connectorRedrawScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.connectorRedrawScheduled = false;
|
||||
try {
|
||||
window.app?.updateConnectionLines?.();
|
||||
} catch {
|
||||
/* app.js may not expose it (standalone playground) */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Pop a grabbed window above its siblings using app.js's own z-counter, so a
|
||||
* picked-up window comes to the front like a real focus. Cosmetic + best-effort. */
|
||||
private bringWindowToFront(el: HTMLElement): void {
|
||||
const app = window.app;
|
||||
if (!app) return;
|
||||
try {
|
||||
if (el.classList.contains('ultracode-window')) {
|
||||
app.ultracodeWindowZIndex = (app.ultracodeWindowZIndex ?? 1000) + 1;
|
||||
el.style.zIndex = String(app.ultracodeWindowZIndex);
|
||||
} else {
|
||||
app.subagentWindowZIndex = (app.subagentWindowZIndex ?? 1000) + 1;
|
||||
el.style.zIndex = String(app.subagentWindowZIndex);
|
||||
}
|
||||
} catch {
|
||||
/* cosmetic only */
|
||||
}
|
||||
}
|
||||
|
||||
private positionGhost(ghost: HTMLElement, x: number, y: number): void {
|
||||
ghost.style.left = `${x}px`;
|
||||
ghost.style.top = `${y}px`;
|
||||
@@ -385,17 +512,19 @@ class GestureBridge {
|
||||
if (grab.kind === 'tab') {
|
||||
grab.ghost.remove();
|
||||
grab.tab.classList.remove('cg-grabbed');
|
||||
} else {
|
||||
} else if (grab.kind === 'panel') {
|
||||
grab.panel.el.style.pointerEvents = '';
|
||||
grab.panel.el.classList.remove('cg-float-grabbed', 'cg-redock');
|
||||
} else {
|
||||
grab.el.classList.remove('cg-win-grabbed');
|
||||
}
|
||||
}
|
||||
this.grabs.clear();
|
||||
for (const tap of this.taps.values()) tap.el.classList.remove('cg-tap-armed');
|
||||
this.taps.clear();
|
||||
document
|
||||
.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed`)
|
||||
.forEach((t) => t.classList.remove('cg-grabbed', 'cg-tap-armed'));
|
||||
.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`)
|
||||
.forEach((t) => t.classList.remove('cg-grabbed', 'cg-tap-armed', 'cg-win-grabbed'));
|
||||
}
|
||||
|
||||
private onStatus(fps: number, hands: HandState[]): void {
|
||||
@@ -491,6 +620,10 @@ function injectStyles(): void {
|
||||
.cg-status { color: #9aa0a6; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.session-tab.cg-grabbed { opacity: .35; outline: 2px dashed #4ade80; outline-offset: -2px; }
|
||||
.cg-tap-armed { outline: 2px solid #4ade80 !important; outline-offset: 2px; box-shadow: 0 0 0 4px rgba(74,222,128,.25) !important; }
|
||||
.subagent-window.cg-win-grabbed, .ultracode-window.cg-win-grabbed {
|
||||
outline: 2px solid #4ade80 !important; outline-offset: -2px;
|
||||
box-shadow: 0 12px 48px rgba(74,222,128,.5) !important;
|
||||
}
|
||||
.cg-float {
|
||||
position: fixed; left: 0; top: 0; width: ${FLOAT_W}px; height: ${FLOAT_H}px;
|
||||
z-index: ${Z}; display: flex; flex-direction: column; overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build the Codeman agent base image locally (decision: "build locally on first
|
||||
* use", see docs/docker-cases-plan.md). No registry account required.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-agent-image.mjs [--engine docker|podman] [--image <ref>] [--no-cache]
|
||||
*
|
||||
* Defaults: engine=docker (falls back to podman if docker is absent),
|
||||
* image=codeman/agent:base
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = join(__dirname, '..');
|
||||
const DOCKERFILE = join(REPO_ROOT, 'docker', 'agent.Dockerfile');
|
||||
const DEFAULT_IMAGE = 'codeman/agent:base';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { image: DEFAULT_IMAGE, engine: undefined, noCache: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--image') args.image = argv[++i];
|
||||
else if (a === '--engine') args.engine = argv[++i];
|
||||
else if (a === '--no-cache') args.noCache = true;
|
||||
else if (a === '-h' || a === '--help') args.help = true;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function engineAvailable(engine) {
|
||||
const r = spawnSync(engine, ['--version'], { stdio: 'ignore' });
|
||||
return r.status === 0;
|
||||
}
|
||||
|
||||
function resolveEngine(preferred) {
|
||||
if (preferred) {
|
||||
if (!engineAvailable(preferred)) {
|
||||
console.error(`[build-agent-image] engine "${preferred}" not found on PATH`);
|
||||
process.exit(1);
|
||||
}
|
||||
return preferred;
|
||||
}
|
||||
if (engineAvailable('docker')) return 'docker';
|
||||
if (engineAvailable('podman')) return 'podman';
|
||||
console.error('[build-agent-image] neither docker nor podman found on PATH. Install one and retry.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log('Usage: node scripts/build-agent-image.mjs [--engine docker|podman] [--image <ref>] [--no-cache]');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const engine = resolveEngine(args.engine);
|
||||
const buildArgs = ['build', '-f', DOCKERFILE, '-t', args.image];
|
||||
if (args.noCache) buildArgs.push('--no-cache');
|
||||
buildArgs.push(REPO_ROOT);
|
||||
|
||||
console.log(`[build-agent-image] ${engine} ${buildArgs.join(' ')}`);
|
||||
const child = spawn(engine, buildArgs, { stdio: 'inherit' });
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
console.log(`\n[build-agent-image] built ${args.image}. Docker cases can now launch.`);
|
||||
} else {
|
||||
console.error(`\n[build-agent-image] build failed (exit ${code}).`);
|
||||
}
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* capture-readme-gifs.mjs
|
||||
*
|
||||
* Deterministic README GIFs — no real server, Claude CLI, or tmux. Reuses the
|
||||
* mock-injection pipeline from capture-readme-screenshots.mjs (static file
|
||||
* server + page.route mocks), drives a scripted timeline in the page, records
|
||||
* it with Playwright video, and converts to GIF via ffmpeg palette encoding.
|
||||
*
|
||||
* Scenes:
|
||||
* 1. subagent-demo.gif — terminal spawns 3 parallel agents; floating agent
|
||||
* windows open one by one and stream tool-call activity live (driven
|
||||
* through the real _onSubagentDiscovered/_onSubagentToolCall handlers).
|
||||
* 2. zerolag-demo.gif — side-by-side typing: instant local echo (zerolag)
|
||||
* vs bursty ~350 ms server echo, rendered with the vendored xterm.
|
||||
*
|
||||
* Usage: node scripts/capture-readme-gifs.mjs
|
||||
* SCREENSHOT_OUT_DIR=/path/to/review node scripts/capture-readme-gifs.mjs
|
||||
* Output: docs/images/ (or flat into SCREENSHOT_OUT_DIR)
|
||||
* Requires: ffmpeg
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { execSync } from 'child_process';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
PORT,
|
||||
SESSION_IDS,
|
||||
STANDARD_SESSIONS,
|
||||
buildInitPayload,
|
||||
startStaticServer,
|
||||
setupRoutes,
|
||||
injectState,
|
||||
outPath,
|
||||
RST, GRN, YEL, MAG, CYN, GRY, BOLD,
|
||||
} from './capture-readme-screenshots.mjs';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const GIF_COLORS = 192;
|
||||
|
||||
// ─── ffmpeg conversion (palette recipe from capture-subagent-gif.mjs) ────────
|
||||
|
||||
function webmToGif(videoPath, gifPath, { ss, duration, width, fps }) {
|
||||
// One GLOBAL palette (default stats_mode=full) + ordered dither: per-frame
|
||||
// palettes (stats_mode=single:new=1) make dirty rectangles visibly mismatch
|
||||
// on flat dark UI, and error-diffusion dither shimmers between frames.
|
||||
const filters = `fps=${fps},scale=${width}:-1:flags=lanczos`;
|
||||
execSync(
|
||||
`ffmpeg -y -loglevel error -ss ${ss.toFixed(2)} -t ${duration} -i "${videoPath}" ` +
|
||||
`-vf "${filters},split[s0][s1];[s0]palettegen=max_colors=${GIF_COLORS}:reserve_transparent=0[p];` +
|
||||
`[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" "${gifPath}"`,
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Scene 1: subagent demo ──────────────────────────────────────────────────
|
||||
|
||||
const SUBAGENT_VIEWPORT = { width: 1440, height: 810 };
|
||||
|
||||
// Terminal content visible before the agents spawn
|
||||
const TERMINAL_PRESPAWN = [
|
||||
'',
|
||||
`${GRN}●${RST} Working on ${CYN}/home/arkon/codeman-cases/testcase${RST} - I'll use the ${BOLD}Task tool${RST} to spawn parallel agents.`,
|
||||
'',
|
||||
`${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`,
|
||||
` ${GRY}░${RST} Read ${BOLD}127${RST} lines ${GRY}│${RST} ${CYN}1.2KB${RST}`,
|
||||
'',
|
||||
`${GRN}●${RST} ${BOLD}Bash${RST}(find . -name "*.ts" -not -path "*/node_modules/*" | head -20)`,
|
||||
` ${GRY}░${RST} ./src/index.ts`,
|
||||
` ${GRY}░${RST} ./src/session.ts`,
|
||||
` ${GRY}░${RST} ./src/web/server.ts`,
|
||||
` ${GRY}░${RST} ${GRY}... (17 more)${RST}`,
|
||||
'',
|
||||
`${GRN}●${RST} I'll spawn 3 parallel research agents to analyze different parts of the codebase simultaneously.`,
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
function makeAgent(agentId, description, startedOffsetMs) {
|
||||
return {
|
||||
agentId,
|
||||
sessionId: 'claude-sess-w1-0001',
|
||||
projectHash: 'abc123',
|
||||
filePath: `/tmp/${agentId}.jsonl`,
|
||||
startedAt: new Date(Date.now() - startedOffsetMs).toISOString(),
|
||||
lastActivityAt: Date.now(),
|
||||
status: 'active',
|
||||
toolCallCount: 0,
|
||||
entryCount: 0,
|
||||
fileSize: 4000,
|
||||
description,
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
modelShort: 'haiku',
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
parentSessionId: SESSION_IDS.w1,
|
||||
};
|
||||
}
|
||||
|
||||
// Timeline events: t (ms from scene start) + kind
|
||||
// term — write raw data to the session terminal
|
||||
// discover — register subagent + open + position its floating window
|
||||
// tool — stream a tool call into an agent window
|
||||
// msg — stream an assistant message into an agent window
|
||||
// complete — flip an agent to completed
|
||||
function buildSubagentTimeline() {
|
||||
const T = (lines) => lines.join('\r\n') + '\r\n';
|
||||
const tool = (t, agentId, name, input) => ({ t, kind: 'tool', agentId, tool: name, input });
|
||||
const msg = (t, agentId, text) => ({ t, kind: 'msg', agentId, text });
|
||||
|
||||
return [
|
||||
{
|
||||
t: 600,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${BOLD}Task${RST}(Find and document all API endpoints in src/)`,
|
||||
` ${GRY}░${RST} Spawned ${CYN}agent-001${RST} ${GRY}(haiku)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
{
|
||||
t: 1000,
|
||||
kind: 'discover',
|
||||
agent: makeAgent('agent-001', 'Find and document all API endpoints in src/', 2000),
|
||||
x: 440, y: 45,
|
||||
},
|
||||
tool(1500, 'agent-001', 'Glob', { pattern: 'src/**/*.ts' }),
|
||||
{
|
||||
t: 2000,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${BOLD}Task${RST}(Explore and understand test structure in test/)`,
|
||||
` ${GRY}░${RST} Spawned ${CYN}agent-002${RST} ${GRY}(haiku)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(2200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/server.ts' }),
|
||||
{
|
||||
t: 2500,
|
||||
kind: 'discover',
|
||||
agent: makeAgent('agent-002', 'Explore and understand test structure in test/', 1200),
|
||||
x: 880, y: 45,
|
||||
},
|
||||
tool(3000, 'agent-002', 'Glob', { pattern: 'test/**/*.test.ts' }),
|
||||
{
|
||||
t: 3300,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${BOLD}Task${RST}(Analyze TypeScript type definitions in src/types.ts)`,
|
||||
` ${GRY}░${RST} Spawned ${CYN}agent-003${RST} ${GRY}(haiku)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(3500, 'agent-001', 'Grep', { pattern: 'app\\.get|app\\.post|app\\.delete', path: 'src/' }),
|
||||
{
|
||||
t: 3800,
|
||||
kind: 'discover',
|
||||
agent: makeAgent('agent-003', 'Analyze TypeScript type definitions in src/types.ts', 400),
|
||||
x: 660, y: 400,
|
||||
},
|
||||
tool(4100, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }),
|
||||
{
|
||||
t: 4500,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${MAG}✻${RST} ${YEL}Waiting for agents...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 32s · ↓ 1.7k tokens · thinking)${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(4700, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types.ts' }),
|
||||
tool(5200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/schemas.ts' }),
|
||||
tool(5600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/config/vitest.config.ts' }),
|
||||
tool(6100, 'agent-003', 'Grep', { pattern: 'export (interface|type)', path: 'src/types/' }),
|
||||
msg(6700, 'agent-001', 'Found 47 API endpoints across server.ts. Documenting REST paths...'),
|
||||
tool(7100, 'agent-002', 'Grep', { pattern: 'const PORT =', path: 'test/' }),
|
||||
msg(7700, 'agent-002', 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...'),
|
||||
tool(8100, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types/index.ts' }),
|
||||
msg(8700, 'agent-003', 'Mapped 38 exported interfaces across 15 domain files. Building summary...'),
|
||||
{
|
||||
t: 9300,
|
||||
kind: 'term',
|
||||
data: T([
|
||||
`${GRN}●${RST} ${CYN}agent-001${RST}: ${GRY}12 tool calls — Glob, Read(server.ts), Grep(endpoints)...${RST}`,
|
||||
`${GRN}●${RST} ${CYN}agent-002${RST}: ${GRY}8 tool calls — Glob, Read(test-utils), Read(vitest.config)...${RST}`,
|
||||
`${GRN}●${RST} ${CYN}agent-003${RST}: ${GRY}7 tool calls — Read(types.ts), Grep(interface)...${RST}`,
|
||||
'',
|
||||
]),
|
||||
},
|
||||
tool(10100, 'agent-001', 'Glob', { pattern: 'src/web/routes/*.ts' }),
|
||||
tool(10600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/setup.ts' }),
|
||||
tool(11100, 'agent-003', 'Grep', { pattern: 'assertNever', path: 'src/' }),
|
||||
{
|
||||
t: 11600,
|
||||
kind: 'term',
|
||||
data: T([`${GRN}●${RST} ${GRY}171.8k, 13s${RST} ${GRY}│${RST} ${GRY}1.7k tokens${RST} ${GRY}│${RST} ${GRY}thinking${RST}`, '']),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const SUBAGENT_TAIL_HOLD = 2500; // hold the final frame
|
||||
|
||||
async function recordSubagentScene(browser, videoDir) {
|
||||
console.log('\n1/2 Recording subagent-demo...');
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: SUBAGENT_VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
recordVideo: { dir: videoDir, size: SUBAGENT_VIEWPORT },
|
||||
});
|
||||
const recStart = Date.now();
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
// Start with NO subagents — they appear during the recording
|
||||
const initPayload = buildInitPayload(STANDARD_SESSIONS);
|
||||
await setupRoutes(page, initPayload, TERMINAL_PRESPAWN);
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await injectState(page, initPayload, TERMINAL_PRESPAWN, SESSION_IDS.w1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
try { window.app?.fitAddon?.fit(); } catch {}
|
||||
window.app?.terminal?.scrollToBottom();
|
||||
});
|
||||
await sleep(500);
|
||||
|
||||
const timeline = buildSubagentTimeline();
|
||||
const totalMs = Math.max(...timeline.map((e) => e.t)) + SUBAGENT_TAIL_HOLD;
|
||||
const sceneStart = Date.now();
|
||||
|
||||
// Run the whole timeline inside the page so events interleave naturally
|
||||
await page.evaluate((events) => {
|
||||
const app = window.app;
|
||||
for (const ev of events) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (ev.kind === 'term') {
|
||||
app.terminal.write(ev.data);
|
||||
app.terminal.scrollToBottom();
|
||||
} else if (ev.kind === 'discover') {
|
||||
app._onSubagentDiscovered(ev.agent);
|
||||
app.openSubagentWindow(ev.agent.agentId);
|
||||
// The spawn animation (400ms) lands on the auto-grid; glide to our tile after it
|
||||
setTimeout(() => {
|
||||
const win = app.subagentWindows.get(ev.agent.agentId);
|
||||
if (win?.element) {
|
||||
win.element.style.transition = 'left 0.25s ease, top 0.25s ease';
|
||||
win.element.style.left = `${ev.x}px`;
|
||||
win.element.style.top = `${ev.y}px`;
|
||||
}
|
||||
}, 520);
|
||||
setTimeout(() => {
|
||||
const win = app.subagentWindows.get(ev.agent.agentId);
|
||||
if (win?.element) win.element.style.transition = '';
|
||||
app.updateConnectionLines();
|
||||
}, 850);
|
||||
} else if (ev.kind === 'tool') {
|
||||
app._onSubagentToolCall({
|
||||
agentId: ev.agentId,
|
||||
tool: ev.tool,
|
||||
input: ev.input,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else if (ev.kind === 'msg') {
|
||||
app._onSubagentMessage({
|
||||
agentId: ev.agentId,
|
||||
role: 'assistant',
|
||||
text: ev.text,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else if (ev.kind === 'complete') {
|
||||
app._onSubagentCompleted({ agentId: ev.agentId, timestamp: new Date().toISOString() });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('timeline event failed', ev, err);
|
||||
}
|
||||
}, ev.t);
|
||||
}
|
||||
}, timeline);
|
||||
|
||||
await sleep(totalMs + 500);
|
||||
|
||||
await page.close();
|
||||
const videoPath = await page.video().path();
|
||||
await context.close();
|
||||
|
||||
return {
|
||||
videoPath,
|
||||
ss: (sceneStart - recStart) / 1000 - 0.4,
|
||||
duration: (totalMs + 400) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Scene 2: zerolag typing comparison ──────────────────────────────────────
|
||||
|
||||
const ZEROLAG_VIEWPORT = { width: 1280, height: 470 };
|
||||
const TYPED_TEXT = 'echo "zero lag typing from anywhere"';
|
||||
const TYPE_INTERVAL_MS = 110;
|
||||
const REMOTE_FLUSH_MS = 350; // server-echo pane flushes queued chars in bursts
|
||||
const ZEROLAG_TAIL_HOLD = 1800;
|
||||
|
||||
const ZEROLAG_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="http://localhost:${PORT}/vendor/xterm.css">
|
||||
<script src="http://localhost:${PORT}/vendor/xterm.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 1280px; height: 470px; background: #0a0a0c;
|
||||
display: flex; align-items: center; justify-content: center; gap: 48px;
|
||||
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.pane { width: 560px; }
|
||||
.card {
|
||||
background: #131316; border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
|
||||
}
|
||||
.card-head {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; align-self: center; }
|
||||
.title { font-size: 15px; font-weight: 600; color: #e8e8ea; }
|
||||
.sub { font-size: 12.5px; color: #8b8b92; }
|
||||
.term { padding: 16px 8px 12px 16px; height: 165px; }
|
||||
.good .dot { background: #22c55e; box-shadow: 0 0 8px rgba(34,197,94,0.7); }
|
||||
.bad .dot { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,0.7); }
|
||||
.tag {
|
||||
margin-top: 14px; text-align: center; font-size: 14.5px; color: #7e7e86;
|
||||
}
|
||||
.tag b { color: #22c55e; font-weight: 600; }
|
||||
.bad-tag b { color: #ef4444; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pane">
|
||||
<div class="card good">
|
||||
<div class="card-head">
|
||||
<span class="dot"></span>
|
||||
<span class="title">With zerolag-input</span>
|
||||
<span class="sub">instant local echo</span>
|
||||
</div>
|
||||
<div class="term" id="termLeft"></div>
|
||||
</div>
|
||||
<div class="tag">keystrokes echo in <b>0 ms</b></div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<div class="card bad">
|
||||
<div class="card-head">
|
||||
<span class="dot"></span>
|
||||
<span class="title">Without</span>
|
||||
<span class="sub">server round-trip echo</span>
|
||||
</div>
|
||||
<div class="term" id="termRight"></div>
|
||||
</div>
|
||||
<div class="tag bad-tag">keystrokes echo after <b>~350 ms</b></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
async function recordZerolagScene(browser, videoDir) {
|
||||
console.log('\n2/2 Recording zerolag-demo...');
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: ZEROLAG_VIEWPORT,
|
||||
deviceScaleFactor: 1,
|
||||
recordVideo: { dir: videoDir, size: ZEROLAG_VIEWPORT },
|
||||
});
|
||||
const recStart = Date.now();
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
await page.setContent(ZEROLAG_HTML, { waitUntil: 'load' });
|
||||
await page.waitForFunction(() => typeof Terminal !== 'undefined');
|
||||
|
||||
await page.evaluate(() => {
|
||||
const theme = {
|
||||
background: '#131316',
|
||||
foreground: '#e8e8ea',
|
||||
cursor: '#22c55e',
|
||||
cursorAccent: '#131316',
|
||||
};
|
||||
const mk = (id) => {
|
||||
const term = new Terminal({
|
||||
cols: 44,
|
||||
rows: 5,
|
||||
fontSize: 20,
|
||||
fontFamily: "'SF Mono', 'Cascadia Code', Menlo, monospace",
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
theme,
|
||||
});
|
||||
term.open(document.getElementById(id));
|
||||
term.write('\x1b[32m❯\x1b[0m ');
|
||||
return term;
|
||||
};
|
||||
window.termLeft = mk('termLeft');
|
||||
window.termRight = mk('termRight');
|
||||
});
|
||||
await sleep(600);
|
||||
|
||||
const sceneStart = Date.now();
|
||||
const typingMs = TYPED_TEXT.length * TYPE_INTERVAL_MS;
|
||||
const totalMs = typingMs + REMOTE_FLUSH_MS + ZEROLAG_TAIL_HOLD;
|
||||
|
||||
await page.evaluate(
|
||||
({ text, interval, flushEvery }) => {
|
||||
let i = 0;
|
||||
const remoteQueue = [];
|
||||
const typer = setInterval(() => {
|
||||
if (i >= text.length) { clearInterval(typer); return; }
|
||||
const ch = text[i++];
|
||||
window.termLeft.write(ch); // local echo: instant
|
||||
remoteQueue.push(ch); // server echo: waits for the round-trip
|
||||
}, interval);
|
||||
const flusher = setInterval(() => {
|
||||
if (remoteQueue.length) window.termRight.write(remoteQueue.splice(0).join(''));
|
||||
if (i >= text.length && remoteQueue.length === 0) clearInterval(flusher);
|
||||
}, flushEvery);
|
||||
},
|
||||
{ text: TYPED_TEXT, interval: TYPE_INTERVAL_MS, flushEvery: REMOTE_FLUSH_MS }
|
||||
);
|
||||
|
||||
await sleep(totalMs + 400);
|
||||
|
||||
await page.close();
|
||||
const videoPath = await page.video().path();
|
||||
await context.close();
|
||||
|
||||
return {
|
||||
videoPath,
|
||||
ss: (sceneStart - recStart) / 1000 - 0.6, // small lead-in with idle cursors
|
||||
duration: (totalMs + 600) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log('='.repeat(60));
|
||||
console.log('Codeman README GIF Capture');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const server = await startStaticServer();
|
||||
const videoDir = mkdtempSync(join(tmpdir(), 'codeman-gifs-'));
|
||||
let browser;
|
||||
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
||||
});
|
||||
|
||||
const sub = await recordSubagentScene(browser, videoDir);
|
||||
const subGif = outPath('images', 'subagent-demo.gif');
|
||||
webmToGif(sub.videoPath, subGif, { ss: Math.max(0, sub.ss), duration: sub.duration, width: 960, fps: 8 });
|
||||
console.log(` Saved: ${subGif}`);
|
||||
|
||||
const zl = await recordZerolagScene(browser, videoDir);
|
||||
const zlGif = outPath('images', 'zerolag-demo.gif');
|
||||
webmToGif(zl.videoPath, zlGif, { ss: Math.max(0, zl.ss), duration: zl.duration, width: 900, fps: 10 });
|
||||
console.log(` Saved: ${zlGif}`);
|
||||
|
||||
console.log('\nDone.');
|
||||
} catch (err) {
|
||||
console.error('\nFatal error:', err.message);
|
||||
console.error(err.stack);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
server.close();
|
||||
rmSync(videoDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => process.exit(1));
|
||||
|
||||
main();
|
||||
@@ -51,6 +51,9 @@ async function newCtx(browser) {
|
||||
localStorage.setItem('codeman:skin', skin);
|
||||
localStorage.setItem('codeman-font-size', String(font));
|
||||
const blob = { skin, showFileBrowser: false, showProjectInsights: false };
|
||||
// Don't auto-hide subagent windows that belong to a non-active tab — the
|
||||
// subagent scene re-homes agents and needs both windows visible at once.
|
||||
blob.subagentActiveTabOnly = false;
|
||||
if (planUsage) blob.showPlanUsageLimits = true;
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify(blob));
|
||||
} catch {
|
||||
@@ -136,9 +139,9 @@ async function sceneSubagent(browser) {
|
||||
const sessions = await listSessions(page);
|
||||
const targetId = process.env.SUBAGENT_SID || (sessions.find((s) => s.mode === 'claude') || sessions[0])?.id;
|
||||
if (targetId) await page.evaluate((id) => window.app.selectSession(id), targetId);
|
||||
// Wait (up to ~25s) for live subagents to arrive via SSE into app.subagents.
|
||||
// Wait (up to ~45s) for live subagents to arrive via SSE into app.subagents.
|
||||
let agents = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
for (let i = 0; i < 45; i++) {
|
||||
agents = await page.evaluate(() =>
|
||||
Array.from(window.app.subagents?.entries?.() || []).map(([id, a]) => ({ id, name: a.name ?? a.agentType ?? '' }))
|
||||
);
|
||||
@@ -151,6 +154,44 @@ async function sceneSubagent(browser) {
|
||||
await context.close();
|
||||
return;
|
||||
}
|
||||
// The window body renders from app.subagentActivity, which fills ONLY from live
|
||||
// SSE tool-call/progress events — a fresh client never gets past activity replayed.
|
||||
// So sit connected and wait for live activity to accumulate, then open the two
|
||||
// agents that actually have content (otherwise the windows read "No activity yet").
|
||||
let active = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
active = await page.evaluate(() =>
|
||||
Array.from(window.app.subagentActivity?.entries?.() || [])
|
||||
.filter(([, arr]) => Array.isArray(arr) && arr.length >= 1)
|
||||
.map(([id, arr]) => ({ id, n: arr.length }))
|
||||
.sort((a, b) => b.n - a.n)
|
||||
);
|
||||
if (active.length >= 2) break;
|
||||
// xhigh-effort agents churn in bursts between long thinking pauses, so be
|
||||
// patient (~150s); accept a single populated window after ~45s if that's all.
|
||||
if (i >= 30 && active.length >= 1) break;
|
||||
await sleep(1500);
|
||||
}
|
||||
console.log(' agents with live activity:', JSON.stringify(active));
|
||||
const openIds = (active.length ? active : agents).map((a) => a.id);
|
||||
// Capture-only DOM nudge: on fresh dev sessions, a tab's claudeSessionId stays the
|
||||
// Codeman id and never becomes the real Claude conversation UUID, so the window
|
||||
// open-gate (claudeSessionId === agent.sessionId) + the activeTabOnly hide rule both
|
||||
// fail. Re-home the chosen agents onto the active tab and align its claudeSessionId
|
||||
// to the agents' (shared) sessionId so the windows open AND show their live activity.
|
||||
await page.evaluate(
|
||||
(ids) => {
|
||||
const activeId = window.app.activeSessionId;
|
||||
const tab = window.app.sessions.get(activeId);
|
||||
ids.slice(0, 2).forEach((id) => {
|
||||
const a = window.app.subagents.get(id);
|
||||
if (!a) return;
|
||||
a.parentSessionId = activeId;
|
||||
if (tab && a.sessionId) tab.claudeSessionId = a.sessionId;
|
||||
});
|
||||
},
|
||||
openIds
|
||||
);
|
||||
await page.evaluate(
|
||||
(ids) => {
|
||||
ids.slice(0, 2).forEach((id) => {
|
||||
@@ -159,22 +200,33 @@ async function sceneSubagent(browser) {
|
||||
} catch {}
|
||||
});
|
||||
},
|
||||
agents.map((a) => a.id)
|
||||
openIds
|
||||
);
|
||||
await sleep(2000);
|
||||
await page.evaluate(() => {
|
||||
// Viewport-relative tiling: center two subagent windows over the terminal so
|
||||
// the layout adapts to whatever VW/VH the capture uses (e.g. the HQ 1100×650
|
||||
// recipe) instead of overflowing at narrower widths.
|
||||
const wins = Array.from(window.app.subagentWindows.values());
|
||||
const place = [
|
||||
{ left: 360, top: 60, w: 430, h: 330 },
|
||||
{ left: 810, top: 60, w: 430, h: 330 },
|
||||
];
|
||||
const W = window.innerWidth;
|
||||
const H = window.innerHeight;
|
||||
const winW = Math.min(440, Math.floor((W - 60) / 2 - 10));
|
||||
const winH = Math.min(360, Math.floor(H * 0.56));
|
||||
const top = Math.floor(H * 0.16);
|
||||
const gap = 16;
|
||||
const totalW = winW * 2 + gap;
|
||||
const startLeft = Math.max(16, Math.floor((W - totalW) / 2));
|
||||
wins.slice(0, 2).forEach((win, i) => {
|
||||
const el = win.element;
|
||||
const p = place[i];
|
||||
el.style.left = p.left + 'px';
|
||||
el.style.top = p.top + 'px';
|
||||
el.style.width = p.w + 'px';
|
||||
el.style.height = p.h + 'px';
|
||||
// Force visible: a freshly opened window may be hidden by the activeTabOnly
|
||||
// rule before we override it (we also seed subagentActiveTabOnly:false).
|
||||
win.hidden = false;
|
||||
win.minimized = false;
|
||||
el.style.display = 'flex';
|
||||
el.style.left = startLeft + i * (winW + gap) + 'px';
|
||||
el.style.top = top + 'px';
|
||||
el.style.width = winW + 'px';
|
||||
el.style.height = winH + 'px';
|
||||
});
|
||||
});
|
||||
await sleep(1500);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,52 @@
|
||||
*/
|
||||
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||
import { stripAnsi } from './utils/index.js';
|
||||
|
||||
const MAGIC_LINK_RE = /codeman:\/\/attach\?([^\s<>"']+)/g;
|
||||
const CODEX_SAVED_FILE_RE = /\bSaved to:\s*(file:\/\/[^\s<>"']+)/gi;
|
||||
|
||||
export interface TerminalAttachmentRequest {
|
||||
path: string;
|
||||
source: 'external' | 'codex-generated';
|
||||
}
|
||||
|
||||
export interface ParseTerminalAttachmentOptions {
|
||||
/**
|
||||
* Enable the Codex `Saved to: file://...` scanner. Only codex-mode sessions
|
||||
* may set this — the relaxed codex-generated trust policy must never be
|
||||
* reachable from other modes' (prompt-injectable) terminal output.
|
||||
*/
|
||||
codexArtifacts?: boolean;
|
||||
}
|
||||
|
||||
export function parseAttachmentMagicLinks(data: string): string[] {
|
||||
return parseMagicAttachmentRequests(data).map((request) => request.path);
|
||||
}
|
||||
|
||||
export function parseTerminalAttachmentRequests(
|
||||
data: string,
|
||||
options: ParseTerminalAttachmentOptions = {}
|
||||
): TerminalAttachmentRequest[] {
|
||||
const results: TerminalAttachmentRequest[] = [];
|
||||
const seen = new Set<string>();
|
||||
const requests = options.codexArtifacts
|
||||
? [...parseMagicAttachmentRequests(data), ...parseCodexGeneratedArtifactRequests(data)]
|
||||
: parseMagicAttachmentRequests(data);
|
||||
|
||||
for (const request of requests) {
|
||||
const key = `${request.source}:${request.path}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
results.push(request);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function parseMagicAttachmentRequests(data: string): TerminalAttachmentRequest[] {
|
||||
const results: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -27,6 +68,31 @@ export function parseAttachmentMagicLinks(data: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
return results.map((path) => ({ path, source: 'external' }));
|
||||
}
|
||||
|
||||
function parseCodexGeneratedArtifactRequests(data: string): TerminalAttachmentRequest[] {
|
||||
const results: TerminalAttachmentRequest[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Codex styles its TUI output — strip ANSI first so a trailing SGR reset
|
||||
// (e.g. `...mockup.png\x1b[0m`) doesn't ride into the captured URL and break
|
||||
// the extension allowlist check.
|
||||
for (const match of stripAnsi(data).matchAll(CODEX_SAVED_FILE_RE)) {
|
||||
const rawUrl = trimTrailingPunctuation(match[1] || '');
|
||||
try {
|
||||
const filePath = fileURLToPath(rawUrl);
|
||||
if (!isAbsolute(filePath)) continue;
|
||||
const extension = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
if (!isSupportedAttachmentExtension(extension)) continue;
|
||||
if (seen.has(filePath)) continue;
|
||||
seen.add(filePath);
|
||||
results.push({ path: filePath, source: 'codex-generated' });
|
||||
} catch {
|
||||
// Ignore malformed terminal text. Generated-artifact links are advisory.
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,18 @@ import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from './config/att
|
||||
import { validateSessionFilePath } from './web/route-helpers.js';
|
||||
import type { AttachmentDetectedEvent, AttachmentDetectedType } from './types.js';
|
||||
|
||||
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set(['png', 'pdf', 'docx', 'pptx', 'md', 'txt']);
|
||||
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'pdf',
|
||||
'docx',
|
||||
'pptx',
|
||||
'md',
|
||||
'txt',
|
||||
]);
|
||||
|
||||
export type AttachmentSource = 'detected' | 'external';
|
||||
|
||||
@@ -96,7 +107,7 @@ export function isSupportedAttachmentExtension(extension: string): boolean {
|
||||
|
||||
export function getAttachmentType(extension: string): AttachmentDetectedType {
|
||||
const normalized = extension.toLowerCase().replace(/^\./, '');
|
||||
if (normalized === 'png') return 'image';
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(normalized)) return 'image';
|
||||
if (normalized === 'pdf') return 'pdf';
|
||||
if (normalized === 'pptx') return 'presentation';
|
||||
if (normalized === 'md') return 'markdown';
|
||||
|
||||
+166
@@ -584,7 +584,11 @@ program
|
||||
'--allow-unauthenticated-network',
|
||||
'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)'
|
||||
)
|
||||
.option('--multiuser', 'Enable opt-in multi-user mode (named users in ~/.codeman/users.json; env: CODEMAN_MULTIUSER)')
|
||||
.action(async (options) => {
|
||||
// The flag is surfaced to the rest of the process via the env var so
|
||||
// isMultiUserMode() has a single source of truth (see config/multiuser.ts).
|
||||
if (options.multiuser) process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { startWebServer } = await import('./web/server.js');
|
||||
const host = options.host;
|
||||
const port = parseInt(options.port, 10);
|
||||
@@ -626,6 +630,168 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Multi-user Commands ============
|
||||
//
|
||||
// Operate directly on ~/.codeman/users.json (via user-store) with NO running
|
||||
// server, honoring CODEMAN_INSTANCE. This is the headless bootstrap path and the
|
||||
// recovery answer to "locked out: last admin forgot password".
|
||||
|
||||
/** Read a password from stdin without echoing. Falls back to plain read on non-TTY. */
|
||||
function promptHiddenPassword(question: string): Promise<string> {
|
||||
const stdin = process.stdin;
|
||||
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
||||
// Non-interactive: read a single line from stdin.
|
||||
return new Promise((resolve) => {
|
||||
let buf = '';
|
||||
stdin.setEncoding('utf8');
|
||||
stdin.on('data', (d) => (buf += d));
|
||||
stdin.on('end', () => resolve(buf.replace(/\r?\n$/, '')));
|
||||
});
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
process.stdout.write(question);
|
||||
let input = '';
|
||||
stdin.setRawMode(true);
|
||||
stdin.resume();
|
||||
stdin.setEncoding('utf8');
|
||||
const onData = (chunk: string) => {
|
||||
for (const c of chunk) {
|
||||
if (c === '\n' || c === '\r' || c === '\u0004') {
|
||||
stdin.setRawMode!(false);
|
||||
stdin.pause();
|
||||
stdin.removeListener('data', onData);
|
||||
process.stdout.write('\n');
|
||||
resolve(input);
|
||||
return;
|
||||
} else if (c === '\u0003') {
|
||||
process.stdout.write('\n');
|
||||
process.exit(1);
|
||||
} else if (c === '\u007f' || c === '\b') {
|
||||
input = input.slice(0, -1);
|
||||
} else {
|
||||
input += c;
|
||||
}
|
||||
}
|
||||
};
|
||||
stdin.on('data', onData);
|
||||
});
|
||||
}
|
||||
|
||||
function readAllStdin(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let buf = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (d) => (buf += d));
|
||||
process.stdin.on('end', () => resolve(buf.replace(/\r?\n$/, '')));
|
||||
});
|
||||
}
|
||||
|
||||
const usersCmd = program.command('users').description('Manage multi-user accounts (~/.codeman/users.json)');
|
||||
|
||||
usersCmd
|
||||
.command('add <name>')
|
||||
.description('Create a user (prompts for password; use --password-stdin for scripts)')
|
||||
.option('--admin', 'Create as an admin')
|
||||
.option('--password-stdin', 'Read the password from stdin instead of prompting')
|
||||
.action(async (name, options) => {
|
||||
const { createUser, isValidUsername } = await import('./user-store.js');
|
||||
if (!isValidUsername(name)) {
|
||||
console.error(chalk.red('✗ Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])'));
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
let password: string;
|
||||
if (options.passwordStdin) {
|
||||
password = await readAllStdin();
|
||||
} else {
|
||||
password = await promptHiddenPassword('New password: ');
|
||||
const confirm = await promptHiddenPassword('Confirm password: ');
|
||||
if (password !== confirm) {
|
||||
console.error(chalk.red('✗ Passwords do not match'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
console.error(chalk.red('✗ Password must be at least 8 characters'));
|
||||
process.exit(1);
|
||||
}
|
||||
const user = await createUser({ username: name, role: options.admin ? 'admin' : 'user', password });
|
||||
console.log(chalk.green(`✓ Created ${user.role} "${user.username}"`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
usersCmd
|
||||
.command('passwd <name>')
|
||||
.description('Reset a user password')
|
||||
.option('--password-stdin', 'Read the new password from stdin instead of prompting')
|
||||
.action(async (name, options) => {
|
||||
const { setPassword } = await import('./user-store.js');
|
||||
try {
|
||||
let password: string;
|
||||
if (options.passwordStdin) {
|
||||
password = await readAllStdin();
|
||||
} else {
|
||||
password = await promptHiddenPassword('New password: ');
|
||||
const confirm = await promptHiddenPassword('Confirm password: ');
|
||||
if (password !== confirm) {
|
||||
console.error(chalk.red('✗ Passwords do not match'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
await setPassword(name, password, { mustChangePassword: false });
|
||||
console.log(chalk.green(`✓ Password updated for "${name}"`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
usersCmd
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List all users')
|
||||
.action(async () => {
|
||||
const { readUsers } = await import('./user-store.js');
|
||||
const users = await readUsers(true);
|
||||
if (users.length === 0) {
|
||||
console.log(chalk.yellow('No users defined (run: codeman users add <name> --admin)'));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.bold('\nUsers:'));
|
||||
for (const u of users) {
|
||||
const role = u.role === 'admin' ? chalk.magenta('admin') : chalk.cyan('user ');
|
||||
const state = u.disabled ? chalk.red('disabled') : chalk.green('enabled ');
|
||||
const flags = [u.mustChangePassword ? 'must-change-pw' : '', u.canBypassPermissions ? 'can-bypass' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
console.log(` ${role} ${state} ${u.username}${flags ? chalk.gray(` [${flags}]`) : ''}`);
|
||||
}
|
||||
console.log('');
|
||||
});
|
||||
|
||||
usersCmd
|
||||
.command('rm <name>')
|
||||
.description('Delete a user')
|
||||
.option('--delete-space', "Also delete the user's ~/codeman-users/<name> space")
|
||||
.action(async (name, options) => {
|
||||
const { deleteUser, deleteUserSpace } = await import('./user-store.js');
|
||||
try {
|
||||
await deleteUser(name);
|
||||
if (options.deleteSpace) {
|
||||
await deleteUserSpace(name);
|
||||
console.log(chalk.green(`✓ Deleted user "${name}" and their space`));
|
||||
} else {
|
||||
console.log(chalk.green(`✓ Deleted user "${name}" (space left on disk)`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('doctor')
|
||||
.alias('check-deps')
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
* it easy to tune memory usage.
|
||||
*
|
||||
* Memory Budget Rationale (for 20 concurrent sessions):
|
||||
* - Terminal buffer: 2MB max × 20 = 40MB worst case
|
||||
* - Terminal buffer: 32MB max × 20 = 640MB worst case
|
||||
* - Text output: 1MB max × 20 = 20MB worst case
|
||||
* - Messages: ~1KB each × 1000 × 20 = 20MB worst case
|
||||
* - Total buffer overhead: ~80MB (acceptable for long-running server)
|
||||
*
|
||||
* @module config/buffer-limits
|
||||
*/
|
||||
|
||||
import { DEFAULT_TERMINAL_BUFFER_MAX_BYTES, DEFAULT_TERMINAL_BUFFER_TRIM_BYTES } from './terminal-history.js';
|
||||
|
||||
// ============================================================================
|
||||
// Terminal Buffer Limits
|
||||
// ============================================================================
|
||||
@@ -21,17 +22,17 @@
|
||||
/**
|
||||
* Maximum terminal buffer size in characters.
|
||||
* Contains raw terminal output with ANSI escape sequences.
|
||||
* Reduced from 5MB to 2MB for better render performance.
|
||||
* Sourced from terminal-history config (env/settings overridable).
|
||||
* Override: CODEMAN_MAX_TERMINAL_BUFFER (bytes)
|
||||
*/
|
||||
export const MAX_TERMINAL_BUFFER_SIZE = parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '') || 2 * 1024 * 1024;
|
||||
export const MAX_TERMINAL_BUFFER_SIZE = DEFAULT_TERMINAL_BUFFER_MAX_BYTES;
|
||||
|
||||
/**
|
||||
* Size to trim terminal buffer to when max is exceeded.
|
||||
* Keeps the most recent portion to preserve context.
|
||||
* Override: CODEMAN_TRIM_TERMINAL_TO (bytes)
|
||||
*/
|
||||
export const TRIM_TERMINAL_TO = parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '') || 1.5 * 1024 * 1024;
|
||||
export const TRIM_TERMINAL_TO = DEFAULT_TERMINAL_BUFFER_TRIM_BYTES;
|
||||
|
||||
// ============================================================================
|
||||
// Text Output Buffer Limits
|
||||
@@ -96,3 +97,18 @@ export const TRIM_RESPAWN_BUFFER_TO = 512 * 1024; // 512KB
|
||||
* which is enough to extract metadata from the first few JSONL lines.
|
||||
*/
|
||||
export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
|
||||
|
||||
// ============================================================================
|
||||
// Paste-Image Upload Limits
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Maximum size (bytes) of a single image uploaded via POST
|
||||
* /api/sessions/:id/paste-image. The mobile picker / drag-drop / paste paths
|
||||
* send one file per request (the client uploads up to MAX_PASTE_IMAGES of them
|
||||
* per batch), so this caps each individual file, not the batch. Generous enough
|
||||
* for full-resolution phone photos and large screenshots; the client downscales
|
||||
* very large images before upload, so legitimate uploads land well under this.
|
||||
* Override: CODEMAN_MAX_PASTE_IMAGE_BYTES (bytes)
|
||||
*/
|
||||
export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAGE_BYTES || '') || 50 * 1024 * 1024; // 50MB
|
||||
|
||||
@@ -90,6 +90,14 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [
|
||||
usedBy: ['Codex sessions'],
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['codex'], versionArg: '--version' } }],
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
label: 'Gemini CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['Gemini sessions'],
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['gemini'], versionArg: '--version' } }],
|
||||
},
|
||||
{
|
||||
id: 'libreoffice',
|
||||
label: 'LibreOffice',
|
||||
|
||||
@@ -43,6 +43,19 @@ export const MAX_SSE_CLIENTS = 100;
|
||||
*/
|
||||
export const MAX_TODOS_PER_SESSION = 500;
|
||||
|
||||
/**
|
||||
* Maximum cron-job run-history records retained across all jobs. Oldest runs
|
||||
* (by startedAt) are pruned when exceeded — bounds state.json growth from
|
||||
* frequently-firing or perpetually-skipped jobs.
|
||||
*/
|
||||
export const MAX_CRON_RUN_HISTORY = 500;
|
||||
|
||||
/**
|
||||
* Maximum saved cron jobs. Jobs persist to state.json, so an unbounded count
|
||||
* would grow it without limit; creation past the cap is rejected with 400.
|
||||
*/
|
||||
export const MAX_CRON_JOBS = 100;
|
||||
|
||||
// ============================================================================
|
||||
// Pending Tool Calls Limits
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @fileoverview Multi-user mode gating + limits (opt-in, off by default).
|
||||
*
|
||||
* Multi-user mode is enabled by `codeman web --multiuser` (which sets
|
||||
* `CODEMAN_MULTIUSER=1`) or the env var directly. When OFF, behavior is
|
||||
* byte-identical to today: `users.json` is never read and all ownership scoping
|
||||
* is bypassed. Everything here is per-instance like the rest of Codeman: a beta
|
||||
* instance (`CODEMAN_INSTANCE=beta`) has its own `users.json` via `dataPath()`,
|
||||
* and its user spaces live under the same shared `~/codeman-users` as prod (like
|
||||
* `~/codeman-cases`), unless `CODEMAN_USER_SPACES_DIR` overrides it.
|
||||
*
|
||||
* See `docs/multi-user-plan.md` sections 3, 4.2, and 11.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { MAX_CONCURRENT_SESSIONS } from './map-limits.js';
|
||||
|
||||
/**
|
||||
* Whether multi-user mode is active. Read from the environment each call so it is
|
||||
* stable for the process lifetime (env does not change after boot) and trivially
|
||||
* overridable in tests. Accepts `1` or `true`.
|
||||
*/
|
||||
export function isMultiUserMode(): boolean {
|
||||
const v = process.env.CODEMAN_MULTIUSER;
|
||||
return v === '1' || v === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Root of per-user spaces: `~/codeman-users` (sibling of `~/codeman-cases`).
|
||||
* Overridable via `CODEMAN_USER_SPACES_DIR` (used by tests). Resolved lazily so a
|
||||
* test can point it at a temp dir before the first call.
|
||||
*/
|
||||
export function getUserSpacesDir(): string {
|
||||
return process.env.CODEMAN_USER_SPACES_DIR || join(homedir(), 'codeman-users');
|
||||
}
|
||||
|
||||
/** Absolute path to a user's top-level space: `<USER_SPACES_DIR>/<username>[/segments]`. */
|
||||
export function userSpacePath(username: string, ...segments: string[]): string {
|
||||
return join(getUserSpacesDir(), username, ...segments);
|
||||
}
|
||||
|
||||
/** Absolute path to a user's cases dir: `<USER_SPACES_DIR>/<username>/cases`. */
|
||||
export function userCasesDir(username: string): string {
|
||||
return join(getUserSpacesDir(), username, 'cases');
|
||||
}
|
||||
|
||||
/** Maximum number of user accounts (default 25, env `CODEMAN_MAX_USERS`). */
|
||||
export function maxUsers(): number {
|
||||
const n = Number(process.env.CODEMAN_MAX_USERS);
|
||||
return Number.isInteger(n) && n > 0 ? n : 25;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user concurrent-session cap (the fairness lever). Defaults to half the
|
||||
* global cap; overridable via `CODEMAN_MAX_SESSIONS_PER_USER`. The global cap
|
||||
* (MAX_CONCURRENT_SESSIONS) still applies on top and is shared across users.
|
||||
*/
|
||||
export function maxSessionsPerUser(): number {
|
||||
const n = Number(process.env.CODEMAN_MAX_SESSIONS_PER_USER);
|
||||
if (Number.isInteger(n) && n > 0) return n;
|
||||
return Math.max(1, Math.floor(MAX_CONCURRENT_SESSIONS / 2));
|
||||
}
|
||||
@@ -51,6 +51,19 @@ export const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000;
|
||||
/** Completed scheduled run max age before cleanup (ms) */
|
||||
export const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000;
|
||||
|
||||
// ============================================================================
|
||||
// Cron Jobs
|
||||
// ============================================================================
|
||||
|
||||
/** How often the cron loop wakes to check for due jobs (ms). */
|
||||
export const CRON_TICK_INTERVAL = 30 * 1000;
|
||||
|
||||
/** Max attempts (× 500ms) to poll a launched session for CLI readiness before sending the prompt. */
|
||||
export const CRON_READY_MAX_ATTEMPTS = 60;
|
||||
|
||||
/** Extra settle delay after CLI readiness is detected, before sending the prompt (ms). */
|
||||
export const CRON_READY_SETTLE_MS = 2000;
|
||||
|
||||
/** Session limit retry wait before retrying (ms) */
|
||||
export const SESSION_LIMIT_WAIT_MS = 5000;
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Defaults, bounds, and resolution for terminal history retention.
|
||||
*
|
||||
* Raised defaults (the ones actually wired):
|
||||
* - tmux history-limit: 50,000 -> 100,000 lines (applied at session spawn)
|
||||
* - server PTY buffer cap: 2MB max / 1.5MB trim -> 32MB / 24MB (via buffer-limits.ts)
|
||||
* Browser xterm scrollback is a separate hardcoded DEFAULT_SCROLLBACK (50,000) in
|
||||
* src/web/public/constants.js and deliberately stays at 50k — 100k xterm lines per tab
|
||||
* is a mobile-memory hazard — so DEFAULT_TERMINAL_SCROLLBACK_LINES stays 50,000 to match.
|
||||
* The terminalScrollbackLines/terminalBufferMaxBytes/terminalBufferTrimBytes settings keys
|
||||
* remain schema-validated but inert (a follow-up wires them); only tmuxHistoryLimit is live.
|
||||
* All values remain env- and settings-overridable and bounds-clamped via
|
||||
* resolveTerminalHistoryConfig().
|
||||
*/
|
||||
|
||||
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 50_000;
|
||||
export const DEFAULT_TMUX_HISTORY_LIMIT = 100_000;
|
||||
export const DEFAULT_TERMINAL_BUFFER_MAX_BYTES =
|
||||
parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '', 10) || 32 * 1024 * 1024;
|
||||
// Trim must stay below the max: BufferAccumulator.trim() keeps the last trimSize chars, so a
|
||||
// trim >= max never shrinks the buffer — every append then re-joins the whole string (O(n²))
|
||||
// and memory overshoots the operator's cap (e.g. CODEMAN_MAX_TERMINAL_BUFFER=2097152 with no
|
||||
// trim env would leave the 24MB trim default in force). Clamp to 75% of the resolved max,
|
||||
// preserving the 24MB/32MB default ratio as trim hysteresis.
|
||||
export const DEFAULT_TERMINAL_BUFFER_TRIM_BYTES = Math.min(
|
||||
parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '', 10) || 24 * 1024 * 1024,
|
||||
Math.floor(DEFAULT_TERMINAL_BUFFER_MAX_BYTES * 0.75)
|
||||
);
|
||||
|
||||
export const MIN_TERMINAL_SCROLLBACK_LINES = 1_000;
|
||||
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
|
||||
export const MIN_TERMINAL_BUFFER_BYTES = 1024 * 1024;
|
||||
export const MAX_TERMINAL_BUFFER_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
export interface TerminalHistoryConfig {
|
||||
terminalScrollbackLines: number;
|
||||
tmuxHistoryLimit: number;
|
||||
terminalBufferMaxBytes: number;
|
||||
terminalBufferTrimBytes: number;
|
||||
}
|
||||
|
||||
function boundedInt(value: unknown, fallback: number, min: number, max: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
||||
return Math.max(min, Math.min(max, Math.trunc(value)));
|
||||
}
|
||||
|
||||
export function resolveTerminalHistoryConfig(settings: Record<string, unknown> = {}): TerminalHistoryConfig {
|
||||
const terminalBufferMaxBytes = boundedInt(
|
||||
settings.terminalBufferMaxBytes,
|
||||
DEFAULT_TERMINAL_BUFFER_MAX_BYTES,
|
||||
MIN_TERMINAL_BUFFER_BYTES,
|
||||
MAX_TERMINAL_BUFFER_BYTES
|
||||
);
|
||||
const terminalBufferTrimBytes = boundedInt(
|
||||
settings.terminalBufferTrimBytes,
|
||||
Math.min(DEFAULT_TERMINAL_BUFFER_TRIM_BYTES, terminalBufferMaxBytes),
|
||||
MIN_TERMINAL_BUFFER_BYTES,
|
||||
terminalBufferMaxBytes
|
||||
);
|
||||
|
||||
return {
|
||||
terminalScrollbackLines: boundedInt(
|
||||
settings.terminalScrollbackLines,
|
||||
DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES
|
||||
),
|
||||
tmuxHistoryLimit: boundedInt(
|
||||
settings.tmuxHistoryLimit,
|
||||
DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES
|
||||
),
|
||||
terminalBufferMaxBytes,
|
||||
terminalBufferTrimBytes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @fileoverview Input shape for creating/updating a cron job. This is the
|
||||
* user-settable subset of `CronJob` (server-maintained bookkeeping fields
|
||||
* such as nextRunAt / lastStatus are excluded). Produced by the zod schema.
|
||||
*/
|
||||
|
||||
import type { ConcurrencyPolicy, InputMode, PromptMode, ScheduleType } from '../types/cron.js';
|
||||
import type { SessionMode } from '../types/session.js';
|
||||
|
||||
export type { CronJob, CronJobRun, CronJobRunStatus, TriggerType } from '../types/cron.js';
|
||||
|
||||
export interface CronJobInput {
|
||||
name: string;
|
||||
agentType: SessionMode;
|
||||
workingDir: string;
|
||||
launchCommand?: string;
|
||||
promptMode: PromptMode;
|
||||
promptText?: string;
|
||||
promptFilePath?: string;
|
||||
inputMode: InputMode;
|
||||
scheduleType: ScheduleType;
|
||||
runAt?: number;
|
||||
intervalMinutes?: number;
|
||||
dailyTime?: string;
|
||||
weeklyDays?: number[];
|
||||
weeklyTime?: string;
|
||||
enabled: boolean;
|
||||
notes?: string;
|
||||
concurrencyPolicy: ConcurrencyPolicy;
|
||||
/** Default true. Ignored for 'once' schedules. */
|
||||
autoClosePreviousSession?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
/**
|
||||
* @fileoverview Cron service: CRUD for cron jobs, manual Run Now,
|
||||
* the background due-job tick, and run-history recording.
|
||||
*
|
||||
* It does NOT own session/tmux logic — it reuses Codeman's existing session
|
||||
* layer (create → addSession → setupSessionListeners → startInteractive/Shell →
|
||||
* send prompt via writeViaMux/write), mirroring the "quick start" route flow.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { statSync, realpathSync } from 'node:fs';
|
||||
import { Session } from '../session.js';
|
||||
import { SseEvent } from '../web/sse-events.js';
|
||||
import { CronJobSchema } from '../web/schemas.js';
|
||||
import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api.js';
|
||||
import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js';
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js';
|
||||
import { sessionCapacityState, isWorkingDirAllowedForUsername } from '../web/route-helpers.js';
|
||||
import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
|
||||
import {
|
||||
DEFAULT_BLOCKED_TREES,
|
||||
isBlockedAttachmentPath,
|
||||
loadAttachmentGuardConfig,
|
||||
} from '../config/attachment-guard.js';
|
||||
import { validateSessionFilePath } from '../web/route-helpers.js';
|
||||
import { computeNextRunAt, dueKeyFor } from './cron-time.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../web/ports/index.js';
|
||||
import type { CronJob, CronJobRun, CronJobRunStatus, TriggerType } from '../types/cron.js';
|
||||
import type { GeminiConfig } from '../types/session.js';
|
||||
import type { CronJobInput } from './cron-input.js';
|
||||
|
||||
/** The subset of the route context the cron depends on. */
|
||||
export type CronDeps = SessionPort & EventPort & ConfigPort & InfraPort;
|
||||
|
||||
const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Hard ceiling on a prompt-file read (defends against unbounded-read DoS). */
|
||||
const MAX_PROMPT_FILE_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Pseudo-filesystem trees a cron job may never touch, ON TOP of the shared
|
||||
* attachment blocklist. `/proc` in particular defeats the workingDir
|
||||
* confinement trick (`workingDir: '/proc'` + `promptFilePath:
|
||||
* '/proc/self/environ'` would read the SERVER's own environment).
|
||||
*/
|
||||
const CRON_PSEUDO_FS_TREES: readonly string[] = ['/proc', '/sys', '/dev'];
|
||||
|
||||
/** Sync blocklist for the create/update workingDir gate (no settings extras). */
|
||||
const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TREES, ...CRON_PSEUDO_FS_TREES];
|
||||
|
||||
/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */
|
||||
const HAS_NEWLINE = /[\r\n]/;
|
||||
|
||||
/** Order-insensitive equality for the weekly-days arrays. */
|
||||
function sameDays(a: number[] | undefined, b: number[] | undefined): boolean {
|
||||
const x = [...(a ?? [])].sort((p, q) => p - q);
|
||||
const y = [...(b ?? [])].sort((p, q) => p - q);
|
||||
return x.length === y.length && x.every((v, i) => v === y[i]);
|
||||
}
|
||||
|
||||
export class CronService {
|
||||
constructor(private readonly deps: CronDeps) {}
|
||||
|
||||
private get store() {
|
||||
return this.deps.store;
|
||||
}
|
||||
|
||||
// ───────────────────────────── Reads ─────────────────────────────
|
||||
|
||||
listJobs(): CronJob[] {
|
||||
return Object.values(this.store.getCronJobs());
|
||||
}
|
||||
|
||||
getJob(id: string): CronJob | null {
|
||||
return this.store.getCronJob(id);
|
||||
}
|
||||
|
||||
listRuns(jobId?: string): CronJobRun[] {
|
||||
const all = Object.values(this.store.getCronJobRuns());
|
||||
const filtered = jobId ? all.filter((r) => r.cronJobId === jobId) : all;
|
||||
return filtered.sort((a, b) => b.startedAt - a.startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of LIVE sessions of a given agent type (for the multi-session
|
||||
* warning and the skip_if_same_agent_running policy). Sessions whose CLI has
|
||||
* exited (`stopped`/`error` — the tab is still open but nothing is running)
|
||||
* don't count. When `excludeJobId` is given, sessions created by that job's
|
||||
* own runs are also excluded — otherwise a recurring job with the skip
|
||||
* policy would deadlock on its own previous (never-closed) session and fire
|
||||
* exactly once, forever skipping after that.
|
||||
*/
|
||||
countActiveAgents(agentType: string, excludeJobId?: string): number {
|
||||
const ownSessionIds = excludeJobId
|
||||
? new Set(
|
||||
this.listRuns(excludeJobId)
|
||||
.map((r) => r.sessionId)
|
||||
.filter((id): id is string => id !== null)
|
||||
)
|
||||
: null;
|
||||
let n = 0;
|
||||
for (const [id, s] of this.deps.sessions.entries()) {
|
||||
if (s.mode !== agentType) continue;
|
||||
if (s.status === 'stopped' || s.status === 'error') continue;
|
||||
if (ownSessionIds?.has(id)) continue;
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ──────────────────────────── Mutations ───────────────────────────
|
||||
|
||||
createJob(input: CronJobInput, owner?: string): CronJob {
|
||||
if (Object.keys(this.store.getCronJobs()).length >= MAX_CRON_JOBS) {
|
||||
throw this.badRequest(`Maximum number of cron jobs (${MAX_CRON_JOBS}) reached`);
|
||||
}
|
||||
this.assertValidWorkingDir(input.workingDir);
|
||||
const now = Date.now();
|
||||
const job: CronJob = {
|
||||
id: uuidv4(),
|
||||
name: input.name,
|
||||
owner,
|
||||
agentType: input.agentType,
|
||||
workingDir: input.workingDir,
|
||||
launchCommand: input.launchCommand,
|
||||
promptMode: input.promptMode,
|
||||
promptText: input.promptText,
|
||||
promptFilePath: input.promptFilePath,
|
||||
inputMode: input.inputMode,
|
||||
scheduleType: input.scheduleType,
|
||||
runAt: input.runAt,
|
||||
intervalMinutes: input.intervalMinutes,
|
||||
dailyTime: input.dailyTime,
|
||||
weeklyDays: input.weeklyDays,
|
||||
weeklyTime: input.weeklyTime,
|
||||
enabled: input.enabled,
|
||||
notes: input.notes,
|
||||
concurrencyPolicy: input.concurrencyPolicy,
|
||||
autoClosePreviousSession: input.autoClosePreviousSession ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastRunAt: null,
|
||||
nextRunAt: null,
|
||||
lastStatus: null,
|
||||
lastDueKey: null,
|
||||
};
|
||||
job.nextRunAt = job.enabled ? computeNextRunAt(job, now) : null;
|
||||
this.store.setCronJob(job.id, job);
|
||||
this.broadcastListChanged();
|
||||
return job;
|
||||
}
|
||||
|
||||
updateJob(id: string, patch: Partial<CronJobInput>): CronJob | null {
|
||||
const existing = this.getJob(id);
|
||||
if (!existing) return null;
|
||||
const now = Date.now();
|
||||
|
||||
// A completed one-time job is only re-armed when the SCHEDULE actually
|
||||
// CHANGES — otherwise a cosmetic edit would silently resurrect a job that
|
||||
// already fired. We compare VALUES, not field-presence: the edit form
|
||||
// round-trips the full job (incl. unchanged scheduleType/runAt) on every
|
||||
// save, so a presence check would always re-arm. Only a real schedule
|
||||
// change re-arms.
|
||||
const changed = <T>(next: T | undefined, prev: T): boolean => next !== undefined && next !== prev;
|
||||
const scheduleChanged =
|
||||
changed(patch.scheduleType, existing.scheduleType) ||
|
||||
changed(patch.runAt, existing.runAt) ||
|
||||
changed(patch.intervalMinutes, existing.intervalMinutes) ||
|
||||
changed(patch.dailyTime, existing.dailyTime) ||
|
||||
changed(patch.weeklyTime, existing.weeklyTime) ||
|
||||
(patch.weeklyDays !== undefined && !sameDays(patch.weeklyDays, existing.weeklyDays));
|
||||
const reArm = existing.scheduleType !== 'once' || !existing.completedOnce || scheduleChanged;
|
||||
|
||||
const updated: CronJob = {
|
||||
...existing,
|
||||
...patch,
|
||||
id: existing.id,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: now,
|
||||
completedOnce: reArm ? false : existing.completedOnce,
|
||||
lastDueKey: null,
|
||||
};
|
||||
|
||||
// The PUT schema is `.partial()`, so its cross-field rules don't run on a
|
||||
// partial body. Re-validate the MERGED job against the full schema so a
|
||||
// partial edit can't leave an enabled job with an inconsistent schedule
|
||||
// (e.g. switching to `once` without a `runAt` → a dead `nextRunAt:null`).
|
||||
const check = CronJobSchema.safeParse(updated);
|
||||
if (!check.success) {
|
||||
throw this.badRequest(check.error.issues[0]?.message ?? 'Invalid cron job update');
|
||||
}
|
||||
if (patch.workingDir !== undefined) this.assertValidWorkingDir(patch.workingDir);
|
||||
|
||||
updated.nextRunAt = updated.enabled ? computeNextRunAt(updated, now) : null;
|
||||
this.store.setCronJob(updated.id, updated);
|
||||
this.broadcastListChanged();
|
||||
return updated;
|
||||
}
|
||||
|
||||
setEnabled(id: string, enabled: boolean): CronJob | null {
|
||||
const existing = this.getJob(id);
|
||||
if (!existing) return null;
|
||||
const now = Date.now();
|
||||
existing.enabled = enabled;
|
||||
existing.updatedAt = now;
|
||||
existing.nextRunAt = enabled ? computeNextRunAt(existing, now) : null;
|
||||
this.store.setCronJob(existing.id, existing);
|
||||
this.broadcastListChanged();
|
||||
return existing;
|
||||
}
|
||||
|
||||
deleteJob(id: string): boolean {
|
||||
if (!this.getJob(id)) return false;
|
||||
this.store.removeCronJob(id);
|
||||
for (const run of this.listRuns(id)) this.store.removeCronJobRun(run.id);
|
||||
this.deps.broadcast(SseEvent.CronJobDeleted, { id });
|
||||
this.broadcastListChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ──────────────────────────── Execution ───────────────────────────
|
||||
|
||||
/** Manual Run Now — always launches regardless of schedule/enabled state. */
|
||||
async runNow(id: string): Promise<CronJobRun | null> {
|
||||
const job = this.getJob(id);
|
||||
if (!job) return null;
|
||||
return this.launch(job, 'manual_run_now');
|
||||
}
|
||||
|
||||
/**
|
||||
* Background tick: launch every enabled job whose next run is due. Advances
|
||||
* each job's schedule and guards against double-launching the same due time.
|
||||
*/
|
||||
async tickDueJobs(now: number = Date.now()): Promise<void> {
|
||||
for (const job of this.listJobs()) {
|
||||
if (!job.enabled || job.nextRunAt == null || job.nextRunAt > now) continue;
|
||||
|
||||
const key = dueKeyFor(job.id, job.nextRunAt);
|
||||
if (job.lastDueKey === key) {
|
||||
// This due time was already consumed (overlap/restart) — just advance.
|
||||
this.advanceAfterFire(job, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Optional concurrency policy for AUTOMATIC runs. Only LIVE sessions
|
||||
// block, and this job's own previous sessions never do (see
|
||||
// countActiveAgents) — otherwise a recurring job would deadlock on the
|
||||
// session it created last time.
|
||||
if (job.concurrencyPolicy === 'skip_if_same_agent_running' && this.countActiveAgents(job.agentType, job.id) > 0) {
|
||||
// Record the skip so the job's run history isn't silently empty when it
|
||||
// keeps getting skipped (otherwise it looks like the job never ran).
|
||||
this.recordSkippedRun(job);
|
||||
if (job.scheduleType === 'once') {
|
||||
// A skipped one-time job is NOT consumed: leave nextRunAt armed (and
|
||||
// the due key unconsumed) so the next tick retries once the blocking
|
||||
// session goes away.
|
||||
continue;
|
||||
}
|
||||
job.lastDueKey = key;
|
||||
this.advanceAfterFire(job, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
job.lastDueKey = key;
|
||||
// Advance the schedule BEFORE launching so a slow launch can't be
|
||||
// re-triggered by the next tick.
|
||||
this.advanceAfterFire(job, now);
|
||||
this.launch(job, 'scheduled').catch((err) =>
|
||||
console.error(`[cron] launch failed for job ${job.id}:`, getErrorMessage(err))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recompute nextRunAt for loaded jobs on boot (e.g. after a restart). */
|
||||
init(): void {
|
||||
const now = Date.now();
|
||||
for (const job of this.listJobs()) {
|
||||
const isDeadOnce = job.scheduleType === 'once' && job.completedOnce;
|
||||
if (job.enabled && job.nextRunAt == null && !isDeadOnce) {
|
||||
job.nextRunAt = computeNextRunAt(job, now);
|
||||
this.store.setCronJob(job.id, job);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────── Internals ───────────────────────────
|
||||
|
||||
private advanceAfterFire(job: CronJob, now: number): void {
|
||||
if (job.scheduleType === 'once') {
|
||||
job.completedOnce = true;
|
||||
job.enabled = false;
|
||||
job.nextRunAt = null;
|
||||
} else {
|
||||
job.nextRunAt = computeNextRunAt(job, now);
|
||||
}
|
||||
job.updatedAt = now;
|
||||
this.store.setCronJob(job.id, job);
|
||||
this.broadcastListChanged();
|
||||
}
|
||||
|
||||
private async launch(job: CronJob, trigger: TriggerType): Promise<CronJobRun> {
|
||||
const run: CronJobRun = {
|
||||
id: uuidv4(),
|
||||
cronJobId: job.id,
|
||||
sessionId: null,
|
||||
sessionName: null,
|
||||
startedAt: Date.now(),
|
||||
finishedAt: null,
|
||||
status: 'created',
|
||||
triggerType: trigger,
|
||||
createdSessionUrl: null,
|
||||
};
|
||||
this.store.setCronJobRun(run.id, run);
|
||||
this.pruneRunHistory();
|
||||
this.deps.broadcast(SseEvent.CronRunCreated, run);
|
||||
|
||||
// Resolve the prompt.
|
||||
let prompt: string;
|
||||
try {
|
||||
prompt = await this.resolvePrompt(job);
|
||||
} catch (err) {
|
||||
return this.failRun(job, run, `Prompt error: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
// Validate working directory.
|
||||
try {
|
||||
if (!statSync(job.workingDir).isDirectory()) {
|
||||
return this.failRun(job, run, 'workingDir is not a directory');
|
||||
}
|
||||
} catch {
|
||||
return this.failRun(job, run, 'workingDir does not exist');
|
||||
}
|
||||
|
||||
// Section 6.3: defense-in-depth workingDir confinement re-check at FIRE time against the
|
||||
// owner's CURRENT space (complements the create/update gate). No-op in single-user / unset owner.
|
||||
if (!(await isWorkingDirAllowedForUsername(job.owner, job.workingDir))) {
|
||||
return this.failRun(job, run, 'workingDir is outside the owner workspace');
|
||||
}
|
||||
|
||||
// Recurring jobs: close the still-open session created by this job's
|
||||
// previous run before launching the next (default ON, opt-out via
|
||||
// autoClosePreviousSession:false) — otherwise an unattended interval/daily
|
||||
// job accumulates a new tab per fire until the global session cap.
|
||||
if (job.scheduleType !== 'once' && job.autoClosePreviousSession !== false) {
|
||||
await this.closePreviousRunSessions(job, run.id);
|
||||
}
|
||||
|
||||
// Respect the global cap AND the owner's per-user cap (multi-user).
|
||||
const cap = sessionCapacityState(this.deps.sessions, job.owner);
|
||||
if (cap.atGlobalCap) {
|
||||
return this.failRun(job, run, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`);
|
||||
}
|
||||
if (cap.atUserCap) {
|
||||
return this.failRun(job, run, `Owner's per-user session limit reached`);
|
||||
}
|
||||
|
||||
// Section 6.3: re-resolve the owner's grant at FIRE time (it may have been revoked
|
||||
// since create). Gates shell/launchCommand AND clamps the external-CLI bypass below.
|
||||
const ownerGranted = await canUsernameRunPrivilegedCommands(job.owner);
|
||||
if ((job.agentType === 'shell' || job.launchCommand) && !ownerGranted) {
|
||||
return this.failRun(job, run, 'Owner lacks the can-bypass-permissions grant for shell/launchCommand jobs');
|
||||
}
|
||||
|
||||
// Create + start the session (mirrors the quick-start route flow).
|
||||
let session: Session;
|
||||
try {
|
||||
const mode = job.agentType;
|
||||
const globalNice = await this.deps.getGlobalNiceConfig();
|
||||
const modelConfig = await this.deps.getModelConfig();
|
||||
const claudeModeConfig = await this.deps.getClaudeModeConfig();
|
||||
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner);
|
||||
const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined;
|
||||
// Section 6.3: cron carries no per-CLI config, so buildGeminiCommand(undefined)
|
||||
// would default a non-granted owner to `--approval-mode yolo` (classifier-free) —
|
||||
// materialize auto_edit for a non-granted gemini owner, mirroring the route clamp
|
||||
// (#15). Granted/admin/single-user leave it undefined → yolo parity. Codex's absent
|
||||
// config already defaults to the safe sandbox, so no clamp is needed there.
|
||||
const geminiConfig: GeminiConfig | undefined =
|
||||
mode === 'gemini' && !ownerGranted ? { approvalMode: 'auto_edit' } : undefined;
|
||||
session = new Session({
|
||||
workingDir: job.workingDir,
|
||||
mode,
|
||||
name: job.name,
|
||||
mux: this.deps.mux,
|
||||
useMux: true,
|
||||
niceConfig: globalNice,
|
||||
model,
|
||||
claudeMode: effectiveClaudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
geminiConfig,
|
||||
owner: job.owner,
|
||||
});
|
||||
this.deps.addSession(session);
|
||||
this.store.incrementSessionsCreated();
|
||||
this.deps.persistSessionState(session);
|
||||
await this.deps.setupSessionListeners(session);
|
||||
this.deps.broadcast(SseEvent.SessionCreated, this.deps.getSessionStateWithRespawn(session));
|
||||
if (mode === 'shell') {
|
||||
await session.startShell();
|
||||
} else {
|
||||
await session.startInteractive();
|
||||
}
|
||||
this.deps.broadcast(SseEvent.SessionInteractive, { id: session.id, mode });
|
||||
} catch (err) {
|
||||
return this.failRun(job, run, `Session launch failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
|
||||
run.sessionId = session.id;
|
||||
run.sessionName = session.name;
|
||||
run.createdSessionUrl = `/?session=${session.id}`;
|
||||
run.status = 'session_started';
|
||||
this.store.setCronJobRun(run.id, run);
|
||||
this.deps.broadcast(SseEvent.CronRunUpdated, run);
|
||||
this.updateJobLastStatus(job.id, 'session_started');
|
||||
|
||||
// Send the prompt once the CLI is ready (async; does not block the caller).
|
||||
this.sendPromptWhenReady(session.id, prompt, job, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the prompt text and enforces the single-line constraint: prompt
|
||||
* delivery rides writeViaMux/PTY writes where a newline is Enter, so a
|
||||
* multi-line prompt would be silently corrupted (typed mode fuses lines,
|
||||
* paste mode submits the first line and dribbles the rest in as separate
|
||||
* messages). Rather than mangle an unattended agent's instructions, fail the
|
||||
* run with a clear error. A prompt FILE may end with trailing newline(s)
|
||||
* (every editor writes one) — those are stripped before the check.
|
||||
*/
|
||||
private async resolvePrompt(job: CronJob): Promise<string> {
|
||||
if (job.promptMode === 'prompt_file_path') {
|
||||
if (!job.promptFilePath) throw new Error('prompt file path is empty');
|
||||
const safePath = await this.resolveSafePromptPath(job.promptFilePath, job.workingDir);
|
||||
const content = (await readFile(safePath, 'utf-8')).replace(/[\r\n]+$/, '');
|
||||
if (HAS_NEWLINE.test(content)) {
|
||||
throw new Error('prompt file must contain a single line — multi-line prompts are not supported');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
const text = job.promptText ?? '';
|
||||
if (HAS_NEWLINE.test(text)) {
|
||||
// Schema-rejected since this check was added; guards legacy persisted jobs.
|
||||
throw new Error('promptText must be a single line — multi-line prompts are not supported');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards a prompt-file path before it is read. The path is user-supplied via
|
||||
* the API and its contents are injected into an agent session (an exfil sink
|
||||
* over SSE/terminal), so an unconfined read would let a hostile job config
|
||||
* pull arbitrary host files — including the SERVER PROCESS'S OWN secrets via
|
||||
* `/proc/self/environ` — into the session.
|
||||
*
|
||||
* A denylist is the wrong posture for an exfil sink (it kept missing `/proc`,
|
||||
* `/dev`, other users' `~/.ssh`, modern cloud creds…). So the PRIMARY gate is
|
||||
* an allowlist: the prompt file must resolve INSIDE the job's working
|
||||
* directory. A symlink escaping the workspace fails this because we check the
|
||||
* realpath-resolved target. We additionally require a regular file (rejects
|
||||
* directories, FIFOs, and `/dev/*` character devices that would hang or OOM
|
||||
* the unbounded read) within a sane size cap, and keep the shared blocklist as
|
||||
* cheap defense-in-depth. Returns the symlink-resolved path to read.
|
||||
*/
|
||||
private async resolveSafePromptPath(rawPath: string, workingDir: string): Promise<string> {
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = realpathSync(rawPath);
|
||||
} catch {
|
||||
throw new Error('prompt file path could not be resolved');
|
||||
}
|
||||
|
||||
// workingDir is USER-CONTROLLED, so it is not a trust boundary by itself:
|
||||
// realpath-resolve it (a symlinked workspace must not defeat containment)
|
||||
// and reject blocked/pseudo-fs trees — otherwise workingDir '/proc' would
|
||||
// make '/proc/self/environ' pass the containment check below.
|
||||
let realWorkingDir: string;
|
||||
try {
|
||||
realWorkingDir = realpathSync(workingDir);
|
||||
} catch {
|
||||
throw new Error('job working directory could not be resolved');
|
||||
}
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
const blockedTrees = [...guard.blockedTrees, ...CRON_PSEUDO_FS_TREES];
|
||||
if (realWorkingDir === '/' || isBlockedAttachmentPath(realWorkingDir, blockedTrees)) {
|
||||
throw new Error('job working directory is blocked');
|
||||
}
|
||||
|
||||
// Defense-in-depth blocklist (secret locations, /etc, /root, pseudo-fs).
|
||||
if (isBlockedAttachmentPath(resolved, blockedTrees)) {
|
||||
throw new Error('prompt file path is blocked');
|
||||
}
|
||||
|
||||
// Primary gate: the prompt file must live inside the job's workspace.
|
||||
if (!validateSessionFilePath(realWorkingDir, resolved)) {
|
||||
throw new Error('prompt file path must be inside the job working directory');
|
||||
}
|
||||
|
||||
// Reject non-regular files and oversized files (DoS via unbounded read).
|
||||
let info;
|
||||
try {
|
||||
info = statSync(resolved);
|
||||
} catch {
|
||||
throw new Error('prompt file path could not be resolved');
|
||||
}
|
||||
if (!info.isFile()) throw new Error('prompt file path is not a regular file');
|
||||
if (info.size > MAX_PROMPT_FILE_BYTES) throw new Error('prompt file is too large');
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private sendPromptWhenReady(sessionId: string, prompt: string, job: CronJob, run: CronJobRun): void {
|
||||
setImmediate(() => {
|
||||
const poll = async (): Promise<void> => {
|
||||
if (job.agentType !== 'shell') {
|
||||
for (let attempt = 0; attempt < CRON_READY_MAX_ATTEMPTS; attempt++) {
|
||||
await delay(500);
|
||||
const s = this.deps.sessions.get(sessionId);
|
||||
if (!s) return; // session was removed
|
||||
const buf = s.getTerminalBuffer().slice(-2048);
|
||||
if (buf.includes('❯') || buf.includes('tokens')) break;
|
||||
}
|
||||
await delay(CRON_READY_SETTLE_MS);
|
||||
} else {
|
||||
await delay(1000);
|
||||
// Shell mode: deliver the optional custom launch command as the
|
||||
// first input line (single-line, schema-enforced), then give it a
|
||||
// moment to start before the prompt follows.
|
||||
if (job.launchCommand) {
|
||||
const shell = this.deps.sessions.get(sessionId);
|
||||
if (!shell) return;
|
||||
const sent = await shell.writeViaMux(`${job.launchCommand}\r`);
|
||||
if (!sent) {
|
||||
this.failRun(job, run, 'Failed to send launch command: mux write failed');
|
||||
return;
|
||||
}
|
||||
await delay(1000);
|
||||
}
|
||||
}
|
||||
const s = this.deps.sessions.get(sessionId);
|
||||
if (!s) return;
|
||||
try {
|
||||
const payload = prompt.endsWith('\r') ? prompt : `${prompt}\r`;
|
||||
let delivered = true;
|
||||
if (job.inputMode === 'paste') {
|
||||
s.write(payload);
|
||||
} else {
|
||||
delivered = await s.writeViaMux(payload);
|
||||
}
|
||||
if (!delivered) {
|
||||
this.failRun(job, run, 'Failed to send prompt: mux write failed');
|
||||
return;
|
||||
}
|
||||
run.status = 'prompt_sent';
|
||||
run.finishedAt = Date.now();
|
||||
this.store.setCronJobRun(run.id, run);
|
||||
this.deps.broadcast(SseEvent.CronRunUpdated, run);
|
||||
this.updateJobLastStatus(job.id, 'prompt_sent');
|
||||
} catch (err) {
|
||||
this.failRun(job, run, `Failed to send prompt: ${getErrorMessage(err)}`);
|
||||
}
|
||||
};
|
||||
poll().catch((err) => console.error('[cron] sendPromptWhenReady error:', getErrorMessage(err)));
|
||||
});
|
||||
}
|
||||
|
||||
/** 400-shaped error for route handlers (mirrors parseBody's error contract). */
|
||||
private badRequest(msg: string): Error {
|
||||
return Object.assign(new Error(msg), {
|
||||
statusCode: 400,
|
||||
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, msg),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/update gate for a job's workingDir: must exist, be a directory, and
|
||||
* not resolve into a blocked or pseudo-filesystem tree (nor the fs root).
|
||||
* The user-supplied workingDir doubles as the prompt-file confinement root,
|
||||
* so an unrestricted value would defeat that boundary (e.g. '/proc').
|
||||
*/
|
||||
private assertValidWorkingDir(workingDir: string): void {
|
||||
let real: string;
|
||||
try {
|
||||
real = realpathSync(workingDir);
|
||||
} catch {
|
||||
throw this.badRequest('workingDir does not exist');
|
||||
}
|
||||
if (!statSync(real).isDirectory()) throw this.badRequest('workingDir is not a directory');
|
||||
if (real === '/' || isBlockedAttachmentPath(real, CRON_WORKING_DIR_BLOCKED_TREES)) {
|
||||
throw this.badRequest('workingDir is not allowed (blocked or pseudo-filesystem tree)');
|
||||
}
|
||||
}
|
||||
|
||||
/** Close still-open sessions created by this job's previous runs (normal cleanup path). */
|
||||
private async closePreviousRunSessions(job: CronJob, currentRunId: string): Promise<void> {
|
||||
for (const prev of this.listRuns(job.id)) {
|
||||
if (prev.id === currentRunId || !prev.sessionId) continue;
|
||||
if (!this.deps.sessions.has(prev.sessionId)) continue;
|
||||
try {
|
||||
await this.deps.cleanupSession(prev.sessionId, true, 'cron: superseded by the next run of this job');
|
||||
} catch (err) {
|
||||
console.error(`[cron] failed to auto-close previous session ${prev.sessionId}:`, getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private failRun(job: CronJob, run: CronJobRun, message: string): CronJobRun {
|
||||
run.status = 'failed';
|
||||
run.errorMessage = message;
|
||||
run.finishedAt = Date.now();
|
||||
this.store.setCronJobRun(run.id, run);
|
||||
this.deps.broadcast(SseEvent.CronRunUpdated, run);
|
||||
this.updateJobLastStatus(job.id, 'failed');
|
||||
return run;
|
||||
}
|
||||
|
||||
private recordSkippedRun(job: CronJob): void {
|
||||
// Coalesce consecutive skips: if the job is already in a skip streak, don't
|
||||
// record again — a perpetually-skipped interval job would otherwise write a
|
||||
// run every tick forever and bloat state.json.
|
||||
if (this.listRuns(job.id)[0]?.status === 'skipped') return;
|
||||
|
||||
const now = Date.now();
|
||||
const run: CronJobRun = {
|
||||
id: uuidv4(),
|
||||
cronJobId: job.id,
|
||||
sessionId: null,
|
||||
sessionName: null,
|
||||
startedAt: now,
|
||||
finishedAt: now,
|
||||
status: 'skipped',
|
||||
errorMessage: `Skipped: a ${job.agentType} agent is already running (concurrency policy)`,
|
||||
triggerType: 'scheduled',
|
||||
createdSessionUrl: null,
|
||||
};
|
||||
this.store.setCronJobRun(run.id, run);
|
||||
this.pruneRunHistory();
|
||||
this.deps.broadcast(SseEvent.CronRunCreated, run);
|
||||
// A skip is NOT a run: surface it as the lastStatus, but do NOT advance
|
||||
// lastRunAt (no session was created).
|
||||
this.updateJobLastStatus(job.id, 'skipped', { touchLastRun: false });
|
||||
}
|
||||
|
||||
/** Prune the oldest run records (by startedAt) once the global cap is exceeded. */
|
||||
private pruneRunHistory(): void {
|
||||
const runs = Object.values(this.store.getCronJobRuns());
|
||||
if (runs.length <= MAX_CRON_RUN_HISTORY) return;
|
||||
runs.sort((a, b) => a.startedAt - b.startedAt);
|
||||
for (const run of runs.slice(0, runs.length - MAX_CRON_RUN_HISTORY)) {
|
||||
this.store.removeCronJobRun(run.id);
|
||||
}
|
||||
}
|
||||
|
||||
private updateJobLastStatus(jobId: string, status: CronJobRunStatus, opts: { touchLastRun?: boolean } = {}): void {
|
||||
const fresh = this.store.getCronJob(jobId);
|
||||
if (!fresh) return;
|
||||
const now = Date.now();
|
||||
fresh.lastStatus = status;
|
||||
if (opts.touchLastRun !== false) fresh.lastRunAt = now;
|
||||
fresh.updatedAt = now;
|
||||
this.store.setCronJob(fresh.id, fresh);
|
||||
this.broadcastListChanged();
|
||||
}
|
||||
|
||||
private broadcastListChanged(): void {
|
||||
this.deps.broadcast(SseEvent.CronJobsChanged, { jobs: this.listJobs() });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @fileoverview Pure next-run-time calculations for the cron.
|
||||
*
|
||||
* All functions are pure and take an explicit `after` timestamp (epoch ms) so
|
||||
* they are deterministic and unit-testable. Times use the SERVER'S LOCAL
|
||||
* timezone for v0.1 (per the build brief) — daily/weekly wall-clock times are
|
||||
* interpreted via the host's local time.
|
||||
*/
|
||||
|
||||
import type { CronJob } from '../types/cron.js';
|
||||
|
||||
/** Parse an 'HH:MM' (24-hour) string into hours/minutes, or null if invalid. */
|
||||
export function parseHHMM(value: string | undefined): { hours: number; minutes: number } | null {
|
||||
if (!value) return null;
|
||||
const m = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
|
||||
if (!m) return null;
|
||||
const hours = Number(m[1]);
|
||||
const minutes = Number(m[2]);
|
||||
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
|
||||
return { hours, minutes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the epoch-ms timestamp for `hours:minutes` (local time) on the day of
|
||||
* `base`, shifted by `dayOffset` days.
|
||||
*/
|
||||
function atLocalTime(base: number, hours: number, minutes: number, dayOffset: number): number {
|
||||
const d = new Date(base);
|
||||
d.setHours(hours, minutes, 0, 0);
|
||||
d.setDate(d.getDate() + dayOffset);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next fire time strictly relevant to `after`, or null if the job
|
||||
* has no future run (e.g. a completed one-time job, or invalid config).
|
||||
*
|
||||
* For `once`, returns the absolute `runAt` (even if already in the past, so a
|
||||
* missed one-time job still fires once) until it has `completedOnce`.
|
||||
*/
|
||||
export function computeNextRunAt(job: CronJob, after: number): number | null {
|
||||
switch (job.scheduleType) {
|
||||
case 'once': {
|
||||
if (job.completedOnce) return null;
|
||||
return typeof job.runAt === 'number' ? job.runAt : null;
|
||||
}
|
||||
case 'interval': {
|
||||
const minutes = job.intervalMinutes;
|
||||
if (!minutes || minutes <= 0) return null;
|
||||
return after + minutes * 60_000;
|
||||
}
|
||||
case 'daily': {
|
||||
const t = parseHHMM(job.dailyTime);
|
||||
if (!t) return null;
|
||||
let next = atLocalTime(after, t.hours, t.minutes, 0);
|
||||
if (next <= after) next = atLocalTime(after, t.hours, t.minutes, 1);
|
||||
return next;
|
||||
}
|
||||
case 'weekly': {
|
||||
const t = parseHHMM(job.weeklyTime);
|
||||
if (!t) return null;
|
||||
const days = (job.weeklyDays ?? []).filter((d) => d >= 0 && d <= 6);
|
||||
if (days.length === 0) return null;
|
||||
for (let offset = 0; offset <= 7; offset++) {
|
||||
const cand = atLocalTime(after, t.hours, t.minutes, offset);
|
||||
if (cand > after && days.includes(new Date(cand).getDay())) return cand;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate-launch guard key: identifies a specific due time for a job. The
|
||||
* cron records the key it last consumed so an overlapping or restarted
|
||||
* loop will not launch the same due time twice.
|
||||
*/
|
||||
export function dueKeyFor(jobId: string, fireTime: number): string {
|
||||
return `${jobId}:${fireTime}`;
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* @fileoverview Docker case export / import: move a container (toolchain + any
|
||||
* in-image changes) PLUS its workspace to another machine as one portable
|
||||
* `.codeman-container.tgz`, and restore it.
|
||||
*
|
||||
* A full-image export = `docker commit` the running container to an image ->
|
||||
* `docker save` that image -> tar the bind-mounted workspace -> a manifest, all
|
||||
* bundled into one gzip tarball. A workspace-only export skips the image (fast,
|
||||
* files-only). Import validates the manifest + per-member checksums, extracts the
|
||||
* workspace with a path-traversal guard, `docker load`s the image and RE-TAGS it
|
||||
* into a quarantined namespace (never overwriting a local tag), and hands the
|
||||
* caller enough to recreate a hardened case on the destination.
|
||||
*
|
||||
* Safety (all from the design critic): pause the container spanning the workspace
|
||||
* tar AND the commit so the two artifacts are mutually consistent; a free-space
|
||||
* precheck (a full docker graph wedges EVERY session on the host); `docker rmi`
|
||||
* the intermediate image in a finally; sealed containers refuse a full-image
|
||||
* export (an in-container login would ride the committed layer); import rejects
|
||||
* absolute / `..` tar members and checksum mismatches. Bounded by
|
||||
* runWithConversionLimit so N exports cannot fork-bomb the host.
|
||||
*
|
||||
* @module docker-export
|
||||
*/
|
||||
|
||||
import { createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join, basename } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import type { DockerEngine, SessionDocker } from './types.js';
|
||||
import { runWithConversionLimit } from './document-conversion-limiter.js';
|
||||
|
||||
const IS_TEST_MODE = !!process.env.VITEST;
|
||||
|
||||
/** Refuse to export when the target filesystem has less than this free (a full graph wedges the daemon). */
|
||||
export const DOCKER_EXPORT_MIN_FREE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB
|
||||
|
||||
/** Manifest schema version (bump on any breaking field change). */
|
||||
export const DOCKER_EXPORT_SCHEMA = 1;
|
||||
|
||||
export type DockerExportMode = 'full' | 'workspace';
|
||||
|
||||
export interface DockerExportManifest {
|
||||
schemaVersion: number;
|
||||
caseName: string;
|
||||
mode: DockerExportMode;
|
||||
engine: DockerEngine;
|
||||
image: string;
|
||||
containerWorkdir: string;
|
||||
network: string;
|
||||
createdAt: number;
|
||||
codemanVersion: string;
|
||||
mountCredentials: boolean;
|
||||
/** True when the bundle provably carries no credentials (convenient-mode workspace, or a full image whose creds were bind-mounted and thus never committed). */
|
||||
secretFree: boolean;
|
||||
/** sha256 of each bundle member that is present. */
|
||||
checksums: { image?: string; workspace?: string };
|
||||
}
|
||||
|
||||
// ========== Pure helpers (unit-tested) ==========
|
||||
|
||||
/** Raw argv prefix for the engine (NO shell escaping — used with spawn). */
|
||||
export function dockerArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
|
||||
const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
|
||||
if (docker.context) argv.push('--context', docker.context);
|
||||
if (docker.daemonHost) argv.push('-H', docker.daemonHost);
|
||||
return argv;
|
||||
}
|
||||
|
||||
/** Portable bundle filename for a case export. */
|
||||
export function exportBundleName(caseName: string, timestamp: number, mode: DockerExportMode): string {
|
||||
const suffix = mode === 'workspace' ? 'workspace' : 'container';
|
||||
return `${caseName}-${timestamp}.codeman-${suffix}.tgz`;
|
||||
}
|
||||
|
||||
/** Quarantined image tag for an imported bundle (never overwrites a local tag). */
|
||||
export function importedImageTag(caseName: string, timestamp: number): string {
|
||||
return `codeman/imported-${caseName}:${timestamp}`;
|
||||
}
|
||||
|
||||
/** Intermediate commit tag for a full-image export (unique per export, rmi'd in finally). */
|
||||
export function exportImageTag(caseName: string, timestamp: number): string {
|
||||
return `codeman/export-${caseName}:${timestamp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a tar member path that would escape the extraction root (absolute path
|
||||
* or a `..` component). The import-side traversal guard.
|
||||
*/
|
||||
export function isSafeTarMember(member: string): boolean {
|
||||
const trimmed = member.trim();
|
||||
if (!trimmed || trimmed === './') return true;
|
||||
if (trimmed.startsWith('/')) return false;
|
||||
// Normalize separators and check each component.
|
||||
return !trimmed.split('/').some((part) => part === '..');
|
||||
}
|
||||
|
||||
/** Parse the image id/ref from `docker load` output ("Loaded image: x" / "Loaded image ID: sha256:..."). */
|
||||
export function parseLoadedImageRef(loadOutput: string): string | null {
|
||||
const idMatch = loadOutput.match(/Loaded image ID:\s*(sha256:[0-9a-f]+)/i);
|
||||
if (idMatch) return idMatch[1];
|
||||
const refMatch = loadOutput.match(/Loaded image:\s*(\S+)/i);
|
||||
if (refMatch) return refMatch[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an imported bundle's manifest BEFORE any of its fields are trusted.
|
||||
* A bundle is cross-machine input (potentially authored by someone else), and its
|
||||
* fields flow into stored host/case config that the schema layer never sees:
|
||||
* `engine` becomes the probe/launch binary selector, `image`/`containerWorkdir`
|
||||
* reach the shellescaped launch string, `network` is a create arg. Mirror the
|
||||
* DockerHostSchema/DockerCaseLinkSchema constraints here (throwing, since this is
|
||||
* not a web-layer module). Exported for unit tests.
|
||||
*/
|
||||
export function validateImportManifest(manifest: DockerExportManifest): void {
|
||||
const fail = (msg: string): never => {
|
||||
throw new Error(`invalid bundle manifest: ${msg}`);
|
||||
};
|
||||
if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) {
|
||||
fail(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`);
|
||||
}
|
||||
if (manifest.mode !== 'full' && manifest.mode !== 'workspace') fail(`unknown mode ${String(manifest.mode)}`);
|
||||
if (manifest.engine !== 'docker' && manifest.engine !== 'podman') fail(`unknown engine ${String(manifest.engine)}`);
|
||||
if (typeof manifest.caseName !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(manifest.caseName)) fail('bad caseName');
|
||||
if (
|
||||
typeof manifest.image !== 'string' ||
|
||||
manifest.image.length > 512 ||
|
||||
!/^[a-zA-Z0-9][\w./:@-]*$/.test(manifest.image)
|
||||
) {
|
||||
fail('bad image reference');
|
||||
}
|
||||
if (
|
||||
typeof manifest.containerWorkdir !== 'string' ||
|
||||
manifest.containerWorkdir.length > 2000 ||
|
||||
!manifest.containerWorkdir.startsWith('/') ||
|
||||
// comma: --mount specs are comma-delimited CSV; shell escaping cannot protect it
|
||||
/[`$\\"'\n\r;&|<>,]/.test(manifest.containerWorkdir)
|
||||
) {
|
||||
fail('bad containerWorkdir');
|
||||
}
|
||||
if (!['bridge', 'none', 'custom'].includes(manifest.network)) fail(`unknown network ${String(manifest.network)}`);
|
||||
if (typeof manifest.checksums !== 'object' || manifest.checksums === null) fail('missing checksums');
|
||||
}
|
||||
|
||||
// ========== IO helpers ==========
|
||||
|
||||
function run(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: { timeout?: number } = {}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
if (opts.timeout) {
|
||||
timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error(`${cmd} timed out after ${opts.timeout}ms`));
|
||||
}, opts.timeout);
|
||||
}
|
||||
child.stdout.on('data', (d) => (stdout += d));
|
||||
child.stderr.on('data', (d) => (stderr += d));
|
||||
child.on('error', (err) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (code === 0) resolve({ stdout, stderr });
|
||||
else reject(new Error(`${cmd} ${args.join(' ')} exited ${code}: ${stderr.trim()}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream `docker save <tag>` stdout to a raw tar file (no shell, no double-gzip).
|
||||
* Uses stream `pipeline` so completion means the write stream is FULLY flushed to
|
||||
* disk (a naive child 'close' resolves before the last chunks land, truncating the
|
||||
* file — a real bug caught in end-to-end testing), AND waits for a clean exit code.
|
||||
*/
|
||||
async function saveImageToTar(argv: string[], tag: string, outPath: string): Promise<void> {
|
||||
const child = spawn(argv[0], [...argv.slice(1), 'save', tag], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (d) => (stderr += d));
|
||||
const exited = new Promise<void>((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) =>
|
||||
code === 0 ? resolve() : reject(new Error(`docker save exited ${code}: ${stderr.trim()}`))
|
||||
);
|
||||
});
|
||||
// pipeline resolves only after the destination has fully flushed.
|
||||
await Promise.all([pipeline(child.stdout, createWriteStream(outPath)), exited]);
|
||||
}
|
||||
|
||||
async function sha256File(path: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash('sha256');
|
||||
const stream = createReadStream(path);
|
||||
stream.on('data', (d) => hash.update(d));
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
});
|
||||
}
|
||||
|
||||
async function freeBytes(path: string): Promise<number> {
|
||||
try {
|
||||
const stat = await fs.statfs(path);
|
||||
return Number(stat.bavail) * Number(stat.bsize);
|
||||
} catch {
|
||||
return Number.POSITIVE_INFINITY; // statfs unsupported — don't block
|
||||
}
|
||||
}
|
||||
|
||||
async function isContainerRunning(argv: string[], container: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await run(argv[0], [...argv.slice(1), 'inspect', '-f', '{{.State.Running}}', container], {
|
||||
timeout: 15_000,
|
||||
});
|
||||
return stdout.trim() === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
bundlePath: string;
|
||||
manifest: DockerExportManifest;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a docker case to a portable bundle. Bounded by runWithConversionLimit.
|
||||
* `full` mode commits + saves the image AND tars the workspace; `workspace` mode
|
||||
* tars just the workspace. The container is paused across the artifact capture so
|
||||
* image and workspace are mutually consistent.
|
||||
*/
|
||||
export async function exportDockerCase(params: {
|
||||
docker: SessionDocker;
|
||||
caseName: string;
|
||||
timestamp: number;
|
||||
exportsDir: string;
|
||||
mode: DockerExportMode;
|
||||
codemanVersion: string;
|
||||
}): Promise<ExportResult> {
|
||||
const { docker, caseName, timestamp, exportsDir, mode, codemanVersion } = params;
|
||||
|
||||
if (mode === 'full' && !docker.mountCredentials) {
|
||||
throw new Error(
|
||||
'full-image export is refused for a sealed (mountCredentials:false) container: an in-container login would ride the committed image layer. Use a workspace-only export.'
|
||||
);
|
||||
}
|
||||
|
||||
if (IS_TEST_MODE) {
|
||||
// No real docker/tar under vitest — return a deterministic stub.
|
||||
const manifest: DockerExportManifest = {
|
||||
schemaVersion: DOCKER_EXPORT_SCHEMA,
|
||||
caseName,
|
||||
mode,
|
||||
engine: docker.engine,
|
||||
image: docker.image,
|
||||
containerWorkdir: docker.containerWorkdir,
|
||||
network: docker.network,
|
||||
createdAt: timestamp,
|
||||
codemanVersion,
|
||||
mountCredentials: docker.mountCredentials,
|
||||
secretFree: true,
|
||||
checksums: {},
|
||||
};
|
||||
return { bundlePath: join(exportsDir, exportBundleName(caseName, timestamp, mode)), manifest, sizeBytes: 0 };
|
||||
}
|
||||
|
||||
return runWithConversionLimit(async () => {
|
||||
if (!existsSync(exportsDir)) mkdirSync(exportsDir, { recursive: true });
|
||||
|
||||
const free = await freeBytes(exportsDir);
|
||||
if (free < DOCKER_EXPORT_MIN_FREE_BYTES) {
|
||||
throw new Error(
|
||||
`not enough free space to export (need >= ${Math.round(DOCKER_EXPORT_MIN_FREE_BYTES / 1e9)}GB, have ${Math.round(free / 1e9)}GB). A full docker graph wedges every session on the host.`
|
||||
);
|
||||
}
|
||||
|
||||
const argv = dockerArgv(docker);
|
||||
const bundlePath = join(exportsDir, exportBundleName(caseName, timestamp, mode));
|
||||
const stageDir = join(exportsDir, `.stage-${caseName}-${timestamp}`);
|
||||
mkdirSync(stageDir, { recursive: true });
|
||||
const wasRunning = await isContainerRunning(argv, docker.containerName);
|
||||
let commitTag: string | undefined;
|
||||
|
||||
try {
|
||||
if (wasRunning) {
|
||||
await run(argv[0], [...argv.slice(1), 'pause', docker.containerName], { timeout: 30_000 }).catch(() => {});
|
||||
}
|
||||
|
||||
const checksums: DockerExportManifest['checksums'] = {};
|
||||
|
||||
if (mode === 'full') {
|
||||
commitTag = exportImageTag(caseName, timestamp);
|
||||
// Blank instance-specific committed env so the image carries no stale host refs.
|
||||
await run(
|
||||
argv[0],
|
||||
[
|
||||
...argv.slice(1),
|
||||
'commit',
|
||||
'-c',
|
||||
'ENV CODEMAN_API_URL=',
|
||||
'-c',
|
||||
'ENV CODEMAN_HOOK_SECRET_FILE=',
|
||||
docker.containerName,
|
||||
commitTag,
|
||||
],
|
||||
{ timeout: 300_000 }
|
||||
);
|
||||
const imageTar = join(stageDir, 'image.tar');
|
||||
await saveImageToTar(argv, commitTag, imageTar);
|
||||
checksums.image = await sha256File(imageTar);
|
||||
}
|
||||
|
||||
const workspaceTar = join(stageDir, 'workspace.tar');
|
||||
await run('tar', ['-cf', workspaceTar, '-C', docker.hostWorkspacePath, '.'], { timeout: 300_000 });
|
||||
checksums.workspace = await sha256File(workspaceTar);
|
||||
|
||||
const manifest: DockerExportManifest = {
|
||||
schemaVersion: DOCKER_EXPORT_SCHEMA,
|
||||
caseName,
|
||||
mode,
|
||||
engine: docker.engine,
|
||||
image: docker.image,
|
||||
containerWorkdir: docker.containerWorkdir,
|
||||
network: docker.network,
|
||||
createdAt: timestamp,
|
||||
codemanVersion,
|
||||
mountCredentials: docker.mountCredentials,
|
||||
// Convenient mode keeps creds on bind mounts (never committed), so the bundle is secret-free.
|
||||
secretFree: docker.mountCredentials,
|
||||
checksums,
|
||||
};
|
||||
await fs.writeFile(join(stageDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
|
||||
const members =
|
||||
mode === 'full' ? ['manifest.json', 'image.tar', 'workspace.tar'] : ['manifest.json', 'workspace.tar'];
|
||||
await run('tar', ['-czf', bundlePath, '-C', stageDir, ...members], { timeout: 300_000 });
|
||||
|
||||
const stat = await fs.stat(bundlePath);
|
||||
return { bundlePath, manifest, sizeBytes: stat.size };
|
||||
} finally {
|
||||
// Always remove the intermediate image + stage dir, and unpause.
|
||||
if (commitTag) {
|
||||
await run(argv[0], [...argv.slice(1), 'rmi', commitTag], { timeout: 60_000 }).catch(() => {});
|
||||
}
|
||||
await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {});
|
||||
if (wasRunning) {
|
||||
await run(argv[0], [...argv.slice(1), 'unpause', docker.containerName], { timeout: 30_000 }).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
manifest: DockerExportManifest;
|
||||
/** Quarantined image ref the destination case should use (full mode only). */
|
||||
importedImage?: string;
|
||||
/** Directory the workspace was extracted into. */
|
||||
workspacePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a bundle produced by exportDockerCase: validate the manifest + per-member
|
||||
* checksums, extract the workspace (traversal-guarded) into destWorkspace, and, in
|
||||
* full mode, `docker load` the image and re-tag it into a quarantined namespace.
|
||||
*/
|
||||
export async function importDockerBundle(params: {
|
||||
bundlePath: string;
|
||||
destWorkspace: string;
|
||||
engine: DockerEngine;
|
||||
timestamp: number;
|
||||
/** Schema-validated destination case name; the quarantine tag derives from THIS,
|
||||
* never from the (attacker-authored) manifest.caseName. */
|
||||
newCaseName: string;
|
||||
}): Promise<ImportResult> {
|
||||
const { bundlePath, destWorkspace, engine, timestamp, newCaseName } = params;
|
||||
const argv: string[] = [engine === 'podman' ? 'podman' : 'docker'];
|
||||
|
||||
if (IS_TEST_MODE) {
|
||||
const raw = await fs.readFile(bundlePath, 'utf-8').catch(() => '{}');
|
||||
const manifest = JSON.parse(raw) as DockerExportManifest;
|
||||
validateImportManifest(manifest);
|
||||
return { manifest, workspacePath: destWorkspace };
|
||||
}
|
||||
|
||||
const stageDir = `${destWorkspace}.import-stage-${timestamp}`;
|
||||
mkdirSync(stageDir, { recursive: true });
|
||||
try {
|
||||
// Outer-bundle traversal guard (defense in depth: GNU/bsd tar already refuse
|
||||
// `..`/absolute members by default, but the bundle is cross-machine input).
|
||||
const { stdout: bundleMembers } = await run('tar', ['-tzf', bundlePath], { timeout: 60_000 });
|
||||
for (const member of bundleMembers.split('\n').filter(Boolean)) {
|
||||
if (!isSafeTarMember(member)) throw new Error(`unsafe path in bundle archive: ${member}`);
|
||||
}
|
||||
await run('tar', ['--no-same-owner', '-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 });
|
||||
|
||||
const manifestRaw = await fs.readFile(join(stageDir, 'manifest.json'), 'utf-8');
|
||||
const manifest = JSON.parse(manifestRaw) as DockerExportManifest;
|
||||
validateImportManifest(manifest);
|
||||
|
||||
// Integrity: verify checksums before trusting any member.
|
||||
const workspaceTar = join(stageDir, 'workspace.tar');
|
||||
if (manifest.checksums.workspace) {
|
||||
const actual = await sha256File(workspaceTar);
|
||||
if (actual !== manifest.checksums.workspace)
|
||||
throw new Error('workspace checksum mismatch (corrupt or tampered bundle)');
|
||||
}
|
||||
|
||||
// Traversal guard: reject absolute / `..` members before extraction.
|
||||
const { stdout: memberList } = await run('tar', ['-tf', workspaceTar], { timeout: 60_000 });
|
||||
for (const member of memberList.split('\n').filter(Boolean)) {
|
||||
if (!isSafeTarMember(member)) throw new Error(`unsafe path in workspace archive: ${member}`);
|
||||
}
|
||||
mkdirSync(destWorkspace, { recursive: true });
|
||||
await run('tar', ['--no-same-owner', '-xf', workspaceTar, '-C', destWorkspace], { timeout: 300_000 });
|
||||
|
||||
let importedImage: string | undefined;
|
||||
if (manifest.mode === 'full') {
|
||||
const imageTar = join(stageDir, 'image.tar');
|
||||
if (manifest.checksums.image) {
|
||||
const actual = await sha256File(imageTar);
|
||||
if (actual !== manifest.checksums.image)
|
||||
throw new Error('image checksum mismatch (corrupt or tampered bundle)');
|
||||
}
|
||||
const { stdout } = await run(argv[0], [...argv.slice(1), 'load', '-i', imageTar], { timeout: 300_000 });
|
||||
const loadedRef = parseLoadedImageRef(stdout);
|
||||
if (!loadedRef) throw new Error('could not determine loaded image ref');
|
||||
// Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original
|
||||
// tag; the tag name derives from the caller's schema-validated newCaseName.
|
||||
importedImage = importedImageTag(newCaseName, timestamp);
|
||||
await run(argv[0], [...argv.slice(1), 'tag', loadedRef, importedImage], { timeout: 60_000 });
|
||||
}
|
||||
|
||||
return { manifest, importedImage, workspacePath: destWorkspace };
|
||||
} finally {
|
||||
await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** List export bundles in the exports dir (newest first), with size + mtime. */
|
||||
export async function listDockerExports(
|
||||
exportsDir: string
|
||||
): Promise<Array<{ name: string; sizeBytes: number; mtimeMs: number }>> {
|
||||
if (!existsSync(exportsDir)) return [];
|
||||
const entries = await fs.readdir(exportsDir).catch(() => [] as string[]);
|
||||
const out: Array<{ name: string; sizeBytes: number; mtimeMs: number }> = [];
|
||||
for (const name of entries) {
|
||||
if (!name.endsWith('.tgz')) continue;
|
||||
try {
|
||||
const stat = await fs.stat(join(exportsDir, name));
|
||||
out.push({ name: basename(name), sizeBytes: stat.size, mtimeMs: stat.mtimeMs });
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
}
|
||||
+1055
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,18 @@ import { runWithConversionLimit } from './document-conversion-limiter.js';
|
||||
const execFileAsync = promisify(execFile);
|
||||
const THUMBNAIL_CONVERSION_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
/** Browser-renderable image formats served as-is (no conversion). */
|
||||
const IMAGE_PASSTHROUGH_CONTENT_TYPES: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
};
|
||||
|
||||
export interface ThumbnailResult {
|
||||
content: Buffer;
|
||||
contentType: 'image/png';
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
export async function generateFirstPageThumbnail(filePath: string, extension: string): Promise<ThumbnailResult | null> {
|
||||
@@ -24,8 +33,9 @@ export async function generateFirstPageThumbnail(filePath: string, extension: st
|
||||
try {
|
||||
await fs.stat(filePath);
|
||||
|
||||
if (ext === 'png') {
|
||||
return { content: await fs.readFile(filePath), contentType: 'image/png' };
|
||||
const passthroughContentType = IMAGE_PASSTHROUGH_CONTENT_TYPES[ext];
|
||||
if (passthroughContentType) {
|
||||
return { content: await fs.readFile(filePath), contentType: passthroughContentType };
|
||||
}
|
||||
|
||||
if (ext === 'pdf') {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @fileoverview Codex generated-artifact attachment registration.
|
||||
*
|
||||
* Codex image generation prints paths such as `Saved to: file://...`. These
|
||||
* paths are registered directly when they fall within allowed locations (the
|
||||
* session workspace or the well-known Codex generated-artifact directories
|
||||
* anchored at the user's home). The trust decision is made on the
|
||||
* realpath-RESOLVED path so a symlink staged at an allowed location cannot
|
||||
* smuggle an arbitrary host file past workspace confinement.
|
||||
*/
|
||||
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, normalize, sep } from 'node:path';
|
||||
import { registerExternalAttachment, type AttachmentRegistrationResult } from './attachment-registry.js';
|
||||
|
||||
export interface GeneratedArtifactRegistrationOptions {
|
||||
sessionId: string;
|
||||
filePath: string;
|
||||
sessionWorkingDir: string;
|
||||
}
|
||||
|
||||
export async function registerGeneratedArtifactAttachment(
|
||||
options: GeneratedArtifactRegistrationOptions
|
||||
): Promise<AttachmentRegistrationResult> {
|
||||
// Decide trust on the symlink-resolved path. If it can't be resolved, fall
|
||||
// back to the strict force-confined policy (registration will 404 a missing
|
||||
// file anyway).
|
||||
let forceWorkspaceConfinement = true;
|
||||
try {
|
||||
const resolvedPath = realpathSync(options.filePath);
|
||||
forceWorkspaceConfinement = !isAllowedGeneratedArtifactPath(resolvedPath, options.sessionWorkingDir);
|
||||
} catch {
|
||||
// Keep force confinement.
|
||||
}
|
||||
return registerExternalAttachment(options.sessionId, options.filePath, {
|
||||
sessionWorkingDir: options.sessionWorkingDir,
|
||||
forceWorkspaceConfinement,
|
||||
});
|
||||
}
|
||||
|
||||
/** Well-known Codex generated-artifact directories, anchored at the user's home. */
|
||||
function codexGeneratedDirs(): string[] {
|
||||
const home = homedir();
|
||||
return [
|
||||
join(home, '.codex-personal', 'generated_images'),
|
||||
join(home, '.codex', 'generated_images'),
|
||||
join(home, '.codex-personal', 'generated_artifacts'),
|
||||
join(home, '.codex', 'generated_artifacts'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `filePath` (absolute; callers should pass the realpath-resolved
|
||||
* path) is inside the session workspace or one of the well-known Codex
|
||||
* generated-artifact directories under the current user's home. The marker
|
||||
* directories are prefix-anchored to `os.homedir()` — a `.codex/...` subtree
|
||||
* elsewhere on the filesystem does NOT qualify.
|
||||
*/
|
||||
export function isAllowedGeneratedArtifactPath(filePath: string, workingDir: string): boolean {
|
||||
const normalizedPath = normalize(filePath);
|
||||
if (isPathInside(normalizedPath, workingDir)) return true;
|
||||
return codexGeneratedDirs().some((dir) => isPathInside(normalizedPath, dir));
|
||||
}
|
||||
|
||||
function isPathInside(filePath: string, rootPath: string): boolean {
|
||||
const normalizedRoot = normalize(rootPath);
|
||||
if (filePath === normalizedRoot) return true;
|
||||
return filePath.startsWith(normalizedRoot.endsWith(sep) ? normalizedRoot : normalizedRoot + sep);
|
||||
}
|
||||
@@ -14,6 +14,18 @@ import { program } from './cli.js';
|
||||
// In web mode, we should NOT exit on transient errors — log and continue
|
||||
const isWebMode = process.argv.includes('web');
|
||||
|
||||
// COD-115: Codeman IS a tmux controller; it must never present as a tmux *client*.
|
||||
// If the web server is launched from inside a tmux pane it inherits TMUX/TMUX_PANE,
|
||||
// and tmux's nesting guard then kills every new attach-bridge PTY (exit 1 → respawn
|
||||
// loop, crash-looping any new tmux-backed session). Scrub at the root so every
|
||||
// downstream `{...process.env}` spread (attach, send-keys, create) is clean regardless
|
||||
// of launch context. `delete` (not `= undefined`, which node-pty serializes as the
|
||||
// literal string "undefined" and fails to clear).
|
||||
if (isWebMode) {
|
||||
delete process.env.TMUX;
|
||||
delete process.env.TMUX_PANE;
|
||||
}
|
||||
|
||||
import { MAX_CONSECUTIVE_ERRORS, ERROR_RESET_MS } from './config/server-timing.js';
|
||||
|
||||
// Track consecutive unhandled errors in web mode — restart after too many
|
||||
|
||||
+55
-4
@@ -16,6 +16,9 @@ import type {
|
||||
OpenCodeConfig,
|
||||
CodexConfig,
|
||||
EffortLevel,
|
||||
GeminiConfig,
|
||||
SessionRemote,
|
||||
SessionDocker,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
@@ -32,6 +35,12 @@ export interface MuxSession {
|
||||
createdAt: number;
|
||||
/** Working directory */
|
||||
workingDir: string;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
||||
docker?: SessionDocker;
|
||||
/** Owning username in multi-user mode (round-tripped through recovery like remote/docker) */
|
||||
owner?: string;
|
||||
/** Session mode */
|
||||
mode: SessionMode;
|
||||
/** Whether webserver is attached to this session */
|
||||
@@ -64,12 +73,21 @@ export interface CreateSessionOptions {
|
||||
allowedTools?: string;
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
codexConfig?: CodexConfig;
|
||||
geminiConfig?: GeminiConfig;
|
||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
|
||||
effort?: EffortLevel;
|
||||
/** tmux history-limit (scrollback lines) to set for this session. */
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
||||
docker?: SessionDocker;
|
||||
/** Owning username in multi-user mode; persisted for recovery. */
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
/** Options for respawning a dead pane. */
|
||||
@@ -83,12 +101,35 @@ export interface RespawnPaneOptions {
|
||||
allowedTools?: string;
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
codexConfig?: CodexConfig;
|
||||
geminiConfig?: GeminiConfig;
|
||||
/** Resume a previous Claude conversation when respawning */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
|
||||
effort?: EffortLevel;
|
||||
/** tmux history-limit (scrollback lines) to set for this session after respawn. */
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
|
||||
docker?: SessionDocker;
|
||||
/** Owning username (multi-user); redundant on respawn since the Session object survives, kept for shape parity. */
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
/** Options for pane buffer capture (COD-47 full-history mode). */
|
||||
export interface PaneCaptureOptions {
|
||||
/** Capture the entire tmux scrollback instead of just the visible frame. */
|
||||
fullHistory?: boolean;
|
||||
/** Bound the full-history capture to this many scrollback lines (`-S -<N>`). */
|
||||
historyLimitLines?: number;
|
||||
/**
|
||||
* Byte cap the consumer will keep from the capture. Sizes the child-process
|
||||
* stdout buffer (with slack) so multi-MB scrollback dumps aren't killed by
|
||||
* the 1MB execSync default (ENOBUFS).
|
||||
*/
|
||||
maxCaptureBytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,6 +208,9 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Update Ralph enabled state for a session */
|
||||
updateRalphEnabled(sessionId: string, enabled: boolean): void;
|
||||
|
||||
/** Apply a tmux history-limit to all tracked sessions. */
|
||||
setHistoryLimit(limit: number): Promise<void>;
|
||||
|
||||
// ========== Discovery ==========
|
||||
|
||||
/**
|
||||
@@ -215,9 +259,16 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */
|
||||
respawnPane(options: RespawnPaneOptions): Promise<number | null>;
|
||||
|
||||
/** Capture a pane's current tmux buffer with ANSI escape codes preserved. */
|
||||
capturePaneBuffer?(muxName: string, paneTarget: string): string | null;
|
||||
/**
|
||||
* Capture a pane's current tmux buffer with ANSI escape codes preserved.
|
||||
* Pass `{ fullHistory: true }` to capture the entire scrollback as linear
|
||||
* text instead of just the visible single-screen frame (COD-47).
|
||||
*/
|
||||
capturePaneBuffer?(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null;
|
||||
|
||||
/** Capture the active pane's current tmux buffer with ANSI escape codes preserved. */
|
||||
captureActivePaneBuffer?(muxName: string): string | null;
|
||||
/**
|
||||
* Capture the active pane's current tmux buffer with ANSI escape codes preserved.
|
||||
* Pass `{ fullHistory: true }` to capture the entire scrollback (COD-47).
|
||||
*/
|
||||
captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { TerminalMultiplexer } from './mux-interface.js';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
|
||||
import { getErrorMessage, type PlanItem } from './types.js';
|
||||
import { getErrorMessage, type PlanItem, type ClaudeMode } from './types.js';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { PlanItem };
|
||||
@@ -130,18 +130,28 @@ export class PlanOrchestrator {
|
||||
private taskDescription = '';
|
||||
private researchModel: string;
|
||||
private plannerModel: string;
|
||||
// Multi-user permission threading: the resolved claudeMode/owner/allowedTools for the
|
||||
// internal research/planner one-shots. Left undefined = today's single-user behavior
|
||||
// (the caller threads the resolved global mode, byte-identical when !isMultiUserMode()).
|
||||
private claudeMode?: ClaudeMode;
|
||||
private owner?: string;
|
||||
private allowedTools?: string;
|
||||
|
||||
constructor(
|
||||
mux: TerminalMultiplexer,
|
||||
workingDir: string = process.cwd(),
|
||||
outputDir?: string,
|
||||
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> }
|
||||
modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record<string, string> },
|
||||
security?: { claudeMode?: ClaudeMode; owner?: string; allowedTools?: string }
|
||||
) {
|
||||
this.mux = mux;
|
||||
this.workingDir = workingDir;
|
||||
this.outputDir = outputDir;
|
||||
this.researchModel = modelConfig?.agentTypeOverrides?.explore || modelConfig?.defaultModel || DEFAULT_MODEL;
|
||||
this.plannerModel = modelConfig?.agentTypeOverrides?.review || modelConfig?.defaultModel || DEFAULT_MODEL;
|
||||
this.claudeMode = security?.claudeMode;
|
||||
this.owner = security?.owner;
|
||||
this.allowedTools = security?.allowedTools;
|
||||
}
|
||||
|
||||
private saveAgentOutput(agentType: string, prompt: string, result: unknown, durationMs: number): void {
|
||||
@@ -424,6 +434,12 @@ export class PlanOrchestrator {
|
||||
mux: this.mux,
|
||||
useMux: false,
|
||||
mode: 'claude',
|
||||
// Section 6.3: run this one-shot under the caller-resolved permission mode/owner so a
|
||||
// non-granted multi-user user cannot regain --dangerously-skip-permissions. Undefined
|
||||
// (single-user, not threaded) is byte-identical to today (Session keeps its default).
|
||||
claudeMode: this.claudeMode,
|
||||
allowedTools: this.allowedTools,
|
||||
owner: this.owner,
|
||||
});
|
||||
|
||||
this.runningSessions.add(session);
|
||||
@@ -580,6 +596,10 @@ export class PlanOrchestrator {
|
||||
mux: this.mux,
|
||||
useMux: false,
|
||||
mode: 'claude',
|
||||
// Section 6.3: same permission-mode/owner threading as the research one-shot above.
|
||||
claudeMode: this.claudeMode,
|
||||
allowedTools: this.allowedTools,
|
||||
owner: this.owner,
|
||||
});
|
||||
|
||||
this.runningSessions.add(session);
|
||||
|
||||
+25
-10
@@ -9,10 +9,23 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import webpush from 'web-push';
|
||||
import type { VapidKeys, PushSubscriptionRecord } from './types.js';
|
||||
import type { VapidKeys, PushSubscriptionRecord, UserRole } from './types.js';
|
||||
import { Debouncer } from './utils/index.js';
|
||||
import { getDataDir } from './config/instance.js';
|
||||
|
||||
/**
|
||||
* A push subscription plus the multi-user owner identity stamped at subscribe time.
|
||||
* `username`/`role` are undefined in single-user mode (and for legacy records saved
|
||||
* before this field existed). sendPushNotifications uses them to scope a
|
||||
* session-notification to its owner's devices (+ admins) instead of fanning out to
|
||||
* every user. Kept as a store-local widening of PushSubscriptionRecord so the shared
|
||||
* type stays untouched; the extra keys serialize/persist transparently.
|
||||
*/
|
||||
export type OwnedPushSubscriptionRecord = PushSubscriptionRecord & {
|
||||
username?: string;
|
||||
role?: UserRole;
|
||||
};
|
||||
|
||||
const DATA_DIR = getDataDir();
|
||||
const KEYS_FILE = join(DATA_DIR, 'push-keys.json');
|
||||
const SUBS_FILE = join(DATA_DIR, 'push-subscriptions.json');
|
||||
@@ -20,7 +33,7 @@ const SAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
export class PushSubscriptionStore {
|
||||
private vapidKeys: VapidKeys | null = null;
|
||||
private subscriptions: Map<string, PushSubscriptionRecord> = new Map();
|
||||
private subscriptions: Map<string, OwnedPushSubscriptionRecord> = new Map();
|
||||
private saveDeb = new Debouncer(SAVE_DEBOUNCE_MS);
|
||||
private _disposed = false;
|
||||
|
||||
@@ -67,17 +80,19 @@ export class PushSubscriptionStore {
|
||||
}
|
||||
|
||||
/** Register or update a push subscription (deduplicates by endpoint) */
|
||||
addSubscription(sub: Omit<PushSubscriptionRecord, 'lastUsedAt'>): PushSubscriptionRecord {
|
||||
addSubscription(sub: Omit<OwnedPushSubscriptionRecord, 'lastUsedAt'>): OwnedPushSubscriptionRecord {
|
||||
// Check for existing subscription with same endpoint
|
||||
for (const [existingId, existing] of this.subscriptions) {
|
||||
if (existing.endpoint === sub.endpoint) {
|
||||
// Update existing
|
||||
const updated: PushSubscriptionRecord = {
|
||||
// Update existing (re-stamp owner identity so it tracks the current caller)
|
||||
const updated: OwnedPushSubscriptionRecord = {
|
||||
...existing,
|
||||
keys: sub.keys,
|
||||
userAgent: sub.userAgent,
|
||||
lastUsedAt: Date.now(),
|
||||
pushPreferences: sub.pushPreferences,
|
||||
username: sub.username,
|
||||
role: sub.role,
|
||||
};
|
||||
this.subscriptions.set(existingId, updated);
|
||||
this.scheduleSave();
|
||||
@@ -86,7 +101,7 @@ export class PushSubscriptionStore {
|
||||
}
|
||||
|
||||
// New subscription
|
||||
const record: PushSubscriptionRecord = {
|
||||
const record: OwnedPushSubscriptionRecord = {
|
||||
...sub,
|
||||
lastUsedAt: Date.now(),
|
||||
};
|
||||
@@ -96,7 +111,7 @@ export class PushSubscriptionStore {
|
||||
}
|
||||
|
||||
/** Update push preferences for a subscription */
|
||||
updatePreferences(id: string, preferences: Record<string, boolean>): PushSubscriptionRecord | null {
|
||||
updatePreferences(id: string, preferences: Record<string, boolean>): OwnedPushSubscriptionRecord | null {
|
||||
const sub = this.subscriptions.get(id);
|
||||
if (!sub) return null;
|
||||
sub.pushPreferences = preferences;
|
||||
@@ -124,12 +139,12 @@ export class PushSubscriptionStore {
|
||||
}
|
||||
|
||||
/** Get all subscriptions */
|
||||
getAll(): PushSubscriptionRecord[] {
|
||||
getAll(): OwnedPushSubscriptionRecord[] {
|
||||
return Array.from(this.subscriptions.values());
|
||||
}
|
||||
|
||||
/** Get a single subscription by ID */
|
||||
get(id: string): PushSubscriptionRecord | null {
|
||||
get(id: string): OwnedPushSubscriptionRecord | null {
|
||||
return this.subscriptions.get(id) ?? null;
|
||||
}
|
||||
|
||||
@@ -138,7 +153,7 @@ export class PushSubscriptionStore {
|
||||
if (!existsSync(SUBS_FILE)) return;
|
||||
try {
|
||||
const raw = readFileSync(SUBS_FILE, 'utf-8');
|
||||
const arr = JSON.parse(raw) as PushSubscriptionRecord[];
|
||||
const arr = JSON.parse(raw) as OwnedPushSubscriptionRecord[];
|
||||
for (const sub of arr) {
|
||||
this.subscriptions.set(sub.id, sub);
|
||||
}
|
||||
|
||||
+47
-3
@@ -467,6 +467,12 @@ export class RalphTracker extends EventEmitter {
|
||||
/** Timestamp of last cleanup check for throttling */
|
||||
private _lastCleanupTime: number = 0;
|
||||
|
||||
/** Maximum number of todos retained for this session (defaults to global cap) */
|
||||
private _maxTodos: number = MAX_TODOS_PER_SESSION;
|
||||
|
||||
/** Todo auto-expiry duration in milliseconds (defaults to global constant) */
|
||||
private _todoExpiryMs: number = TODO_EXPIRY_MS;
|
||||
|
||||
/** Debouncer for todoUpdate events */
|
||||
private _todoDeb = new Debouncer(EVENT_DEBOUNCE_MS);
|
||||
|
||||
@@ -1053,6 +1059,10 @@ export class RalphTracker extends EventEmitter {
|
||||
planVersion: this.planTracker.planVersion,
|
||||
planHistoryLength: this.planTracker.getPlanHistory().length,
|
||||
completionConfidence: this._lastCompletionConfidence,
|
||||
// Surface the live todo-config so it persists (toState) and reads back into
|
||||
// the Session Options modal (broadcast) — mirrors maxIterations round-trip.
|
||||
maxTodos: this._maxTodos,
|
||||
todoExpirationMinutes: this.todoExpirationMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1840,7 +1850,7 @@ export class RalphTracker extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
while (this._todos.size >= MAX_TODOS_PER_SESSION) {
|
||||
while (this._todos.size >= this._maxTodos) {
|
||||
const oldest = this.findOldestTodo();
|
||||
if (oldest) {
|
||||
this._todos.delete(oldest.id);
|
||||
@@ -2164,14 +2174,14 @@ export class RalphTracker extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove todo items older than TODO_EXPIRY_MS.
|
||||
* Remove todo items older than the configured expiry duration.
|
||||
*/
|
||||
private cleanupExpiredTodos(): void {
|
||||
const now = Date.now();
|
||||
const toDelete: string[] = [];
|
||||
|
||||
for (const [id, todo] of this._todos) {
|
||||
if (now - todo.detectedAt > TODO_EXPIRY_MS) {
|
||||
if (now - todo.detectedAt > this._todoExpiryMs) {
|
||||
toDelete.push(id);
|
||||
}
|
||||
}
|
||||
@@ -2211,6 +2221,34 @@ export class RalphTracker extends EventEmitter {
|
||||
this.emit('loopUpdate', this.loopState);
|
||||
}
|
||||
|
||||
/** Maximum number of todos retained for this session. */
|
||||
get maxTodos(): number {
|
||||
return this._maxTodos;
|
||||
}
|
||||
|
||||
/** Todo auto-expiry duration in minutes for this session. */
|
||||
get todoExpirationMinutes(): number {
|
||||
return Math.round(this._todoExpiryMs / 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the maximum number of retained todos (external API).
|
||||
* Ignores non-positive values.
|
||||
*/
|
||||
setMaxTodos(maxTodos: number): void {
|
||||
if (!Number.isFinite(maxTodos) || maxTodos <= 0) return;
|
||||
this._maxTodos = Math.floor(maxTodos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the todo auto-expiry duration (external API), specified in minutes.
|
||||
* Converts to milliseconds internally. Ignores non-positive values.
|
||||
*/
|
||||
setTodoExpirationMinutes(minutes: number): void {
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) return;
|
||||
this._todoExpiryMs = Math.floor(minutes) * 60000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the tracker from external state.
|
||||
*/
|
||||
@@ -2311,6 +2349,12 @@ export class RalphTracker extends EventEmitter {
|
||||
...loopState,
|
||||
enabled: loopState.enabled ?? false,
|
||||
};
|
||||
// Restore the per-session todo-config into the live fields used by the hot
|
||||
// paths (eviction cap + expiry). Setters ignore non-positive values.
|
||||
if (typeof loopState.maxTodos === 'number') this.setMaxTodos(loopState.maxTodos);
|
||||
if (typeof loopState.todoExpirationMinutes === 'number') {
|
||||
this.setTodoExpirationMinutes(loopState.todoExpirationMinutes);
|
||||
}
|
||||
this._todos.clear();
|
||||
for (const todo of todos) {
|
||||
this._todos.set(todo.id, {
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type {
|
||||
RemoteCase,
|
||||
RemoteCommandMode,
|
||||
RemoteHost,
|
||||
RemoteSessionInfo,
|
||||
RemoteSshOptions,
|
||||
SessionMode,
|
||||
SessionRemote,
|
||||
} from './types.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const REMOTE_HOSTS_FILE = 'remote-hosts.json';
|
||||
const REMOTE_CASES_FILE = 'remote-cases.json';
|
||||
|
||||
export function remoteHostsPath(configDir: string): string {
|
||||
return join(configDir, REMOTE_HOSTS_FILE);
|
||||
}
|
||||
|
||||
export function remoteCasesPath(configDir: string): string {
|
||||
return join(configDir, REMOTE_CASES_FILE);
|
||||
}
|
||||
|
||||
async function readJsonArray<T>(path: string): Promise<T[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(path, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonArray<T>(configDir: string, path: string, value: T[]): Promise<void> {
|
||||
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
||||
await fs.writeFile(path, JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
export async function readRemoteHosts(configDir: string): Promise<RemoteHost[]> {
|
||||
return readJsonArray<RemoteHost>(remoteHostsPath(configDir));
|
||||
}
|
||||
|
||||
export async function writeRemoteHosts(configDir: string, hosts: RemoteHost[]): Promise<void> {
|
||||
await writeJsonArray(configDir, remoteHostsPath(configDir), hosts);
|
||||
}
|
||||
|
||||
export async function readRemoteCases(configDir: string): Promise<RemoteCase[]> {
|
||||
return readJsonArray<RemoteCase>(remoteCasesPath(configDir));
|
||||
}
|
||||
|
||||
export async function writeRemoteCases(configDir: string, cases: RemoteCase[]): Promise<void> {
|
||||
await writeJsonArray(configDir, remoteCasesPath(configDir), cases);
|
||||
}
|
||||
|
||||
export function defaultRemoteCommandForMode(mode: SessionMode): string {
|
||||
const commands: Record<RemoteCommandMode, string> = {
|
||||
shell: 'exec bash -l',
|
||||
// Mirror the LOCAL claude default so the remote agent runs non-interactively
|
||||
// (no trust-folder/permission prompt that nothing on the remote answers). The
|
||||
// per-host `commands.claude` override stays the escape hatch.
|
||||
claude: 'exec claude --dangerously-skip-permissions',
|
||||
opencode: 'exec opencode',
|
||||
codex: 'exec codex',
|
||||
gemini: 'exec gemini',
|
||||
};
|
||||
return commands[mode as RemoteCommandMode] || commands.shell;
|
||||
}
|
||||
|
||||
export function remoteSshTarget(host: Pick<RemoteHost, 'username' | 'host'>): string {
|
||||
return `${host.username}@${host.host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
|
||||
* Mirrors the helper in tmux-manager.ts so a value with spaces/metachars stays a
|
||||
* single shell token. Used here for identity paths and `-o KEY=VALUE` options.
|
||||
*/
|
||||
function shellescape(str: string): string {
|
||||
return "'" + str.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a leading `~` or `$HOME` in an identity path to an absolute path.
|
||||
*
|
||||
* ssh does NOT expand `~` inside `-i` (the shell would, but we shellescape the
|
||||
* value into a single quoted token so the shell never sees it). So we expand at
|
||||
* build time, before escaping. Non-`~`/`$HOME` paths are returned unchanged.
|
||||
*/
|
||||
function expandIdentityPath(identityFile: string): string {
|
||||
if (identityFile === '~') return homedir();
|
||||
if (identityFile.startsWith('~/')) return join(homedir(), identityFile.slice(2));
|
||||
if (identityFile === '$HOME') return homedir();
|
||||
if (identityFile.startsWith('$HOME/')) return join(homedir(), identityFile.slice('$HOME/'.length));
|
||||
return identityFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-107 — build the ordered, shell-safe ssh CONNECTION tokens shared by both
|
||||
* the durable-launch command (`buildRemoteLaunchCommand`) and the tmux
|
||||
* prerequisite probe (`buildRemoteTmuxCheckCommand`), so the prereq check and
|
||||
* the real launch connect with IDENTICAL options (they can't drift).
|
||||
*
|
||||
* Returns the leading tokens of an ssh command line (NOT including `-t`, the
|
||||
* target, or any remote command). Order:
|
||||
* ssh -o BatchMode=yes
|
||||
* [-o ConnectTimeout=10] (default; suppressed if extraSshOptions sets it)
|
||||
* [-p <port>]
|
||||
* [-i <abs-identity>] (~/$HOME expanded, then shellescaped)
|
||||
* [-J <jumpHost>] (shellescaped, single token)
|
||||
* [-o ProxyCommand=nc -X 5 -x <socks> %h %p] (ONE shellescaped -o token)
|
||||
* [-o <KEY=VALUE>] … (each extra option, shellescaped)
|
||||
*
|
||||
* Escaping notes (the risky part):
|
||||
* - The ProxyCommand is emitted as a single shellescaped `-o KEY=VALUE`, so the
|
||||
* whole value (spaces + `%h`/`%p`) reaches ssh as one argument and `%h %p`
|
||||
* survive verbatim — ssh expands them to the real host/port, not the shell.
|
||||
* - A default `-o ConnectTimeout=10` bounds the wait on an unreachable/blackholed
|
||||
* host (else the pane hangs on the OS TCP timeout). It is omitted when the
|
||||
* operator already set ConnectTimeout via extraSshOptions, so their value wins.
|
||||
*/
|
||||
export function buildSshConnectionArgs(remote: RemoteSshOptions & Pick<RemoteHost, 'port'>): string[] {
|
||||
const parts: string[] = ['ssh', '-o BatchMode=yes'];
|
||||
const hasConnectTimeout = (remote.extraSshOptions ?? []).some((opt) => /^ConnectTimeout=/i.test(opt));
|
||||
if (!hasConnectTimeout) parts.push('-o ConnectTimeout=10');
|
||||
if (remote.port) parts.push(`-p ${remote.port}`);
|
||||
if (remote.identityFile) parts.push(`-i ${shellescape(expandIdentityPath(remote.identityFile))}`);
|
||||
if (remote.jumpHost) parts.push(`-J ${shellescape(remote.jumpHost)}`);
|
||||
if (remote.socksProxy) {
|
||||
parts.push(`-o ${shellescape(`ProxyCommand=nc -X 5 -x ${remote.socksProxy} %h %p`)}`);
|
||||
}
|
||||
for (const opt of remote.extraSshOptions ?? []) {
|
||||
parts.push(`-o ${shellescape(opt)}`);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-104 — build the SSH command that checks the remote host has tmux.
|
||||
*
|
||||
* Durable remote sessions run the agent inside a tmux server ON the remote host
|
||||
* (`tmux -L codeman new-session -A …`), so tmux is now a hard prerequisite there.
|
||||
* `command -v tmux` exits 0 (and prints the path) when tmux is installed.
|
||||
*
|
||||
* COD-107 — connects with the SAME options as the real launch
|
||||
* (`buildSshConnectionArgs`) so a proxied/custom-port/identity host that the
|
||||
* launch can reach also passes the prereq probe (and vice-versa).
|
||||
*/
|
||||
export function buildRemoteTmuxCheckCommand(
|
||||
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
|
||||
): string {
|
||||
// ConnectTimeout is now a default of buildSshConnectionArgs (shared with the launch).
|
||||
return [...buildSshConnectionArgs(host), remoteSshTarget(host), "'command -v tmux'"].join(' ');
|
||||
}
|
||||
|
||||
export interface RemoteTmuxCheckResult {
|
||||
ok: boolean;
|
||||
/** Resolved tmux path on the remote (when ok). */
|
||||
tmuxPath?: string;
|
||||
/** Human-readable failure reason (when !ok). */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-104 — verify the remote host has tmux installed (required for durable
|
||||
* remote sessions). Returns a structured result with a clear, user-facing error
|
||||
* when tmux is missing or the host is unreachable. Never throws.
|
||||
*/
|
||||
export async function checkRemoteTmuxAvailable(
|
||||
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
|
||||
): Promise<RemoteTmuxCheckResult> {
|
||||
// Under vitest, never open a real ssh connection — mirrors TmuxManager's
|
||||
// no-op-shell-under-VITEST (IS_TEST_MODE). Without this, remote-case
|
||||
// create-path tests hit a real ~10s ssh timeout. The command construction is
|
||||
// covered by buildRemoteTmuxCheckCommand unit tests; only the live probe is
|
||||
// short-circuited here.
|
||||
if (process.env.VITEST) {
|
||||
return { ok: true, tmuxPath: '(test-mode)' };
|
||||
}
|
||||
const command = buildRemoteTmuxCheckCommand(host);
|
||||
try {
|
||||
const { stdout } = await execAsync(command, { timeout: 15_000 });
|
||||
const tmuxPath = stdout.trim();
|
||||
if (!tmuxPath) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `remote host ${host.host} needs tmux installed for durable remote sessions`,
|
||||
};
|
||||
}
|
||||
return { ok: true, tmuxPath };
|
||||
} catch (err) {
|
||||
const stderr =
|
||||
err && typeof err === 'object' && 'stderr' in err ? String((err as { stderr?: unknown }).stderr ?? '') : '';
|
||||
// `command -v tmux` exits non-zero when tmux is absent (no stderr); a real
|
||||
// connection failure surfaces ssh diagnostics on stderr.
|
||||
if (stderr.trim()) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `could not verify tmux on remote host ${host.host}: ${stderr.trim()}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `remote host ${host.host} needs tmux installed for durable remote sessions`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — build the SSH command that lists `codeman-*` tmux sessions on a
|
||||
* remote host's canonical `-L codeman` socket.
|
||||
*
|
||||
* `list-sessions` exits NON-ZERO with empty output when no sessions exist (and
|
||||
* the server isn't running), so `2>/dev/null` swallows tmux's "no server
|
||||
* running" stderr; the caller treats a non-zero exit / empty output as "no
|
||||
* sessions" rather than an error.
|
||||
*
|
||||
* COD-107 — connection options come from the shared `buildSshConnectionArgs`, so
|
||||
* discovery connects with the SAME port/identity/proxy/jump-host as the launch
|
||||
* and the tmux prereq probe.
|
||||
*/
|
||||
export function buildRemoteListSessionsCommand(
|
||||
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
|
||||
): string {
|
||||
const [ssh, ...connectionArgs] = buildSshConnectionArgs(host);
|
||||
const parts = [ssh, connectionArgs[0], '-o ConnectTimeout=10', ...connectionArgs.slice(1)];
|
||||
// The tmux list-sessions invocation is passed as ONE shell-quoted argument so
|
||||
// the remote login shell runs it verbatim. The `-F` format uses literal `\t`
|
||||
// separators (tmux expands them); `2>/dev/null` is inside the quoted command.
|
||||
const remoteCmd =
|
||||
'tmux -L codeman list-sessions -F "#{session_name}\\t#{session_attached}\\t#{session_created}\\t#{session_windows}" 2>/dev/null';
|
||||
parts.push(remoteSshTarget(host), shellescape(remoteCmd));
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — pure parser for the `tmux list-sessions -F` output emitted by
|
||||
* `buildRemoteListSessionsCommand`. Factored out so the parse is unit-testable
|
||||
* without opening a real ssh connection.
|
||||
*
|
||||
* - Splits each non-empty line into [name, attached, created, windows] on the
|
||||
* field separator. IMPORTANT: the remote tmux's `-F "…\t…"` format does NOT
|
||||
* expand `\t` to a real tab — it emits the LITERAL two-character sequence
|
||||
* `\t` (verified on aa-desktop / tmux next-3.7). So we split on the literal
|
||||
* backslash-t sequence; we also tolerate a real tab in case a tmux build
|
||||
* does expand it. (A real TAB is the regex `\t`; a literal backslash-t is the
|
||||
* regex `\\t`.)
|
||||
* - Keeps ONLY sessions whose name starts with `codeman-` (ignores foreign tmux
|
||||
* sessions that happen to share the socket).
|
||||
* - Coerces: `attached` → boolean (`'1'`), `created`/`windows` → finite ints.
|
||||
* - Skips malformed lines (wrong column count or non-numeric created/windows)
|
||||
* rather than emitting garbage.
|
||||
*/
|
||||
export function parseRemoteSessionList(stdout: string): RemoteSessionInfo[] {
|
||||
const out: RemoteSessionInfo[] = [];
|
||||
for (const rawLine of stdout.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
// Split on a literal `\t` (backslash + t, what the remote tmux emits) OR a
|
||||
// real tab character. `/\\t|\t/` = the two-char sequence, or a TAB.
|
||||
const cols = line.split(/\\t|\t/);
|
||||
if (cols.length !== 4) continue;
|
||||
const [name, attachedStr, createdStr, windowsStr] = cols;
|
||||
if (!name.startsWith('codeman-')) continue;
|
||||
const created = Number(createdStr);
|
||||
const windows = Number(windowsStr);
|
||||
if (!Number.isFinite(created) || !Number.isFinite(windows)) continue;
|
||||
// COD-106 — `session_attached` is the CLIENT COUNT (not a 0/1 flag); >1 = shared.
|
||||
const attachedNum = Number(attachedStr.trim());
|
||||
const attachedClients = Number.isFinite(attachedNum) ? Math.max(0, Math.trunc(attachedNum)) : 0;
|
||||
out.push({
|
||||
name,
|
||||
attached: attachedClients > 0,
|
||||
attachedClients,
|
||||
created: Math.trunc(created),
|
||||
windows: Math.trunc(windows),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — discover `codeman-*` tmux sessions already running on a remote host
|
||||
* (created by the remote's own Codeman, another instance, or this one), so the
|
||||
* operator can attach to one this Codeman didn't launch.
|
||||
*
|
||||
* NEVER throws: returns `[]` on unreachable host / no tmux / no sessions
|
||||
* (`list-sessions` exits non-zero with empty output when there are none).
|
||||
*
|
||||
* VITEST guard — like `checkRemoteTmuxAvailable`, returns `[]` under test so a
|
||||
* real ssh never runs in a request path (which would make route tests hit a
|
||||
* ~10s timeout). The command construction is covered by
|
||||
* `buildRemoteListSessionsCommand` and the parse by `parseRemoteSessionList`.
|
||||
*/
|
||||
export async function listRemoteCodemanSessions(
|
||||
remote: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
|
||||
): Promise<RemoteSessionInfo[]> {
|
||||
if (process.env.VITEST) {
|
||||
return [];
|
||||
}
|
||||
const command = buildRemoteListSessionsCommand(remote);
|
||||
try {
|
||||
const { stdout } = await execAsync(command, { timeout: 15_000 });
|
||||
return parseRemoteSessionList(stdout);
|
||||
} catch {
|
||||
// Unreachable host, no tmux server, or no sessions (non-zero exit). All map
|
||||
// to "nothing to attach to" — never surface as an error to the caller.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function remoteDisplayPath(
|
||||
remote: Pick<SessionRemote, 'username' | 'host' | 'remotePath'> | { username: string; host: string; path: string }
|
||||
): string {
|
||||
const path = 'remotePath' in remote ? remote.remotePath : remote.path;
|
||||
return `${remote.username}@${remote.host}:${path}`;
|
||||
}
|
||||
|
||||
export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): SessionRemote {
|
||||
return {
|
||||
hostId: host.id,
|
||||
label: host.label,
|
||||
host: host.host,
|
||||
username: host.username,
|
||||
port: host.port,
|
||||
remotePath: remoteCase.remotePath,
|
||||
commands: host.commands,
|
||||
// COD-105 — the COD-104 launch path creates the remote session, so we own it
|
||||
// (an explicit kill may propagate a remote kill-session). Discovered+attached
|
||||
// sessions go through `toAttachedSessionRemote` with `owned: false`.
|
||||
owned: true,
|
||||
// COD-107 — carry the advanced SSH options from host config into the session
|
||||
// so the launch/prereq commands connect the same way the operator configured.
|
||||
identityFile: host.identityFile,
|
||||
socksProxy: host.socksProxy,
|
||||
jumpHost: host.jumpHost,
|
||||
extraSshOptions: host.extraSshOptions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — build a NON-owned `SessionRemote` for ATTACHING to a `codeman-*`
|
||||
* session already running on a remote host (discovered via
|
||||
* `listRemoteCodemanSessions`). The resulting session's pane runs
|
||||
* `tmux -L codeman attach -t <remoteSessionName>` (see
|
||||
* `buildRemoteAttachCommand`), and because we did NOT create the remote session,
|
||||
* `owned: false` means closing the tab DETACHES rather than killing it.
|
||||
*
|
||||
* `remotePath` is informational here (the attached remote session keeps its own
|
||||
* cwd); we record the host's nominal path so display helpers still show
|
||||
* `user@host:path`.
|
||||
*/
|
||||
export function toAttachedSessionRemote(
|
||||
host: RemoteHost,
|
||||
remoteSessionName: string,
|
||||
remotePath: string
|
||||
): SessionRemote {
|
||||
return {
|
||||
hostId: host.id,
|
||||
label: host.label,
|
||||
host: host.host,
|
||||
username: host.username,
|
||||
port: host.port,
|
||||
remotePath,
|
||||
commands: host.commands,
|
||||
// Discovered + attached — another Codeman created it. Detach-not-kill.
|
||||
owned: false,
|
||||
remoteSessionName,
|
||||
identityFile: host.identityFile,
|
||||
socksProxy: host.socksProxy,
|
||||
jumpHost: host.jumpHost,
|
||||
extraSshOptions: host.extraSshOptions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* @fileoverview Pure logic for the remote-session auto-reconnect watcher (COD-108).
|
||||
*
|
||||
* COD-104 made remote tmux sessions durable + idempotently reattachable, but a
|
||||
* reconnect only fired at explicit trigger points. COD-108 adds a continuous
|
||||
* watcher (in `TmuxManager`) that detects a dead remote pane and emits
|
||||
* `remoteSessionDropped`; `SessionManager`/server then reassembles the respawn
|
||||
* options and reattaches (re-running the idempotent remote command).
|
||||
*
|
||||
* This module holds the SIDE-EFFECT-FREE pieces so they can be unit-tested
|
||||
* without real tmux:
|
||||
* - the bounded exponential **backoff schedule** (attempt → delay, capped),
|
||||
* - the per-session **reconnect state** shape,
|
||||
* - the **eligibility decision** (`decideReconnect`) given a session + its
|
||||
* reconnect state + the current time + the guard set.
|
||||
*
|
||||
* The watcher in `tmux-manager.ts` owns the live `isPaneDead` probe and the
|
||||
* timers; everything here is pure and deterministic (time is injected).
|
||||
*
|
||||
* @module remote-reconnect
|
||||
*/
|
||||
|
||||
/**
|
||||
* Bounded exponential backoff delays (ms) between reconnect attempts.
|
||||
* Attempt N (1-based) waits `BACKOFF_SCHEDULE_MS[N-1]` from the previous emit
|
||||
* before the next emit is eligible. After the last entry the session is
|
||||
* considered `reconnect-exhausted` and the watcher stops emitting for it.
|
||||
*
|
||||
* 5s, 15s, 45s, 2m, 5m, 5m → ~6 attempts spanning ~13 minutes.
|
||||
*/
|
||||
export const BACKOFF_SCHEDULE_MS: readonly number[] = [5_000, 15_000, 45_000, 120_000, 300_000, 300_000];
|
||||
|
||||
/** Maximum number of reconnect attempts before exhaustion. */
|
||||
export const MAX_RECONNECT_ATTEMPTS = BACKOFF_SCHEDULE_MS.length;
|
||||
|
||||
/**
|
||||
* Delay (ms) to wait AFTER emitting attempt `attempt` (1-based) before the next
|
||||
* attempt is eligible. `attempt <= 0` returns the first delay; an attempt at or
|
||||
* beyond the cap returns the last delay (callers should check exhaustion via
|
||||
* {@link isExhausted} rather than relying on this for the stop decision).
|
||||
*
|
||||
* Pure — no clock, no I/O.
|
||||
*/
|
||||
export function reconnectDelayForAttempt(attempt: number): number {
|
||||
if (!Number.isFinite(attempt) || attempt <= 1) return BACKOFF_SCHEDULE_MS[0];
|
||||
const idx = Math.min(Math.floor(attempt) - 1, BACKOFF_SCHEDULE_MS.length - 1);
|
||||
return BACKOFF_SCHEDULE_MS[idx];
|
||||
}
|
||||
|
||||
/** Whether `attempts` reconnect emits have reached/exceeded the cap. Pure. */
|
||||
export function isExhausted(attempts: number): boolean {
|
||||
return attempts >= MAX_RECONNECT_ATTEMPTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session reconnect bookkeeping held by the watcher. All time values are
|
||||
* epoch ms. `inFlight` guards against stacking respawns when a tick fires while
|
||||
* a previous reattach is still running. `exhaustedEmitted` ensures the
|
||||
* `remoteReconnectExhausted` event fires at most once per session.
|
||||
*/
|
||||
export interface RemoteReconnectState {
|
||||
/** Number of `remoteSessionDropped` emits so far (advances per emit). */
|
||||
attempts: number;
|
||||
/** Earliest time (epoch ms) the next emit is eligible. 0 = eligible now. */
|
||||
nextEligibleAt: number;
|
||||
/** A reattach triggered by a prior emit is currently running. */
|
||||
inFlight: boolean;
|
||||
/** Cap reached — stop auto-retrying for this session. */
|
||||
exhausted: boolean;
|
||||
/** The `remoteReconnectExhausted` SSE event has already been emitted. */
|
||||
exhaustedEmitted: boolean;
|
||||
}
|
||||
|
||||
/** A fresh reconnect state (no attempts, immediately eligible). Pure. */
|
||||
export function freshReconnectState(): RemoteReconnectState {
|
||||
return { attempts: 0, nextEligibleAt: 0, inFlight: false, exhausted: false, exhaustedEmitted: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the backoff after an emit at time `now`. Increments `attempts` and
|
||||
* schedules `nextEligibleAt = now + delay`. Returns a NEW state object (does
|
||||
* not mutate the input). Pure.
|
||||
*
|
||||
* NOTE: this does NOT set `exhausted`. Exhaustion is a decision the watcher
|
||||
* makes on the FOLLOWING tick (via {@link decideReconnect} → `exhaust`), so the
|
||||
* `remoteReconnectExhausted` event fires exactly once after the final attempt's
|
||||
* backoff window elapses — not pre-emptively on the last emit.
|
||||
*/
|
||||
export function advanceBackoff(state: RemoteReconnectState, now: number): RemoteReconnectState {
|
||||
const attempts = state.attempts + 1;
|
||||
const delay = reconnectDelayForAttempt(attempts);
|
||||
return {
|
||||
...state,
|
||||
attempts,
|
||||
nextEligibleAt: now + delay,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reset after a successful reattach — back to a fresh, eligible state. Pure. */
|
||||
export function resetReconnectState(): RemoteReconnectState {
|
||||
return freshReconnectState();
|
||||
}
|
||||
|
||||
/** Minimal session view the decision needs (avoids importing MuxSession here). */
|
||||
export interface ReconnectSessionView {
|
||||
sessionId: string;
|
||||
/** Truthy when this is a remote (SSH-wrapped) session. */
|
||||
isRemote: boolean;
|
||||
/** Result of `isPaneDead(muxName)` for this session. */
|
||||
paneDead: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decision outcomes for a single watcher tick on one session.
|
||||
* - `emit` → emit `remoteSessionDropped { sessionId, attempt }`, then
|
||||
* advance backoff (attempt = the returned `attempt`).
|
||||
* - `exhaust` → cap reached this tick; emit `remoteReconnectExhausted` once.
|
||||
* - `skip` → do nothing (not remote / pane alive / guarded / in-flight /
|
||||
* not yet due / already exhausted).
|
||||
*/
|
||||
export type ReconnectAction =
|
||||
| { kind: 'emit'; attempt: number }
|
||||
| { kind: 'exhaust' }
|
||||
| { kind: 'skip'; reason: ReconnectSkipReason };
|
||||
|
||||
export type ReconnectSkipReason =
|
||||
| 'not-remote'
|
||||
| 'pane-alive'
|
||||
| 'guarded'
|
||||
| 'in-flight'
|
||||
| 'not-due'
|
||||
| 'exhausted'
|
||||
| 'disabled';
|
||||
|
||||
export interface DecideReconnectInput {
|
||||
session: ReconnectSessionView;
|
||||
state: RemoteReconnectState | undefined;
|
||||
/** Session is in the intentional-teardown guard set (killed/detached/stopping). */
|
||||
guarded: boolean;
|
||||
/** Kill-switch: `remoteAutoReconnect` setting. When false, never reconnect. */
|
||||
enabled: boolean;
|
||||
now: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE eligibility decision for one session on one tick. No clock, no I/O — all
|
||||
* inputs are passed in. The watcher translates the result into emits + state
|
||||
* transitions.
|
||||
*
|
||||
* Order of guards (most-decisive first):
|
||||
* 1. kill-switch off → skip:disabled
|
||||
* 2. not a remote session → skip:not-remote
|
||||
* 3. pane is alive → skip:pane-alive
|
||||
* 4. intentional teardown guard → skip:guarded (NEVER revive a killed tab)
|
||||
* 5. a reattach already running → skip:in-flight (no stacked respawns)
|
||||
* 6. already exhausted → skip:exhausted (one exhaust emit, then quiet)
|
||||
* 7. cap reached this tick → exhaust
|
||||
* 8. not yet due (backoff) → skip:not-due
|
||||
* 9. otherwise → emit (attempt = attempts + 1)
|
||||
*/
|
||||
export function decideReconnect(input: DecideReconnectInput): ReconnectAction {
|
||||
const { session, state, guarded, enabled, now } = input;
|
||||
|
||||
if (!enabled) return { kind: 'skip', reason: 'disabled' };
|
||||
if (!session.isRemote) return { kind: 'skip', reason: 'not-remote' };
|
||||
if (!session.paneDead) return { kind: 'skip', reason: 'pane-alive' };
|
||||
// Intentional kill / detach must NEVER be auto-revived.
|
||||
if (guarded) return { kind: 'skip', reason: 'guarded' };
|
||||
|
||||
const s = state ?? freshReconnectState();
|
||||
|
||||
// Only one reconnect in flight per session — don't stack respawns.
|
||||
if (s.inFlight) return { kind: 'skip', reason: 'in-flight' };
|
||||
|
||||
if (s.exhausted) return { kind: 'skip', reason: 'exhausted' };
|
||||
|
||||
// Cap reached: surface exhaustion once, then go quiet.
|
||||
if (isExhausted(s.attempts)) return { kind: 'exhaust' };
|
||||
|
||||
// Backoff gate — only emit when due.
|
||||
if (now < s.nextEligibleAt) return { kind: 'skip', reason: 'not-due' };
|
||||
|
||||
return { kind: 'emit', attempt: s.attempts + 1 };
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @fileoverview Pure cross-session federated search core (COD-9).
|
||||
*
|
||||
* `searchSources()` is the testable heart of `GET /api/search`: it takes a
|
||||
* normalized query plus already-collected, in-memory source data and returns
|
||||
* grouped, ranked, and capped results. It performs NO I/O — the route wrapper
|
||||
* (`src/web/routes/search-routes.ts`) is responsible for harvesting the source
|
||||
* arrays from the live server stores (sessions, run-summary trackers, attachment
|
||||
* histories) in a bounded way before calling this.
|
||||
*
|
||||
* v1 scope (do not expand here): three sources — sessions/cases, run-summary
|
||||
* events, file paths. Terminal-buffer scanning and any persisted index are
|
||||
* explicitly deferred.
|
||||
*
|
||||
* Ranking: results are grouped by source type in the fixed order
|
||||
* sessions → events → files. Within each group, exact (case-insensitive)
|
||||
* name/path matches come first, then recency (newest timestamp first) as the
|
||||
* tiebreak. There is no relevance-scoring pass in v1.
|
||||
*
|
||||
* Safety: file results only ever expose a workspace-relative path — server-
|
||||
* private absolute paths are never placed in a result. Per-group and total caps
|
||||
* bound the output so a broad query cannot return an unbounded payload.
|
||||
*
|
||||
* Key exports:
|
||||
* - searchSources() — the pure core.
|
||||
* - SEARCH_TOTAL_CAP / SEARCH_PER_GROUP_CAP — the output bounds.
|
||||
* - SearchSources and the *Input row types — the source-data contract.
|
||||
*/
|
||||
|
||||
import type { SearchResult, SearchResultGroup, SearchResponseData, SearchSourceType } from './types/search.js';
|
||||
|
||||
/** Maximum results returned across all groups combined. */
|
||||
export const SEARCH_TOTAL_CAP = 60;
|
||||
/** Maximum results returned within any single source group. */
|
||||
export const SEARCH_PER_GROUP_CAP = 25;
|
||||
/** Maximum characters in a result snippet. */
|
||||
export const SEARCH_SNIPPET_MAX = 200;
|
||||
|
||||
/** A live-session row harvested for the session/case source. */
|
||||
export interface SessionSearchInput {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
workingDir: string;
|
||||
/** Recency timestamp (e.g. lastActivityAt or createdAt). */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** A run-summary timeline event harvested for the event source. */
|
||||
export interface EventSearchInput {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
eventId: string;
|
||||
title: string;
|
||||
details: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** A per-session attachment harvested for the file source. */
|
||||
export interface FileSearchInput {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
fileName: string;
|
||||
/** Workspace-relative path, if known. Absolute/external paths are never passed in. */
|
||||
relativePath: string | undefined;
|
||||
timestamp: number;
|
||||
/** Attachment history item id, used as the jump-to target. */
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
/** The full set of in-memory source data the pure core searches over. */
|
||||
export interface SearchSources {
|
||||
sessions: SessionSearchInput[];
|
||||
events: EventSearchInput[];
|
||||
files: FileSearchInput[];
|
||||
}
|
||||
|
||||
/** Fixed group/render order. */
|
||||
const GROUP_ORDER: SearchSourceType[] = ['session', 'event', 'file'];
|
||||
|
||||
function truncate(text: string, max = SEARCH_SNIPPET_MAX): string {
|
||||
const trimmed = text.trim().replace(/\s+/g, ' ');
|
||||
return trimmed.length > max ? trimmed.slice(0, max - 1) + '…' : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a group's results: exact matches first, then newest timestamp first.
|
||||
* Stable for equal keys.
|
||||
*/
|
||||
function sortGroup(rows: SearchResult[]): SearchResult[] {
|
||||
return rows
|
||||
.map((result, index) => ({ result, index }))
|
||||
.sort((a, b) => {
|
||||
if (a.result.exactMatch !== b.result.exactMatch) {
|
||||
return a.result.exactMatch ? -1 : 1;
|
||||
}
|
||||
if (a.result.timestamp !== b.result.timestamp) {
|
||||
return b.result.timestamp - a.result.timestamp;
|
||||
}
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map((r) => r.result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the provided in-memory sources for `query`.
|
||||
*
|
||||
* @param query Raw query string (already length-validated by the route). Blank
|
||||
* queries return an empty result set.
|
||||
* @param sources Harvested, bounded source arrays.
|
||||
*/
|
||||
export function searchSources(query: string, sources: SearchSources): SearchResponseData {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (needle.length === 0) {
|
||||
return { query: query.trim(), groups: [], totalResults: 0, truncated: false };
|
||||
}
|
||||
|
||||
const contains = (s: string | undefined): boolean => !!s && s.toLowerCase().includes(needle);
|
||||
const isExact = (s: string | undefined): boolean => !!s && s.toLowerCase() === needle;
|
||||
|
||||
// -- Source: sessions/cases --
|
||||
const sessionRows: SearchResult[] = [];
|
||||
for (const s of sources.sessions) {
|
||||
if (contains(s.sessionName) || contains(s.workingDir) || contains(s.sessionId)) {
|
||||
sessionRows.push({
|
||||
type: 'session',
|
||||
sessionId: s.sessionId,
|
||||
sessionName: s.sessionName,
|
||||
timestamp: s.timestamp,
|
||||
snippet: truncate(s.workingDir ? `${s.sessionName} — ${s.workingDir}` : s.sessionName),
|
||||
exactMatch: isExact(s.sessionName),
|
||||
jumpTo: { kind: 'session', sessionId: s.sessionId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -- Source: run-summary events --
|
||||
const eventRows: SearchResult[] = [];
|
||||
for (const e of sources.events) {
|
||||
if (contains(e.title) || contains(e.details)) {
|
||||
const snippetBase = e.details && contains(e.details) ? `${e.title}: ${e.details}` : e.title;
|
||||
eventRows.push({
|
||||
type: 'event',
|
||||
sessionId: e.sessionId,
|
||||
sessionName: e.sessionName,
|
||||
timestamp: e.timestamp,
|
||||
snippet: truncate(snippetBase),
|
||||
exactMatch: isExact(e.title),
|
||||
jumpTo: { kind: 'run-summary', sessionId: e.sessionId, targetId: e.eventId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -- Source: file paths --
|
||||
const fileRows: SearchResult[] = [];
|
||||
for (const f of sources.files) {
|
||||
if (contains(f.fileName) || contains(f.relativePath)) {
|
||||
fileRows.push({
|
||||
type: 'file',
|
||||
sessionId: f.sessionId,
|
||||
sessionName: f.sessionName,
|
||||
timestamp: f.timestamp,
|
||||
snippet: truncate(f.relativePath ?? f.fileName),
|
||||
// Exact match keys off the safe path (or filename) — never an absolute path.
|
||||
exactMatch: isExact(f.relativePath) || isExact(f.fileName),
|
||||
jumpTo: {
|
||||
kind: 'file-preview',
|
||||
sessionId: f.sessionId,
|
||||
targetId: f.itemId,
|
||||
// Only ever expose a relative path; absolute/external paths are not passed in.
|
||||
relativePath: f.relativePath,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const byType: Record<SearchSourceType, SearchResult[]> = {
|
||||
session: sortGroup(sessionRows),
|
||||
event: sortGroup(eventRows),
|
||||
file: sortGroup(fileRows),
|
||||
};
|
||||
|
||||
const groups: SearchResultGroup[] = [];
|
||||
let total = 0;
|
||||
let truncated = false;
|
||||
|
||||
for (const type of GROUP_ORDER) {
|
||||
const all = byType[type];
|
||||
if (all.length === 0) continue;
|
||||
|
||||
// Per-group cap.
|
||||
let capped = all.slice(0, SEARCH_PER_GROUP_CAP);
|
||||
if (all.length > capped.length) truncated = true;
|
||||
|
||||
// Total cap (never exceed the global budget).
|
||||
const remaining = SEARCH_TOTAL_CAP - total;
|
||||
if (capped.length > remaining) {
|
||||
capped = capped.slice(0, Math.max(0, remaining));
|
||||
truncated = true;
|
||||
}
|
||||
if (capped.length === 0) continue;
|
||||
|
||||
groups.push({ type, results: capped });
|
||||
total += capped.length;
|
||||
}
|
||||
|
||||
return { query: query.trim(), groups, totalResults: total, truncated };
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* @fileoverview Pure merge/filter logic for the unified session list (COD-121).
|
||||
*
|
||||
* Combines four read-only views of a session — live (in-memory `Session`),
|
||||
* persisted (`state.json`), transcript history (`~/.claude/projects`), and the
|
||||
* lifecycle audit log — plus mux process stats, into one de-duplicated list
|
||||
* keyed by sessionId. Transcript-history rows are keyed by the Claude
|
||||
* conversation UUID (the `.jsonl` filename stem), which diverges from the
|
||||
* Codeman id for resumed sessions — an alias map (claudeSessionId → Codeman id,
|
||||
* built from the live/persisted views) folds them into the owning session item.
|
||||
* Higher-precedence sources overwrite scalar fields when present
|
||||
* (history < lifecycle < persisted < live), while the `sources` array
|
||||
* always accumulates every contributing view. A "meaningfulness floor" drops
|
||||
* noise (bare lifecycle/mux-only rows with no name and no first prompt).
|
||||
*
|
||||
* PURE: no fs/IO and no node imports. All IO happens in the route that feeds
|
||||
* this module its inputs, which keeps the merge/sort/filter behavior unit-testable.
|
||||
*/
|
||||
|
||||
export type UnifiedSessionItem = {
|
||||
sessionId: string;
|
||||
name?: string;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
isWorking?: boolean;
|
||||
workingDir?: string;
|
||||
createdAt?: number;
|
||||
lastActivityAt?: number;
|
||||
claudeSessionId?: string;
|
||||
firstPrompt?: string;
|
||||
/** Most recent user prompt from the transcript (COD-145), parallel to firstPrompt. */
|
||||
lastPrompt?: string;
|
||||
sizeBytes?: number;
|
||||
projectKey?: string;
|
||||
remote?: boolean;
|
||||
/** Pinned to the top of the session manager list (COD-139). */
|
||||
pinned?: boolean;
|
||||
/** When the session was pinned (epoch ms) — orders the pinned group desc. */
|
||||
pinnedAt?: number;
|
||||
sources: string[];
|
||||
stats?: { memoryMB: number; cpuPercent: number };
|
||||
};
|
||||
|
||||
/** Live in-memory session view (subset of `Session.toState()`). */
|
||||
export type LiveSessionInput = {
|
||||
id: string;
|
||||
name?: string;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
isWorking?: boolean;
|
||||
workingDir?: string;
|
||||
createdAt?: number;
|
||||
lastActivityAt?: number;
|
||||
claudeSessionId?: string;
|
||||
pinned?: boolean;
|
||||
pinnedAt?: number;
|
||||
};
|
||||
|
||||
/** Persisted session view (subset of `SessionState`). */
|
||||
export type PersistedSessionInput = {
|
||||
id: string;
|
||||
name?: string;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
workingDir?: string;
|
||||
createdAt?: number;
|
||||
lastActivityAt?: number;
|
||||
/** Claude conversation ID this session resumes (`SessionState.resumeSessionId`). */
|
||||
claudeSessionId?: string;
|
||||
pinned?: boolean;
|
||||
pinnedAt?: number;
|
||||
};
|
||||
|
||||
/** Lifecycle audit-log view. Entries are expected NEWEST-first (the order `SessionLifecycleLog.query()` returns). */
|
||||
export type LifecycleInput = {
|
||||
sessionId: string;
|
||||
name?: string;
|
||||
mode?: string;
|
||||
ts: number;
|
||||
event?: string;
|
||||
};
|
||||
|
||||
/** Transcript-history view (one `.jsonl` per session). */
|
||||
export type HistoryInput = {
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
/** Most recent user prompt from the transcript (COD-145). */
|
||||
lastPrompt?: string;
|
||||
projectKey?: string;
|
||||
};
|
||||
|
||||
/** Mux process-stat view. */
|
||||
export type MuxStatInput = {
|
||||
sessionId: string;
|
||||
muxName?: string;
|
||||
mode?: string;
|
||||
stats?: { memoryMB: number; cpuPercent: number };
|
||||
remote?: boolean;
|
||||
};
|
||||
|
||||
export type UnifiedSources = {
|
||||
live?: LiveSessionInput[];
|
||||
persisted?: PersistedSessionInput[];
|
||||
lifecycle?: LifecycleInput[];
|
||||
history?: HistoryInput[];
|
||||
mux?: MuxStatInput[];
|
||||
};
|
||||
|
||||
/** Push a source tag onto an item exactly once. */
|
||||
function addSource(item: UnifiedSessionItem, source: string): void {
|
||||
if (!item.sources.includes(source)) item.sources.push(source);
|
||||
}
|
||||
|
||||
/** Get-or-create the accumulator item for a sessionId. */
|
||||
function ensureItem(map: Map<string, UnifiedSessionItem>, sessionId: string): UnifiedSessionItem {
|
||||
let item = map.get(sessionId);
|
||||
if (!item) {
|
||||
item = { sessionId, sources: [] };
|
||||
map.set(sessionId, item);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Overwrite a scalar field only when the incoming value is defined. */
|
||||
function overwrite<K extends keyof UnifiedSessionItem>(
|
||||
item: UnifiedSessionItem,
|
||||
key: K,
|
||||
value: UnifiedSessionItem[K] | undefined
|
||||
): void {
|
||||
if (value !== undefined) item[key] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge all source views into one list, applying precedence
|
||||
* (history → lifecycle → persisted → live) and the meaningfulness floor.
|
||||
*/
|
||||
export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionItem[] {
|
||||
const map = new Map<string, UnifiedSessionItem>();
|
||||
|
||||
// Alias map: Claude conversation UUID → owning Codeman session id. Resumed
|
||||
// (claudeSessionId = resumeSessionId != id) and /clear-respawned sessions
|
||||
// would otherwise surface twice — once as a live/persisted row and once as a
|
||||
// separate history-only row keyed by the conversation UUID. Live wins over
|
||||
// persisted on conflicting entries (registered last).
|
||||
const aliasToOwner = new Map<string, string>();
|
||||
for (const p of sources.persisted ?? []) {
|
||||
if (p.claudeSessionId !== undefined && p.claudeSessionId !== p.id) aliasToOwner.set(p.claudeSessionId, p.id);
|
||||
}
|
||||
for (const v of sources.live ?? []) {
|
||||
if (v.claudeSessionId !== undefined && v.claudeSessionId !== v.id) aliasToOwner.set(v.claudeSessionId, v.id);
|
||||
}
|
||||
const resolveId = (sessionId: string): string => aliasToOwner.get(sessionId) ?? sessionId;
|
||||
|
||||
// 1) history (lowest precedence; keys resolve through the alias map)
|
||||
for (const h of sources.history ?? []) {
|
||||
const item = ensureItem(map, resolveId(h.sessionId));
|
||||
addSource(item, 'history');
|
||||
overwrite(item, 'workingDir', h.workingDir);
|
||||
overwrite(item, 'sizeBytes', h.sizeBytes);
|
||||
overwrite(item, 'firstPrompt', h.firstPrompt);
|
||||
overwrite(item, 'lastPrompt', h.lastPrompt);
|
||||
overwrite(item, 'projectKey', h.projectKey);
|
||||
const ms = Date.parse(h.lastModified);
|
||||
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
|
||||
}
|
||||
|
||||
// 2) lifecycle — entries arrive NEWEST-first, so first-seen wins for
|
||||
// name/mode (mirrors the lastActivityAt guard); unconditional overwrites
|
||||
// would leave the OLDEST entry in the window (stale name/mode) standing.
|
||||
for (const l of sources.lifecycle ?? []) {
|
||||
const item = ensureItem(map, resolveId(l.sessionId));
|
||||
addSource(item, 'lifecycle');
|
||||
if (item.name === undefined) overwrite(item, 'name', l.name);
|
||||
if (item.mode === undefined) overwrite(item, 'mode', l.mode);
|
||||
if (item.lastActivityAt === undefined && typeof l.ts === 'number') item.lastActivityAt = l.ts;
|
||||
}
|
||||
|
||||
// 3) persisted
|
||||
for (const p of sources.persisted ?? []) {
|
||||
const item = ensureItem(map, p.id);
|
||||
addSource(item, 'persisted');
|
||||
overwrite(item, 'name', p.name);
|
||||
overwrite(item, 'mode', p.mode);
|
||||
overwrite(item, 'status', p.status);
|
||||
overwrite(item, 'workingDir', p.workingDir);
|
||||
overwrite(item, 'createdAt', p.createdAt);
|
||||
overwrite(item, 'lastActivityAt', p.lastActivityAt);
|
||||
overwrite(item, 'pinned', p.pinned);
|
||||
overwrite(item, 'pinnedAt', p.pinnedAt);
|
||||
}
|
||||
|
||||
// 4) live (highest precedence)
|
||||
for (const v of sources.live ?? []) {
|
||||
const item = ensureItem(map, v.id);
|
||||
addSource(item, 'live');
|
||||
overwrite(item, 'name', v.name);
|
||||
overwrite(item, 'mode', v.mode);
|
||||
overwrite(item, 'status', v.status);
|
||||
overwrite(item, 'isWorking', v.isWorking);
|
||||
overwrite(item, 'workingDir', v.workingDir);
|
||||
overwrite(item, 'createdAt', v.createdAt);
|
||||
overwrite(item, 'lastActivityAt', v.lastActivityAt);
|
||||
overwrite(item, 'claudeSessionId', v.claudeSessionId);
|
||||
overwrite(item, 'pinned', v.pinned);
|
||||
overwrite(item, 'pinnedAt', v.pinnedAt);
|
||||
}
|
||||
|
||||
// 5) mux stats + remote flag (create item if mux-only)
|
||||
for (const m of sources.mux ?? []) {
|
||||
const item = ensureItem(map, m.sessionId);
|
||||
addSource(item, 'mux');
|
||||
overwrite(item, 'mode', m.mode);
|
||||
if (m.stats) item.stats = m.stats;
|
||||
if (m.remote !== undefined) item.remote = m.remote;
|
||||
}
|
||||
|
||||
// firstPrompt backfill (COD-140): the only source that sets firstPrompt is the
|
||||
// transcript-history view, keyed by the Claude transcript file's UUID. A live/persisted
|
||||
// row keyed by its Codeman id only inherits firstPrompt when that id happens to equal an
|
||||
// on-disk transcript UUID. When it doesn't (stale/wrong claudeSessionId, post-/clear new
|
||||
// uuid, resumed/attached/worktree session, transcript not yet flushed), the row shows
|
||||
// "(no prompt captured)" even though a real transcript for that working dir exists under a
|
||||
// different UUID. Backfill from the already-passed history: first try the claudeSessionId
|
||||
// join, then the newest transcript in the same workingDir. Never overwrite a non-empty
|
||||
// firstPrompt (so rows keyed to their own transcript are untouched).
|
||||
const firstPromptByUuid = new Map<string, string>();
|
||||
const firstPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
|
||||
// COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite).
|
||||
const lastPromptByUuid = new Map<string, string>();
|
||||
const lastPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
|
||||
for (const h of sources.history ?? []) {
|
||||
const ms = Date.parse(h.lastModified);
|
||||
const ts = Number.isNaN(ms) ? -Infinity : ms;
|
||||
if (h.firstPrompt) {
|
||||
firstPromptByUuid.set(h.sessionId, h.firstPrompt);
|
||||
if (h.workingDir) {
|
||||
const existing = firstPromptByWorkingDir.get(h.workingDir);
|
||||
if (!existing || ts > existing.ms) {
|
||||
firstPromptByWorkingDir.set(h.workingDir, { prompt: h.firstPrompt, ms: ts });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (h.lastPrompt) {
|
||||
lastPromptByUuid.set(h.sessionId, h.lastPrompt);
|
||||
if (h.workingDir) {
|
||||
const existing = lastPromptByWorkingDir.get(h.workingDir);
|
||||
if (!existing || ts > existing.ms) {
|
||||
lastPromptByWorkingDir.set(h.workingDir, { prompt: h.lastPrompt, ms: ts });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of map.values()) {
|
||||
if (!item.firstPrompt) {
|
||||
// never overwrite an existing non-empty prompt
|
||||
const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined;
|
||||
if (byUuid) {
|
||||
item.firstPrompt = byUuid;
|
||||
} else if (item.workingDir) {
|
||||
const byDir = firstPromptByWorkingDir.get(item.workingDir);
|
||||
if (byDir) item.firstPrompt = byDir.prompt;
|
||||
}
|
||||
}
|
||||
if (!item.lastPrompt) {
|
||||
const byUuid = item.claudeSessionId ? lastPromptByUuid.get(item.claudeSessionId) : undefined;
|
||||
if (byUuid) {
|
||||
item.lastPrompt = byUuid;
|
||||
} else if (item.workingDir) {
|
||||
const byDir = lastPromptByWorkingDir.get(item.workingDir);
|
||||
if (byDir) item.lastPrompt = byDir.prompt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.
|
||||
const kept: UnifiedSessionItem[] = [];
|
||||
for (const item of map.values()) {
|
||||
const isReal =
|
||||
item.sources.includes('live') ||
|
||||
item.sources.includes('persisted') ||
|
||||
item.sources.includes('history') ||
|
||||
(item.firstPrompt !== undefined && item.firstPrompt !== '');
|
||||
if (isReal) kept.push(item);
|
||||
}
|
||||
|
||||
// Stable sort (COD-139): pinned group first (pinnedAt desc, most-recently-pinned
|
||||
// first), then unpinned by lastActivityAt desc (undefined last), createdAt desc,
|
||||
// sessionId asc.
|
||||
kept.sort((a, b) => {
|
||||
const pa = a.pinned === true;
|
||||
const pb = b.pinned === true;
|
||||
if (pa !== pb) return pa ? -1 : 1; // pinned floats above unpinned
|
||||
if (pa && pb) {
|
||||
// Both pinned: most-recently-pinned first (undefined pinnedAt sorts last).
|
||||
const ta = a.pinnedAt;
|
||||
const tb = b.pinnedAt;
|
||||
if (ta !== tb) {
|
||||
if (ta === undefined) return 1;
|
||||
if (tb === undefined) return -1;
|
||||
return tb - ta;
|
||||
}
|
||||
// tie-break falls through to the activity/createdAt/id rules below.
|
||||
}
|
||||
const la = a.lastActivityAt;
|
||||
const lb = b.lastActivityAt;
|
||||
if (la !== lb) {
|
||||
if (la === undefined) return 1;
|
||||
if (lb === undefined) return -1;
|
||||
return lb - la;
|
||||
}
|
||||
const ca = a.createdAt;
|
||||
const cb = b.createdAt;
|
||||
if (ca !== cb) {
|
||||
if (ca === undefined) return 1;
|
||||
if (cb === undefined) return -1;
|
||||
return cb - ca;
|
||||
}
|
||||
return a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0;
|
||||
});
|
||||
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive substring filter (name + firstPrompt + lastPrompt + workingDir + sessionId)
|
||||
* with offset/limit paging. `total` is the filtered count BEFORE paging.
|
||||
*/
|
||||
export function filterAndPaginate(
|
||||
items: UnifiedSessionItem[],
|
||||
opts: { q?: string; offset?: number; limit?: number }
|
||||
): { sessions: UnifiedSessionItem[]; total: number } {
|
||||
const q = (opts.q ?? '').trim().toLowerCase();
|
||||
const filtered = q
|
||||
? items.filter((it) => {
|
||||
const hay = [it.name, it.firstPrompt, it.lastPrompt, it.workingDir, it.sessionId]
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
})
|
||||
: items;
|
||||
|
||||
const total = filtered.length;
|
||||
const offset = Math.max(0, Math.floor(opts.offset ?? 0));
|
||||
const limit = Math.min(500, Math.max(1, Math.floor(opts.limit ?? 100)));
|
||||
const sessions = filtered.slice(offset, offset + limit);
|
||||
return { sessions, total };
|
||||
}
|
||||
@@ -21,6 +21,8 @@ function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): str
|
||||
switch (claudeMode) {
|
||||
case 'dangerously-skip-permissions':
|
||||
return ['--dangerously-skip-permissions'];
|
||||
case 'auto':
|
||||
return ['--permission-mode', 'auto'];
|
||||
case 'allowedTools':
|
||||
if (allowedTools) {
|
||||
return ['--allowedTools', allowedTools];
|
||||
@@ -80,8 +82,16 @@ export function buildInteractiveArgs(
|
||||
* @param model - Optional model override
|
||||
* @returns Array of CLI arguments
|
||||
*/
|
||||
export function buildPromptArgs(prompt: string, model?: string): string[] {
|
||||
const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json'];
|
||||
export function buildPromptArgs(
|
||||
prompt: string,
|
||||
model?: string,
|
||||
claudeMode: ClaudeMode = 'dangerously-skip-permissions',
|
||||
allowedTools?: string
|
||||
): string[] {
|
||||
// Respect the session's permission mode instead of always skipping, so a
|
||||
// multi-user non-granted user's one-shot runs classifier-guarded (auto) rather
|
||||
// than with full bypass. Defaults to skip-permissions (unchanged single-user).
|
||||
const args = ['-p', '--verbose', ...buildPermissionArgs(claudeMode, allowedTools), '--output-format', 'stream-json'];
|
||||
if (model) {
|
||||
args.push('--model', model);
|
||||
}
|
||||
@@ -102,14 +112,12 @@ export function buildPromptArgs(prompt: string, model?: string): string[] {
|
||||
* @returns Environment variables object for pty.spawn
|
||||
*/
|
||||
export function buildClaudeEnv(sessionId: string): Record<string, string | undefined> {
|
||||
return {
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
PATH: getAugmentedPath(),
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: undefined,
|
||||
CLAUDECODE: undefined,
|
||||
// Inform Claude it's running within Codeman (helps prevent self-termination)
|
||||
CODEMAN_MUX: '1',
|
||||
CODEMAN_SESSION_ID: sessionId,
|
||||
@@ -117,6 +125,11 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
|
||||
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
|
||||
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
|
||||
};
|
||||
// COD-115: `delete`, not `= undefined` — node-pty serializes a present-with-undefined
|
||||
// key as the literal string "KEY=undefined" (see buildMuxAttachEnv below).
|
||||
delete env.COLORTERM;
|
||||
delete env.CLAUDECODE;
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,17 +137,32 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
|
||||
* Lighter than buildClaudeEnv — no PATH augmentation or Codeman vars needed
|
||||
* since the mux session already has those set.
|
||||
*
|
||||
* @param truecolorEnabled - When true, set COLORTERM=truecolor (COD-75 opt-in);
|
||||
* otherwise leave COLORTERM unset. Mirrors buildEnvExports() so both paths agree.
|
||||
* @returns Environment variables object for pty.spawn
|
||||
*/
|
||||
export function buildMuxAttachEnv(): Record<string, string | undefined> {
|
||||
return {
|
||||
export function buildMuxAttachEnv(truecolorEnabled?: boolean): Record<string, string | undefined> {
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: undefined,
|
||||
CLAUDECODE: undefined,
|
||||
};
|
||||
// COD-115: keys to UNSET must be `delete`d, NOT set to `undefined`. On a
|
||||
// `{...process.env}` spread the key stays present with value undefined, and node-pty
|
||||
// serializes it as the literal string "TMUX=undefined" — a non-empty value that still
|
||||
// trips tmux's nesting guard, killing the attach-bridge PTY (exit 1 → respawn loop).
|
||||
// The server can be launched from inside tmux; attach clients must never inherit that
|
||||
// parent tmux context. (Same fix the working create path uses in tmux-manager.ts.)
|
||||
delete env.TMUX;
|
||||
delete env.TMUX_PANE;
|
||||
delete env.CLAUDECODE;
|
||||
if (truecolorEnabled) {
|
||||
env.COLORTERM = 'truecolor';
|
||||
} else {
|
||||
delete env.COLORTERM; // COD-75: unset for non-truecolor (was `: undefined`, same node-pty quirk)
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @fileoverview Pure helpers for the global session tab-order (COD-131).
|
||||
*
|
||||
* Tab order (drag-and-drop reorder + Ctrl+Shift+{/}) is persisted server-side
|
||||
* so it follows the user across devices. The server is authoritative; the
|
||||
* browser's localStorage (`codeman-session-order`) is the offline fallback.
|
||||
*
|
||||
* These helpers are pure (no IO) so they can be unit-tested in isolation and
|
||||
* reused by both the PUT /api/session-order route and the StateStore accessor.
|
||||
*
|
||||
* - `normalizeSessionOrder` coerces arbitrary input into a clean string[]
|
||||
* (non-empty strings only, deduped with first occurrence winning).
|
||||
* - `mergeSessionOrder` lets the pushing device's order win, while preserving
|
||||
* any server-only ids the pushing device didn't know about — they fall to the
|
||||
* END in their existing relative order, never dropped.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Coerce arbitrary input into a clean ordered list of session ids:
|
||||
* keep only non-empty strings and dedup (first occurrence wins).
|
||||
*
|
||||
* @param order - unknown input (expected to be a string[], but defensive)
|
||||
* @returns a normalized string[] (empty array for non-array / all-junk input)
|
||||
*/
|
||||
export function normalizeSessionOrder(order: unknown): string[] {
|
||||
if (!Array.isArray(order)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const entry of order) {
|
||||
if (typeof entry !== 'string' || entry.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(entry)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(entry);
|
||||
result.push(entry);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an incoming order from a pushing device with the existing server order.
|
||||
*
|
||||
* The incoming order wins; any ids present in `existing` but NOT in `incoming`
|
||||
* are appended at the END, preserving their relative order. This is the
|
||||
* "server-only ids the pushing device didn't know about fall to the end, never
|
||||
* dropped" rule.
|
||||
*
|
||||
* Both arguments are normalized first, so callers may pass raw input safely.
|
||||
*
|
||||
* @param incoming - the order the pushing device wants
|
||||
* @param existing - the current server-side order
|
||||
* @returns the merged, normalized order
|
||||
*/
|
||||
export function mergeSessionOrder(incoming: string[], existing: string[]): string[] {
|
||||
const normalizedIncoming = normalizeSessionOrder(incoming);
|
||||
const incomingSet = new Set(normalizedIncoming);
|
||||
const merged = [...normalizedIncoming];
|
||||
for (const id of normalizeSessionOrder(existing)) {
|
||||
if (!incomingSet.has(id)) {
|
||||
merged.push(id);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @fileoverview Circuit breaker bounding repeated non-zero interactive-PTY exits (COD-118).
|
||||
*
|
||||
* Defense-in-depth after COD-115: if the interactive PTY exits non-zero repeatedly,
|
||||
* external recovery/reconnect paths recreate it indefinitely (COD-115 observed 114
|
||||
* `exited with code: 1` events + orphan sessions). This breaker tracks recent
|
||||
* non-zero exits within a sliding window and "trips" once they exceed a threshold,
|
||||
* so the Session can refuse to respawn and surface an error state instead of looping.
|
||||
*
|
||||
* Design notes:
|
||||
* - PURE + dependency-free. Time is INJECTED (`nowMs` passed to `recordExit`); the
|
||||
* breaker never calls `Date.now()` itself, so trip/window logic is deterministically
|
||||
* unit-testable with no real timers.
|
||||
* - A clean (exit code 0) exit resets the counter — a session that exited normally is
|
||||
* not on a crash-loop. (It does NOT clear an already-tripped breaker; only an explicit
|
||||
* `reset()` — e.g. a user-initiated restart — does that.)
|
||||
* - Once tripped, stays tripped until `reset()`.
|
||||
*
|
||||
* @consumedby session (instantiates one per session; records exits in the interactive
|
||||
* PTY `onExit` handler; gates `startInteractive()` when tripped; `reset()` on restart)
|
||||
* @module session-pty-exit-breaker
|
||||
*/
|
||||
|
||||
/** Non-zero interactive-PTY exits within the window required to trip the breaker. */
|
||||
export const DEFAULT_BREAKER_THRESHOLD = 5;
|
||||
|
||||
/** Sliding window (ms) over which non-zero exits accumulate toward the threshold. */
|
||||
export const DEFAULT_BREAKER_WINDOW_MS = 10_000;
|
||||
|
||||
export interface InteractivePtyExitBreakerOptions {
|
||||
/** Trip after this many non-zero exits within `windowMs` (default 5). */
|
||||
threshold?: number;
|
||||
/** Sliding window length in ms (default 10_000). */
|
||||
windowMs?: number;
|
||||
}
|
||||
|
||||
export interface RecordExitResult {
|
||||
/** True once the breaker has tripped (stays true until `reset()`). */
|
||||
tripped: boolean;
|
||||
/** Number of non-zero exits currently inside the window. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sliding-window counter that trips on rapid repeated non-zero exits.
|
||||
*
|
||||
* 5 within 10s safely clears normal usage (a single exit, an intentional restart)
|
||||
* while tripping fast on a real loop — COD-115 saw 114 exits, far above 5.
|
||||
*/
|
||||
export class InteractivePtyExitBreaker {
|
||||
private readonly _threshold: number;
|
||||
private readonly _windowMs: number;
|
||||
|
||||
/** Timestamps (ms, injected) of recent non-zero exits, oldest first. */
|
||||
private _exitTimes: number[] = [];
|
||||
|
||||
private _tripped = false;
|
||||
|
||||
constructor(opts: InteractivePtyExitBreakerOptions = {}) {
|
||||
this._threshold = opts.threshold ?? DEFAULT_BREAKER_THRESHOLD;
|
||||
this._windowMs = opts.windowMs ?? DEFAULT_BREAKER_WINDOW_MS;
|
||||
}
|
||||
|
||||
/** Whether the breaker has tripped (respawn should be blocked). */
|
||||
get tripped(): boolean {
|
||||
return this._tripped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a PTY exit. A zero (clean) exit resets the non-zero counter; a non-zero
|
||||
* exit is added to the window, stale entries are evicted, and the breaker trips
|
||||
* once the in-window count reaches the threshold.
|
||||
*
|
||||
* @param exitCode the PTY exit code (0 = clean)
|
||||
* @param nowMs injected current time in ms (never read from a real clock)
|
||||
*/
|
||||
recordExit(exitCode: number, nowMs: number): RecordExitResult {
|
||||
if (exitCode === 0) {
|
||||
// Clean exit: a normal stop, not a crash-loop. Clear accumulated non-zero
|
||||
// exits. Does NOT un-trip an already-tripped breaker (only reset() does).
|
||||
this._exitTimes = [];
|
||||
return { tripped: this._tripped, count: 0 };
|
||||
}
|
||||
|
||||
// Evict exits strictly older than the window, then record this one.
|
||||
const cutoff = nowMs - this._windowMs;
|
||||
this._exitTimes = this._exitTimes.filter((t) => t > cutoff);
|
||||
this._exitTimes.push(nowMs);
|
||||
|
||||
if (this._exitTimes.length >= this._threshold) {
|
||||
this._tripped = true;
|
||||
}
|
||||
|
||||
return { tripped: this._tripped, count: this._exitTimes.length };
|
||||
}
|
||||
|
||||
/** Clear the tripped state and the non-zero counter (e.g. on intentional restart). */
|
||||
reset(): void {
|
||||
this._exitTimes = [];
|
||||
this._tripped = false;
|
||||
}
|
||||
}
|
||||
+411
-44
@@ -48,7 +48,11 @@ import {
|
||||
type OpenCodeConfig,
|
||||
type CodexConfig,
|
||||
type EffortLevel,
|
||||
type GeminiConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
} from './types.js';
|
||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||
import { RalphTracker } from './ralph-tracker.js';
|
||||
@@ -60,6 +64,7 @@ import {
|
||||
SPINNER_PATTERN,
|
||||
MAX_SESSION_TOKENS,
|
||||
execPattern,
|
||||
getClaudeCliVersion,
|
||||
} from './utils/index.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_SIZE,
|
||||
@@ -69,6 +74,7 @@ import {
|
||||
MAX_MESSAGES,
|
||||
MAX_LINE_BUFFER_SIZE,
|
||||
} from './config/buffer-limits.js';
|
||||
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import {
|
||||
buildInteractiveArgs,
|
||||
@@ -80,7 +86,8 @@ import {
|
||||
import { SessionAutoOps } from './session-auto-ops.js';
|
||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||
import { SessionTaskCache } from './session-task-cache.js';
|
||||
import { parseAttachmentMagicLinks } from './attachment-magic.js';
|
||||
import { InteractivePtyExitBreaker } from './session-pty-exit-breaker.js';
|
||||
import { parseTerminalAttachmentRequests } from './attachment-magic.js';
|
||||
import {
|
||||
sanitizeAttachmentHistory,
|
||||
upsertAttachmentHistory as upsertAttachmentHistoryList,
|
||||
@@ -133,7 +140,38 @@ const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
||||
|
||||
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
|
||||
export function isExternalCliMode(mode: SessionMode): boolean {
|
||||
return mode === 'opencode' || mode === 'codex';
|
||||
return mode === 'opencode' || mode === 'codex' || mode === 'gemini';
|
||||
}
|
||||
|
||||
function getModeLabel(mode: SessionMode): string {
|
||||
switch (mode) {
|
||||
case 'opencode':
|
||||
return 'OpenCode';
|
||||
case 'codex':
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'shell':
|
||||
return 'Shell';
|
||||
case 'claude':
|
||||
return 'Claude';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modes whose TUI emits alt-screen / scrollback-erase / mouse-tracking sequences
|
||||
* that we strip so the browser keeps everything in the main buffer with scrollback
|
||||
* reachable (the strip runs on both the live stream and the buffer replay).
|
||||
*
|
||||
* Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
|
||||
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
|
||||
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
|
||||
* vim/less/htop legitimately need the alt screen) and `opencode` (renders its own
|
||||
* TUI that may rely on it). Keep parity with the replay-side strip in
|
||||
* session-routes.ts.
|
||||
*/
|
||||
export function isAltScreenStripMode(mode: SessionMode): boolean {
|
||||
return mode === 'codex' || mode === 'claude' || mode === 'gemini';
|
||||
}
|
||||
|
||||
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
||||
@@ -142,6 +180,8 @@ export function isExternalCliMode(mode: SessionMode): boolean {
|
||||
const DEFAULT_PTY_COLS = 120;
|
||||
const DEFAULT_PTY_ROWS = 40;
|
||||
const TMUX_DISPLAY_TIMEOUT_MS = 2000;
|
||||
/** Delay before the in-container Claude CLI version probe (lets the container start). */
|
||||
const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000;
|
||||
|
||||
/**
|
||||
* Ask tmux for the current window geometry of `muxName` so a re-attaching PTY
|
||||
@@ -176,6 +216,12 @@ export function queryTmuxWindowSize(muxName: string, socket: string): { cols: nu
|
||||
return { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS };
|
||||
}
|
||||
|
||||
export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote, docker?: SessionDocker): string {
|
||||
// Remote and docker sessions run the CLI elsewhere (ssh / docker exec); the LOCAL
|
||||
// wrapper pane never needs the workspace as its cwd, so launch it in /tmp.
|
||||
return remote || docker ? '/tmp' : workingDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a JSON message from Claude CLI's stream-json output format.
|
||||
* Messages are newline-delimited JSON objects parsed from PTY output.
|
||||
@@ -255,6 +301,13 @@ export class Session extends EventEmitter {
|
||||
private _pid: number | null = null;
|
||||
private _status: SessionStatus = 'idle';
|
||||
private _currentTaskId: string | null = null;
|
||||
|
||||
// COD-118: bound repeated non-zero interactive-PTY exits. Recorded in the
|
||||
// interactive PTY onExit handler; when it trips, the session flips to 'error'
|
||||
// and startInteractive() refuses to respawn until an explicit user restart
|
||||
// calls resetRespawnBreaker(). Defense-in-depth over the COD-115 crash-loop.
|
||||
private readonly _ptyExitBreaker = new InteractivePtyExitBreaker();
|
||||
private _respawnBlocked = false;
|
||||
// Use BufferAccumulator for hot-path buffers to reduce GC pressure
|
||||
private _terminalBuffer = new BufferAccumulator(MAX_TERMINAL_BUFFER_SIZE, TERMINAL_BUFFER_TRIM_SIZE);
|
||||
private _textOutput = new BufferAccumulator(MAX_TEXT_OUTPUT_SIZE, TEXT_OUTPUT_TRIM_SIZE);
|
||||
@@ -265,9 +318,10 @@ export class Session extends EventEmitter {
|
||||
private _messages: ClaudeMessage[] = [];
|
||||
private _lineBuffer: string = '';
|
||||
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
|
||||
// Codex only: trailing partial CSI held back so sequences split across PTY
|
||||
// chunks can't slip past the alt-screen/scrollback strip (see _handleTerminalOutput)
|
||||
private _codexSeqCarry: string = '';
|
||||
// Alt-screen-strip modes (Codex/Claude): trailing partial CSI held back so
|
||||
// sequences split across PTY chunks can't slip past the alt-screen/scrollback
|
||||
// strip (see _handleTerminalOutput / isAltScreenStripMode)
|
||||
private _altScreenSeqCarry: string = '';
|
||||
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
||||
private rejectPromise: ((reason: Error) => void) | null = null;
|
||||
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
||||
@@ -288,6 +342,11 @@ export class Session extends EventEmitter {
|
||||
// Image watcher setting (per-session toggle)
|
||||
private _imageWatcherEnabled: boolean = false;
|
||||
|
||||
// Pin state (COD-139) — pinned sessions float to the top of the session
|
||||
// manager list, ordered by pinnedAt descending (most-recently-pinned first).
|
||||
private _pinned: boolean = false;
|
||||
private _pinnedAt: number | null = null;
|
||||
|
||||
// Flicker filter setting (per-session toggle, applied on frontend)
|
||||
private _flickerFilterEnabled: boolean = false;
|
||||
|
||||
@@ -335,6 +394,8 @@ export class Session extends EventEmitter {
|
||||
private _openCodeConfig: OpenCodeConfig | undefined;
|
||||
// Codex configuration (only for mode === 'codex')
|
||||
private _codexConfig: CodexConfig | undefined;
|
||||
// Gemini configuration (only for mode === 'gemini')
|
||||
private _geminiConfig: GeminiConfig | undefined;
|
||||
private _resumeSessionId: string | undefined;
|
||||
|
||||
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
||||
@@ -346,6 +407,20 @@ export class Session extends EventEmitter {
|
||||
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
||||
private _effort: EffortLevel | undefined;
|
||||
|
||||
// tmux history-limit (scrollback lines) applied to this session's pane.
|
||||
private readonly _tmuxHistoryLimit: number;
|
||||
|
||||
// Remote execution metadata, present when this session runs over SSH through local tmux.
|
||||
private readonly _remote?: SessionRemote;
|
||||
|
||||
// Docker execution metadata, present when this session runs inside a container via
|
||||
// local tmux + `docker exec`. The container is per-CASE (shared by sibling sessions).
|
||||
private readonly _docker?: SessionDocker;
|
||||
|
||||
// Owning username in multi-user mode (undefined in single-user). Stamped at create
|
||||
// from req.authUser and round-tripped through recovery like _remote/_docker.
|
||||
private _owner?: string;
|
||||
|
||||
// Session color for visual differentiation
|
||||
private _color: import('./types.js').SessionColor = 'default';
|
||||
|
||||
@@ -405,14 +480,24 @@ export class Session extends EventEmitter {
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** Codex configuration (only for mode === 'codex') */
|
||||
codexConfig?: CodexConfig;
|
||||
/** Gemini configuration (only for mode === 'gemini') */
|
||||
geminiConfig?: GeminiConfig;
|
||||
/** Resume a previous Claude conversation (used after server reboot) */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort?: EffortLevel;
|
||||
/** tmux history-limit (scrollback lines) for this session's pane. */
|
||||
tmuxHistoryLimit?: number;
|
||||
/** Restored per-session attachment history. May include server-private external paths. */
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata for sessions launched inside a container via local tmux. */
|
||||
docker?: SessionDocker;
|
||||
/** Owning username (multi-user mode); undefined in single-user. */
|
||||
owner?: string;
|
||||
}
|
||||
) {
|
||||
super();
|
||||
@@ -465,6 +550,11 @@ export class Session extends EventEmitter {
|
||||
this._codexConfig = config.codexConfig;
|
||||
}
|
||||
|
||||
// Apply Gemini configuration
|
||||
if (config.geminiConfig) {
|
||||
this._geminiConfig = config.geminiConfig;
|
||||
}
|
||||
|
||||
// Apply env overrides (exported at spawn, not persisted to disk).
|
||||
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
|
||||
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
|
||||
@@ -479,6 +569,10 @@ export class Session extends EventEmitter {
|
||||
if (config.effort && isEffortLevel(config.effort)) {
|
||||
this._effort = config.effort;
|
||||
}
|
||||
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
|
||||
this._remote = config.remote;
|
||||
this._docker = config.docker;
|
||||
this._owner = config.owner;
|
||||
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
|
||||
this.restoreAttachmentHistory(config.attachmentHistory);
|
||||
}
|
||||
@@ -580,6 +674,21 @@ export class Session extends EventEmitter {
|
||||
return this._claudeSessionId;
|
||||
}
|
||||
|
||||
/** Docker execution metadata when this session runs inside a container, else undefined. */
|
||||
get docker(): SessionDocker | undefined {
|
||||
return this._docker;
|
||||
}
|
||||
|
||||
/** Owning username in multi-user mode, else undefined. */
|
||||
get owner(): string | undefined {
|
||||
return this._owner;
|
||||
}
|
||||
|
||||
/** Set the owning username (used by recovery to restore ownership). */
|
||||
set owner(username: string | undefined) {
|
||||
this._owner = username;
|
||||
}
|
||||
|
||||
// Adopt a Claude conversation ID observed from an external source (e.g. hook
|
||||
// payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so
|
||||
// `_handleJsonMessage` never sees `session_id`; hooks are the only signal
|
||||
@@ -888,6 +997,26 @@ export class Session extends EventEmitter {
|
||||
this._imageWatcherEnabled = enabled;
|
||||
}
|
||||
|
||||
/** Whether this session is pinned to the top of the session manager (COD-139). */
|
||||
get pinned(): boolean {
|
||||
return this._pinned;
|
||||
}
|
||||
|
||||
/** When the session was pinned (epoch ms), or null when unpinned. */
|
||||
get pinnedAt(): number | null {
|
||||
return this._pinnedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set pin state (COD-139). Pinning stamps pinnedAt with now so the pinned
|
||||
* group orders most-recently-pinned first; unpinning clears it. Idempotent:
|
||||
* re-pinning an already-pinned session refreshes its pinnedAt.
|
||||
*/
|
||||
setPinned(pinned: boolean): void {
|
||||
this._pinned = pinned;
|
||||
this._pinnedAt = pinned ? Date.now() : null;
|
||||
}
|
||||
|
||||
get flickerFilterEnabled(): boolean {
|
||||
return this._flickerFilterEnabled;
|
||||
}
|
||||
@@ -938,6 +1067,9 @@ export class Session extends EventEmitter {
|
||||
pid: this.pid,
|
||||
status: this._status,
|
||||
workingDir: this.workingDir,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
currentTaskId: this._currentTaskId,
|
||||
createdAt: this.createdAt,
|
||||
lastActivityAt: this._lastActivityAt,
|
||||
@@ -951,6 +1083,8 @@ export class Session extends EventEmitter {
|
||||
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
||||
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
||||
imageWatcherEnabled: this._imageWatcherEnabled,
|
||||
pinned: this._pinned || undefined,
|
||||
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
|
||||
totalCost: this._totalCost,
|
||||
inputTokens: this._totalInputTokens,
|
||||
outputTokens: this._totalOutputTokens,
|
||||
@@ -969,8 +1103,14 @@ export class Session extends EventEmitter {
|
||||
cliLatestVersion: this._cliLatestVersion || undefined,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
geminiConfig: this._geminiConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
effort: this._effort,
|
||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||
// intent before restarting a crash-looped session. Deliberately NOT restored
|
||||
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
||||
// recovery can re-attach.
|
||||
respawnBlocked: this._respawnBlocked || undefined,
|
||||
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
||||
// envOverrides intentionally NOT on the public SessionState type — they must not
|
||||
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
|
||||
@@ -1121,8 +1261,10 @@ export class Session extends EventEmitter {
|
||||
name: 'xterm-256color',
|
||||
cols: ptyCols,
|
||||
rows: ptyRows,
|
||||
cwd: this.workingDir,
|
||||
env: buildMuxAttachEnv(),
|
||||
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
|
||||
// COD-75: codex/gemini get COLORTERM=truecolor — mirrors buildEnvExports()
|
||||
// in tmux-manager.ts so the attach client and the tmux session agree.
|
||||
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini'),
|
||||
});
|
||||
} catch (spawnErr) {
|
||||
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
||||
@@ -1133,36 +1275,105 @@ export class Session extends EventEmitter {
|
||||
return { isRestored };
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-108 — re-establish a dropped REMOTE session. Triggered by the
|
||||
* `TmuxManager` remote-reconnect watcher (via `remoteSessionDropped`): the
|
||||
* watcher detects a dead remote pane, the session owner reassembles the SAME
|
||||
* `RespawnPaneOptions` used for Claude-idle respawns and calls
|
||||
* `respawnPane()` directly. For a remote session that re-runs
|
||||
* `buildRemoteSessionCommand` (owned → `new-session -A`, non-owned →
|
||||
* `attach`), which idempotently REATTACHES the still-running durable remote
|
||||
* tmux session — scrollback + agent intact (proven COD-104/105).
|
||||
*
|
||||
* Deliberately does NOT route through the Claude-idle respawn-controller —
|
||||
* this is a transport re-establish, not a `/clear`/`/compact` cycle.
|
||||
*
|
||||
* @returns true if the pane was respawned (reattach issued), false otherwise.
|
||||
*/
|
||||
async reattachRemote(): Promise<boolean> {
|
||||
if (!this._remote) return false; // not a remote session
|
||||
if (!this._useMux || !this._mux || !this._muxSession) return false;
|
||||
const mux = this._mux;
|
||||
|
||||
// If tmux lost the whole session (not just a dead pane), there is nothing to
|
||||
// respawn into — a genuine death, leave it for normal recovery/reconcile.
|
||||
if (!mux.muxSessionExists(this._muxSession.muxName)) {
|
||||
console.log('[Session] reattachRemote: mux session gone, skipping:', this._muxSession.muxName);
|
||||
return false;
|
||||
}
|
||||
|
||||
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
|
||||
if (!newPid) {
|
||||
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
|
||||
return false;
|
||||
}
|
||||
console.log('[Session] reattachRemote: reattached remote session', this._muxSession.muxName, 'pid', newPid);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the {@link RespawnPaneOptions} for this session. Single source of
|
||||
* truth shared by interactive start, shell start (via their inline copies),
|
||||
* and {@link reattachRemote} so the remote reattach path can never drift from
|
||||
* the spawn path.
|
||||
*/
|
||||
private _buildRespawnPaneOptions(): import('./mux-interface.js').RespawnPaneOptions {
|
||||
return {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
niceConfig: this._niceConfig,
|
||||
model: this._model,
|
||||
claudeMode: this._claudeMode,
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
geminiConfig: this._geminiConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
};
|
||||
}
|
||||
|
||||
private _handleTerminalOutput(data: string): void {
|
||||
// Codex emits sequences that wipe xterm.js scrollback, plus mouse-tracking
|
||||
// enables that hijack the scroll wheel so the user can't reach scrollback:
|
||||
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
|
||||
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
|
||||
// scrollback. Claude Code does this intermittently (e.g. full-screen pickers /
|
||||
// dialogs), which is why terminal scroll-up "randomly" breaks for Claude
|
||||
// sessions on mobile and desktop until the dialog closes:
|
||||
// - \x1b[?1049h / \x1b[?47h / \x1b[?1047h: switch to the alt buffer (no
|
||||
// scrollback) — \x1b[?...l switches back.
|
||||
// - \x1b[3J: erase saved lines (scrollback). (\x1b[2J / \x1b[J — erase
|
||||
// the visible viewport — are left intact; the TUI repaints those rows.)
|
||||
// - \x1b[?1000h / 1002h / 1003h / 1005h / 1006h / 1007h: mouse-tracking
|
||||
// modes (X10, button-event, any-event, UTF-8, SGR, alt-scroll). Once on,
|
||||
// xterm.js forwards wheel events to codex instead of scrolling the
|
||||
// xterm.js forwards wheel events to the CLI instead of scrolling the
|
||||
// viewport, so the conversation is in scrollback but unreachable.
|
||||
// (Focus events at ?1004 are left alone — codeman uses them for
|
||||
// active-tab detection.)
|
||||
// Strip them at the source so neither the persisted buffer nor the live
|
||||
// SSE/WS stream carries them, keeping everything in the main buffer with
|
||||
// scrollback intact. Codex's cursor-positioned redraws overwrite only the
|
||||
// cells they actually target, so the non-erased rows keep their content.
|
||||
if (this.mode === 'codex') {
|
||||
// scrollback intact. These are controlled TUIs whose cursor-positioned
|
||||
// redraws overwrite only the cells they target, so non-erased rows keep
|
||||
// their content. Gated to Codex/Claude (isAltScreenStripMode) — shell must
|
||||
// keep the alt screen for vim/less/htop.
|
||||
if (isAltScreenStripMode(this.mode)) {
|
||||
// Reassemble sequences split across PTY chunk boundaries first: a chunk
|
||||
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
|
||||
// strip below and leave xterm stuck in the scrollback-less alt buffer
|
||||
// until the next buffer replay. Hold back an incomplete digit-only CSI
|
||||
// tail (≤7 chars — the longest strippable intro is '\x1b[?1049') and
|
||||
// prepend it to the next chunk; complete sequences are never held.
|
||||
data = this._codexSeqCarry + data;
|
||||
this._codexSeqCarry = '';
|
||||
data = this._altScreenSeqCarry + data;
|
||||
this._altScreenSeqCarry = '';
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/);
|
||||
if (splitTail) {
|
||||
this._codexSeqCarry = splitTail[0];
|
||||
this._altScreenSeqCarry = splitTail[0];
|
||||
data = data.slice(0, -splitTail[0].length);
|
||||
if (!data) return;
|
||||
}
|
||||
@@ -1175,18 +1386,26 @@ export class Session extends EventEmitter {
|
||||
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
|
||||
}
|
||||
|
||||
// Scan terminal output for `codeman://attach?path=...` magic links and emit
|
||||
// an attachmentRequested event for each newly-seen absolute path. The web
|
||||
// server turns these into registered attachment cards.
|
||||
const attachmentPaths = parseAttachmentMagicLinks(data);
|
||||
for (const attachmentPath of attachmentPaths) {
|
||||
if (this._attachmentMagicSeen.has(attachmentPath)) continue;
|
||||
this._attachmentMagicSeen.add(attachmentPath);
|
||||
// Scan terminal output for attachment requests. `codeman://attach?...` is an
|
||||
// explicit magic link (all modes); Codex generated images report
|
||||
// `Saved to: file://...` — that scanner (and its relaxed trust policy) is
|
||||
// only enabled for codex-mode sessions. The web server applies the trust
|
||||
// boundary for each request source.
|
||||
const attachmentRequests = parseTerminalAttachmentRequests(data, { codexArtifacts: this.mode === 'codex' });
|
||||
for (const request of attachmentRequests) {
|
||||
const seenKey = `${request.source}:${request.path}`;
|
||||
if (this._attachmentMagicSeen.has(seenKey)) continue;
|
||||
this._attachmentMagicSeen.add(seenKey);
|
||||
if (this._attachmentMagicSeen.size > 200) {
|
||||
const oldest = this._attachmentMagicSeen.values().next().value;
|
||||
if (oldest) this._attachmentMagicSeen.delete(oldest);
|
||||
}
|
||||
this.emit('attachmentRequested', { sessionId: this.id, path: attachmentPath, timestamp: Date.now() });
|
||||
this.emit('attachmentRequested', {
|
||||
sessionId: this.id,
|
||||
path: request.path,
|
||||
source: request.source,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||
@@ -1201,31 +1420,75 @@ export class Session extends EventEmitter {
|
||||
throw new Error('Session already has a running process');
|
||||
}
|
||||
|
||||
// COD-118: if the PTY exit breaker has tripped (repeated non-zero exits in a
|
||||
// short window), refuse to respawn. This is the uniform choke point that stops
|
||||
// automatic recovery/reconnect callers from re-creating a crash-looping PTY.
|
||||
// An explicit user restart clears it via resetRespawnBreaker().
|
||||
if (this._respawnBlocked) {
|
||||
throw new Error(
|
||||
'Respawn blocked: interactive PTY exited non-zero too many times in a short window (circuit breaker tripped). Restart the session to clear it.'
|
||||
);
|
||||
}
|
||||
|
||||
this._resetBuffers();
|
||||
|
||||
const modeLabel = this.mode === 'opencode' ? 'OpenCode' : this.mode === 'codex' ? 'Codex' : 'Claude';
|
||||
const modeLabel = getModeLabel(this.mode);
|
||||
console.log(
|
||||
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||
);
|
||||
|
||||
// Seed the CLI version deterministically for LOCAL Claude sessions. The
|
||||
// banner scrape in parseClaudeCodeInfo() is unreliable — newer Claude Code
|
||||
// builds don't print "Claude Code vX.Y.Z" at startup and resumed sessions
|
||||
// never show it — which left cliVersion undefined and silently disabled
|
||||
// wheel-forwarding to Claude's own transcript (the only route to history in
|
||||
// repaint/alt-screen mode; issue #154). Remote sessions run claude on
|
||||
// another host, so a local probe wouldn't reflect their version — skip them
|
||||
// and let the banner scrape handle those. Cached process-wide, best-effort.
|
||||
if (this.mode === 'claude' && !this._remote && !this._docker && !this._cliVersion) {
|
||||
const probedVersion = getClaudeCliVersion();
|
||||
if (probedVersion) {
|
||||
this._cliVersion = probedVersion;
|
||||
this.emit('cliInfoUpdated', {
|
||||
version: this._cliVersion,
|
||||
model: this._cliModel,
|
||||
accountType: this._cliAccountType,
|
||||
latestVersion: this._cliLatestVersion,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Docker sessions run claude INSIDE the container, so the local probe above
|
||||
// reports the HOST claude (wrong version, and leaving cliVersion undefined
|
||||
// silently disables wheel-forwarding, #154). Probe the IN-CONTAINER version
|
||||
// instead — deferred so the container is up after the mux attach below.
|
||||
if (this.mode === 'claude' && this._docker && !this._cliVersion) {
|
||||
const dockerMeta = this._docker;
|
||||
setTimeout(() => {
|
||||
if (this._isStopped || this._cliVersion) return;
|
||||
void probeDockerCliVersion(dockerMeta, this.mode)
|
||||
.then((version) => {
|
||||
if (!version || this._isStopped || this._cliVersion) return;
|
||||
this._cliVersion = version;
|
||||
this.emit('cliInfoUpdated', {
|
||||
version: this._cliVersion,
|
||||
model: this._cliModel,
|
||||
accountType: this._cliAccountType,
|
||||
latestVersion: this._cliLatestVersion,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
/* best-effort */
|
||||
});
|
||||
}, DOCKER_CLI_VERSION_PROBE_DELAY_MS);
|
||||
}
|
||||
|
||||
// If mux wrapping is enabled, create or attach to a mux session
|
||||
if (this._useMux && this._mux) {
|
||||
try {
|
||||
const { isRestored } = await this._setupOrAttachMuxSession({
|
||||
respawnPaneOptions: {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
niceConfig: this._niceConfig,
|
||||
model: this._model,
|
||||
claudeMode: this._claudeMode,
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
},
|
||||
// Single source of truth shared with reattachRemote() (COD-108).
|
||||
respawnPaneOptions: this._buildRespawnPaneOptions(),
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
@@ -1237,9 +1500,14 @@ export class Session extends EventEmitter {
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
geminiConfig: this._geminiConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
},
|
||||
spawnErrLabel: 'mux attachment',
|
||||
});
|
||||
@@ -1310,6 +1578,10 @@ export class Session extends EventEmitter {
|
||||
if (this.mode === 'codex') {
|
||||
throw new Error('Codex sessions require tmux. Direct PTY fallback is not supported.');
|
||||
}
|
||||
// Gemini sessions require tmux for Gemini/Google auth env injection via setenv
|
||||
if (this.mode === 'gemini') {
|
||||
throw new Error('Gemini sessions require tmux. Direct PTY fallback is not supported.');
|
||||
}
|
||||
try {
|
||||
// Pass --session-id to use the SAME ID as the Codeman session
|
||||
// This ensures subagents can be directly matched to the correct tab
|
||||
@@ -1345,7 +1617,7 @@ export class Session extends EventEmitter {
|
||||
|
||||
// === Auto-accept workspace trust dialog ===
|
||||
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
|
||||
// Codeman sessions always use --dangerously-skip-permissions, so auto-accept.
|
||||
// Codeman sessions run permission-skipping or classifier-guarded (auto) modes, so auto-accept.
|
||||
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
|
||||
this._trustDialogAccepted = true;
|
||||
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
|
||||
@@ -1433,6 +1705,9 @@ export class Session extends EventEmitter {
|
||||
|
||||
this.ptyProcess.onExit(({ exitCode }) => {
|
||||
console.log('[Session] Interactive PTY exited with code:', exitCode);
|
||||
// COD-118: record the exit in the circuit breaker BEFORE status bookkeeping.
|
||||
// A clean (0) exit resets the counter; rapid non-zero repeats trip it.
|
||||
const breakerResult = this._ptyExitBreaker.recordExit(exitCode, Date.now());
|
||||
this.ptyProcess = null;
|
||||
this._pid = null;
|
||||
this._status = 'idle';
|
||||
@@ -1460,10 +1735,38 @@ export class Session extends EventEmitter {
|
||||
if (this._muxSession && this._mux) {
|
||||
this._mux.setAttached(this.id, false);
|
||||
}
|
||||
// COD-118: if the breaker tripped, surface an error state and block the NEXT
|
||||
// respawn so recovery/reconnect callers stop looping. Still emit 'exit' below
|
||||
// for normal cleanup. Cleared by an explicit user restart (resetRespawnBreaker()).
|
||||
if (breakerResult.tripped && !this._respawnBlocked) {
|
||||
this._respawnBlocked = true;
|
||||
this._status = 'error';
|
||||
console.error(
|
||||
`[Session] PTY exit circuit breaker tripped for ${this.id} (${breakerResult.count} non-zero exits within window); blocking respawn.`
|
||||
);
|
||||
this.emit('respawnBreakerTripped', { count: breakerResult.count });
|
||||
}
|
||||
this.emit('exit', exitCode);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the interactive-PTY exit circuit breaker (COD-118).
|
||||
*
|
||||
* Called on an EXPLICIT, user-initiated (re)start so an intentional restart is
|
||||
* never blocked by a prior crash-loop trip. Automatic recovery/reconnect paths
|
||||
* must NOT call this — that's the whole point of the breaker.
|
||||
*/
|
||||
resetRespawnBreaker(): void {
|
||||
this._ptyExitBreaker.reset();
|
||||
this._respawnBlocked = false;
|
||||
}
|
||||
|
||||
/** Whether the interactive-PTY exit circuit breaker is currently tripped (COD-118). */
|
||||
get respawnBlocked(): boolean {
|
||||
return this._respawnBlocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process expensive parsers (ANSI strip, Ralph, bash tool, token, CLI info, task descriptions).
|
||||
* Called on a throttled schedule (every EXPENSIVE_PROCESS_INTERVAL_MS) instead of on every
|
||||
@@ -1573,6 +1876,10 @@ export class Session extends EventEmitter {
|
||||
mode: 'shell',
|
||||
niceConfig: this._niceConfig,
|
||||
envOverrides: this._envOverrides,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
@@ -1581,6 +1888,10 @@ export class Session extends EventEmitter {
|
||||
name: this._name,
|
||||
niceConfig: this._niceConfig,
|
||||
envOverrides: this._envOverrides,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
},
|
||||
spawnErrLabel: 'shell mux attachment',
|
||||
});
|
||||
@@ -1708,7 +2019,7 @@ export class Session extends EventEmitter {
|
||||
model ? `(model: ${model})` : ''
|
||||
);
|
||||
|
||||
const args = buildPromptArgs(prompt, model);
|
||||
const args = buildPromptArgs(prompt, model, this._claudeMode, this._allowedTools);
|
||||
|
||||
try {
|
||||
this.ptyProcess = pty.spawn('claude', args, {
|
||||
@@ -1809,7 +2120,7 @@ export class Session extends EventEmitter {
|
||||
this._errorBuffer = '';
|
||||
this._messages = [];
|
||||
this._lineBuffer = '';
|
||||
this._codexSeqCarry = '';
|
||||
this._altScreenSeqCarry = '';
|
||||
this._lastActivityAt = Date.now();
|
||||
}
|
||||
|
||||
@@ -2187,11 +2498,65 @@ export class Session extends EventEmitter {
|
||||
* ```
|
||||
*/
|
||||
write(data: string): void {
|
||||
this._trackCodexSubmit(data);
|
||||
if (this.ptyProcess) {
|
||||
this.ptyProcess.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Codex thread tracking ─────────────────────────────────────────────
|
||||
// When a codex pane last submitted a message (Enter). The response-viewer
|
||||
// correlates this against ~/.codex/history.jsonl entry timestamps to find
|
||||
// the thread the pane is ACTUALLY on — the only signal that survives
|
||||
// /resume, /new and /fork typed inside the codex TUI itself.
|
||||
private _codexLastSubmitAt = 0;
|
||||
|
||||
get codexLastSubmitAt(): number {
|
||||
return this._codexLastSubmitAt;
|
||||
}
|
||||
|
||||
private _trackCodexSubmit(data: string): void {
|
||||
if (this.mode === 'codex' && (data.includes('\r') || data.includes('\n'))) {
|
||||
this._codexLastSubmitAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-client highest-applied input sequence, for exactly-once input delivery.
|
||||
* Keyed by the web client's stable `clientId`. Bounded so many devices over a
|
||||
* long-lived session can't grow it without limit (insertion order = MRU, so
|
||||
* eviction drops the least-recently-active client).
|
||||
*/
|
||||
private _appliedInputSeq = new Map<string, number>();
|
||||
private static readonly MAX_INPUT_DEDUP_CLIENTS = 256;
|
||||
|
||||
/**
|
||||
* Decide whether an input frame should be applied to the PTY or skipped as a
|
||||
* duplicate redelivery. Returns true exactly once per (clientId, seq): the
|
||||
* first time a seq strictly greater than the client's last-applied is seen.
|
||||
* A redelivery of an already-applied seq (the client never got our ACK and
|
||||
* resent) returns false. Callers should ACK regardless — a duplicate is, from
|
||||
* the client's view, "delivered" — and only `write()` the PTY when this is
|
||||
* true. Relies on the client delivering one client's frames in seq order over
|
||||
* a single ordered stream, so `seq <= last` ⇒ already applied.
|
||||
*
|
||||
* Without this, the client's at-least-once redelivery (needed because a
|
||||
* half-open socket silently drops frames with no error) would type a prompt
|
||||
* twice whenever an ACK is lost after the write landed.
|
||||
*/
|
||||
shouldApplyInput(clientId: string, seq: number): boolean {
|
||||
const last = this._appliedInputSeq.get(clientId);
|
||||
if (last !== undefined && seq <= last) return false;
|
||||
// Re-insert to move this client to the MRU end for fair eviction.
|
||||
if (last !== undefined) this._appliedInputSeq.delete(clientId);
|
||||
this._appliedInputSeq.set(clientId, seq);
|
||||
if (this._appliedInputSeq.size > Session.MAX_INPUT_DEDUP_CLIENTS) {
|
||||
const oldest = this._appliedInputSeq.keys().next().value;
|
||||
if (oldest !== undefined) this._appliedInputSeq.delete(oldest);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends input via the terminal multiplexer's direct input mechanism.
|
||||
*
|
||||
@@ -2209,6 +2574,7 @@ export class Session extends EventEmitter {
|
||||
* ```
|
||||
*/
|
||||
async writeViaMux(data: string): Promise<boolean> {
|
||||
this._trackCodexSubmit(data);
|
||||
if (this._mux && this._muxSession) {
|
||||
return this._mux.sendInput(this.id, data);
|
||||
}
|
||||
@@ -2300,7 +2666,7 @@ export class Session extends EventEmitter {
|
||||
* @param cols - Number of columns (width in characters)
|
||||
* @param rows - Number of rows (height in lines)
|
||||
*/
|
||||
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType } = {}): void {
|
||||
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType; force?: boolean } = {}): void {
|
||||
const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet';
|
||||
if (options.viewportType === 'desktop') {
|
||||
this._lastDesktopDims = { cols, rows };
|
||||
@@ -2313,7 +2679,8 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
this._mobileSizeOverride = true;
|
||||
}
|
||||
if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) {
|
||||
const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows;
|
||||
if (this.ptyProcess && (dimsChanged || options.force)) {
|
||||
this._ptyCols = cols;
|
||||
this._ptyRows = rows;
|
||||
if (this._mux && this._muxSession) {
|
||||
|
||||
@@ -272,6 +272,15 @@ export class StateStore {
|
||||
if (this.state.tokenStats) {
|
||||
parts.push(`"tokenStats":${JSON.stringify(this.state.tokenStats)}`);
|
||||
}
|
||||
if (this.state.cronJobs) {
|
||||
parts.push(`"cronJobs":${JSON.stringify(this.state.cronJobs)}`);
|
||||
}
|
||||
if (this.state.cronJobRuns) {
|
||||
parts.push(`"cronJobRuns":${JSON.stringify(this.state.cronJobRuns)}`);
|
||||
}
|
||||
if (this.state.sessionOrder) {
|
||||
parts.push(`"sessionOrder":${JSON.stringify(this.state.sessionOrder)}`);
|
||||
}
|
||||
|
||||
return `{${parts.join(',')}}`;
|
||||
}
|
||||
@@ -479,6 +488,25 @@ export class StateStore {
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-142: Remove a session's persisted record on kill UNLESS it is pinned.
|
||||
* A pinned session is demoted to a lightweight `stopped` record (pin retained)
|
||||
* so it stays visible in the session-manager pinned group and survives restart.
|
||||
* Unpinned sessions are fully removed (unchanged behavior).
|
||||
* @returns 'preserved' if demoted to stopped+pinned, 'removed' if deleted, 'absent' if no record existed.
|
||||
*/
|
||||
demoteOrRemoveSession(id: string): 'preserved' | 'removed' | 'absent' {
|
||||
const existing = this.state.sessions[id];
|
||||
if (!existing) return 'absent';
|
||||
if (existing.pinned === true) {
|
||||
// Demote in place: keep identity/resume fields + pin, mark stopped, clear live runtime.
|
||||
this.setSession(id, { ...existing, status: 'stopped', pid: null });
|
||||
return 'preserved';
|
||||
}
|
||||
this.removeSession(id);
|
||||
return 'removed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up stale sessions from state that don't have corresponding active sessions.
|
||||
* @param activeSessionIds - Set of currently active session IDs
|
||||
@@ -493,6 +521,7 @@ export class StateStore {
|
||||
|
||||
for (const sessionId of allSessionIds) {
|
||||
if (!activeSessionIds.has(sessionId)) {
|
||||
if (this.state.sessions[sessionId]?.pinned === true) continue; // COD-142: pinned records persist even with no live session
|
||||
const name = this.state.sessions[sessionId]?.name;
|
||||
cleaned.push({ id: sessionId, name });
|
||||
delete this.state.sessions[sessionId];
|
||||
@@ -568,6 +597,51 @@ export class StateStore {
|
||||
this.save();
|
||||
}
|
||||
|
||||
// ========== Cron Job Methods ==========
|
||||
|
||||
/** Returns all scheduled jobs keyed by job ID. */
|
||||
getCronJobs(): Record<string, import('./types/cron.js').CronJob> {
|
||||
if (!this.state.cronJobs) this.state.cronJobs = {};
|
||||
return this.state.cronJobs;
|
||||
}
|
||||
|
||||
/** Returns a scheduled job by ID, or null if not found. */
|
||||
getCronJob(id: string): import('./types/cron.js').CronJob | null {
|
||||
return this.state.cronJobs?.[id] ?? null;
|
||||
}
|
||||
|
||||
/** Sets a scheduled job and triggers a debounced save. */
|
||||
setCronJob(id: string, job: import('./types/cron.js').CronJob): void {
|
||||
if (!this.state.cronJobs) this.state.cronJobs = {};
|
||||
this.state.cronJobs[id] = job;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Removes a scheduled job and triggers a debounced save. */
|
||||
removeCronJob(id: string): void {
|
||||
if (this.state.cronJobs) delete this.state.cronJobs[id];
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Returns all scheduled job runs keyed by run ID. */
|
||||
getCronJobRuns(): Record<string, import('./types/cron.js').CronJobRun> {
|
||||
if (!this.state.cronJobRuns) this.state.cronJobRuns = {};
|
||||
return this.state.cronJobRuns;
|
||||
}
|
||||
|
||||
/** Sets a scheduled job run (history record) and triggers a debounced save. */
|
||||
setCronJobRun(id: string, run: import('./types/cron.js').CronJobRun): void {
|
||||
if (!this.state.cronJobRuns) this.state.cronJobRuns = {};
|
||||
this.state.cronJobRuns[id] = run;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Removes a scheduled job run and triggers a debounced save. */
|
||||
removeCronJobRun(id: string): void {
|
||||
if (this.state.cronJobRuns) delete this.state.cronJobRuns[id];
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Returns the application configuration. */
|
||||
getConfig() {
|
||||
return this.state.config;
|
||||
@@ -579,6 +653,17 @@ export class StateStore {
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Returns the global tab order (ordered sessionIds), [] if unset. COD-131. */
|
||||
getSessionOrder(): string[] {
|
||||
return this.state.sessionOrder ?? [];
|
||||
}
|
||||
|
||||
/** Persists the global tab order (ordered sessionIds) and triggers a debounced save. COD-131. */
|
||||
setSessionOrder(order: string[]): void {
|
||||
this.state.sessionOrder = order;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Resets all state to initial values and saves immediately. */
|
||||
reset(): void {
|
||||
this.state = createInitialState();
|
||||
|
||||
+973
-29
File diff suppressed because it is too large
Load Diff
+51
-8
@@ -43,6 +43,8 @@ interface QrTokenRecord {
|
||||
shortCode: string; // 6 chars base62 (for URL path)
|
||||
createdAt: number; // Date.now()
|
||||
consumed: boolean; // single-use flag
|
||||
/** Multi-user: the user this token logs in when redeemed (absent = rotating global token). */
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/** Rejection-sampled base62 short code — no modulo bias */
|
||||
@@ -378,23 +380,64 @@ export class TunnelManager extends EventEmitter {
|
||||
* Map.get() is hash-based — no timing side-channel from string comparison.
|
||||
*/
|
||||
consumeToken(shortCode: string): boolean {
|
||||
return this.consumeTokenWithIdentity(shortCode).ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like consumeToken, but also returns the bound username for multi-user tokens
|
||||
* (undefined for the rotating global token). Only the identity-less rotating
|
||||
* token triggers an immediate re-rotation (desktop gets a fresh QR); per-user
|
||||
* tokens are on-demand and self-expire.
|
||||
*/
|
||||
consumeTokenWithIdentity(shortCode: string): { ok: boolean; username?: string } {
|
||||
// Global rate limit (across all IPs)
|
||||
if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return false;
|
||||
if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return { ok: false };
|
||||
this.qrAttemptCount++;
|
||||
|
||||
const record = this.qrTokensByCode.get(shortCode);
|
||||
if (!record) return false;
|
||||
if (record.consumed) return false;
|
||||
if (!record) return { ok: false };
|
||||
if (record.consumed) return { ok: false };
|
||||
|
||||
const now = Date.now();
|
||||
if (now - record.createdAt > QR_TOKEN_GRACE_MS) return false;
|
||||
if (now - record.createdAt > QR_TOKEN_GRACE_MS) return { ok: false };
|
||||
|
||||
// Atomic consume (single-threaded JS = no race)
|
||||
record.consumed = true;
|
||||
// Immediately rotate so desktop gets a fresh QR
|
||||
this.rotateToken();
|
||||
this.emit('qrTokenRegenerated');
|
||||
return true;
|
||||
const username = record.username;
|
||||
if (!username) {
|
||||
// Rotating global token — immediately rotate so desktop gets a fresh QR.
|
||||
this.rotateToken();
|
||||
this.emit('qrTokenRegenerated');
|
||||
} else {
|
||||
this.qrTokensByCode.delete(shortCode);
|
||||
}
|
||||
return { ok: true, username };
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-user: mint a single-use token bound to a specific user (on-demand, no
|
||||
* rotation). Evicts expired/consumed tokens first. Returns the short code.
|
||||
*/
|
||||
mintUserToken(username: string): string {
|
||||
const now = Date.now();
|
||||
for (const [code, rec] of this.qrTokensByCode) {
|
||||
if (now - rec.createdAt > QR_TOKEN_GRACE_MS || rec.consumed) this.qrTokensByCode.delete(code);
|
||||
}
|
||||
const record: QrTokenRecord = {
|
||||
token: randomBytes(32).toString('hex'),
|
||||
shortCode: generateShortCode(),
|
||||
createdAt: Date.now(),
|
||||
consumed: false,
|
||||
username,
|
||||
};
|
||||
this.qrTokensByCode.set(record.shortCode, record);
|
||||
return record.shortCode;
|
||||
}
|
||||
|
||||
/** Render a QR SVG for an arbitrary short code (used by per-user minting). */
|
||||
async getQrSvgForCode(tunnelUrl: string, code: string): Promise<string> {
|
||||
const QRCode = await import('qrcode');
|
||||
return QRCode.toString(`${tunnelUrl}/q/${code}`, { type: 'svg', margin: 2, width: 256 });
|
||||
}
|
||||
|
||||
/** Force-regenerate (manual revocation via API) */
|
||||
|
||||
@@ -37,6 +37,16 @@ export enum ApiErrorCode {
|
||||
RATE_LIMITED = 'RATE_LIMITED',
|
||||
/** Operation could not be completed (well-formed but unprocessable) */
|
||||
OPERATION_FAILED = 'OPERATION_FAILED',
|
||||
/** Authenticated but not permitted (e.g. non-admin hitting an admin route) */
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
/** User must change their password before any other action (multi-user) */
|
||||
PASSWORD_CHANGE_REQUIRED = 'PASSWORD_CHANGE_REQUIRED',
|
||||
/** A user with this name already exists (multi-user) */
|
||||
USER_EXISTS = 'USER_EXISTS',
|
||||
/** No user with this name (multi-user) */
|
||||
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||
/** Refusing to demote/disable/delete the last enabled admin (multi-user) */
|
||||
LAST_ADMIN = 'LAST_ADMIN',
|
||||
/** Internal server error */
|
||||
INTERNAL_ERROR = 'INTERNAL_ERROR',
|
||||
}
|
||||
@@ -53,6 +63,11 @@ const ErrorMessages: Record<ApiErrorCode, string> = {
|
||||
[ApiErrorCode.ALREADY_EXISTS]: 'Resource already exists',
|
||||
[ApiErrorCode.RATE_LIMITED]: 'Too many requests',
|
||||
[ApiErrorCode.OPERATION_FAILED]: 'The operation failed',
|
||||
[ApiErrorCode.FORBIDDEN]: 'You do not have permission to perform this action',
|
||||
[ApiErrorCode.PASSWORD_CHANGE_REQUIRED]: 'You must change your password before continuing',
|
||||
[ApiErrorCode.USER_EXISTS]: 'A user with that name already exists',
|
||||
[ApiErrorCode.USER_NOT_FOUND]: 'No such user',
|
||||
[ApiErrorCode.LAST_ADMIN]: 'Cannot remove the last enabled admin',
|
||||
[ApiErrorCode.INTERNAL_ERROR]: 'An internal error occurred',
|
||||
};
|
||||
|
||||
@@ -69,6 +84,11 @@ const ErrorStatus: Record<ApiErrorCode, number> = {
|
||||
[ApiErrorCode.CONFLICT]: 409,
|
||||
[ApiErrorCode.ALREADY_EXISTS]: 409,
|
||||
[ApiErrorCode.OPERATION_FAILED]: 422,
|
||||
[ApiErrorCode.FORBIDDEN]: 403,
|
||||
[ApiErrorCode.PASSWORD_CHANGE_REQUIRED]: 403,
|
||||
[ApiErrorCode.USER_EXISTS]: 409,
|
||||
[ApiErrorCode.USER_NOT_FOUND]: 404,
|
||||
[ApiErrorCode.LAST_ADMIN]: 409,
|
||||
[ApiErrorCode.RATE_LIMITED]: 429,
|
||||
[ApiErrorCode.INTERNAL_ERROR]: 500,
|
||||
};
|
||||
@@ -123,6 +143,25 @@ export interface CaseInfo {
|
||||
path: string;
|
||||
/** Whether CLAUDE.md exists */
|
||||
hasClaudeMd?: boolean;
|
||||
/** Case storage/execution location */
|
||||
location?: 'local' | 'linked-local' | 'remote' | 'docker';
|
||||
/** Whether this is a linked local folder */
|
||||
linked?: boolean;
|
||||
/** Remote case metadata for display and session creation */
|
||||
remote?: {
|
||||
hostId: string;
|
||||
host: string;
|
||||
username: string;
|
||||
path: string;
|
||||
};
|
||||
/** Docker case metadata for display and session creation */
|
||||
docker?: {
|
||||
hostId: string;
|
||||
container: string;
|
||||
image?: string;
|
||||
path: string;
|
||||
network?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Error Handling Utilities ==========
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { SessionState } from './session.js';
|
||||
import type { TaskState } from './task.js';
|
||||
import type { RalphLoopState } from './ralph.js';
|
||||
import type { RespawnConfig } from './respawn.js';
|
||||
import type { CronJob, CronJobRun } from './cron.js';
|
||||
|
||||
// ========== Global Stats Types ==========
|
||||
|
||||
@@ -111,6 +112,12 @@ export interface AppState {
|
||||
tokenStats?: TokenStats;
|
||||
/** Orchestrator Loop state (phased plan execution) */
|
||||
orchestrator?: import('./orchestrator.js').OrchestratorPersistState;
|
||||
/** Cron-style scheduled jobs, keyed by job ID. */
|
||||
cronJobs?: Record<string, CronJob>;
|
||||
/** Scheduled job run history, keyed by run ID. */
|
||||
cronJobRuns?: Record<string, CronJobRun>;
|
||||
/** Global tab order shared across devices (ordered list of sessionIds) — COD-131 */
|
||||
sessionOrder?: string[];
|
||||
}
|
||||
|
||||
// ========== Default Configuration ==========
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @fileoverview Cron Jobs type definitions.
|
||||
*
|
||||
* NOTE: This is intentionally distinct from the existing `ScheduledRun` concept
|
||||
* (see src/web/ports/infra-port.ts), which is a run-now, duration-bounded
|
||||
* autonomous loop. A `CronJob` is a SAVED, NAMED job with a recurring
|
||||
* schedule (once/interval/daily/weekly), enable/disable, next-run calculation,
|
||||
* and a history of `CronJobRun` records. The two do not interact.
|
||||
*
|
||||
* Persisted to `~/.codeman/state.json` via StateStore (see AppState).
|
||||
*/
|
||||
|
||||
import type { SessionMode } from './session.js';
|
||||
|
||||
/** How a job's fire times are computed. */
|
||||
export type ScheduleType = 'once' | 'interval' | 'daily' | 'weekly';
|
||||
|
||||
/** Where the prompt text comes from. */
|
||||
export type PromptMode = 'inline_text' | 'prompt_file_path';
|
||||
|
||||
/** How the prompt is delivered into the session. */
|
||||
export type InputMode = 'paste' | 'typed';
|
||||
|
||||
/** Lifecycle status of a single job execution. */
|
||||
export type CronJobRunStatus = 'created' | 'session_started' | 'prompt_sent' | 'failed' | 'skipped';
|
||||
|
||||
/** What triggered a run. */
|
||||
export type TriggerType = 'scheduled' | 'manual_run_now';
|
||||
|
||||
/** What to do for an AUTOMATIC run when sessions of the same agent already exist. */
|
||||
export type ConcurrencyPolicy = 'warn_only' | 'skip_if_same_agent_running';
|
||||
|
||||
/**
|
||||
* A saved, named cron job.
|
||||
*/
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Owning username in multi-user mode; the job launches as this user. Undefined in single-user. */
|
||||
owner?: string;
|
||||
/** Reuses Codeman's existing session modes; 'shell' covers Terminal/custom. */
|
||||
agentType: SessionMode;
|
||||
workingDir: string;
|
||||
/** Optional custom launch command (only meaningful for 'shell' mode). */
|
||||
launchCommand?: string;
|
||||
|
||||
promptMode: PromptMode;
|
||||
promptText?: string;
|
||||
promptFilePath?: string;
|
||||
inputMode: InputMode;
|
||||
|
||||
scheduleType: ScheduleType;
|
||||
/** once: absolute epoch-ms fire time. */
|
||||
runAt?: number;
|
||||
/** interval: minutes between fires. */
|
||||
intervalMinutes?: number;
|
||||
/** daily: 'HH:MM' (24h, server-local time). */
|
||||
dailyTime?: string;
|
||||
/** weekly: weekdays 0–6 (0=Sunday). */
|
||||
weeklyDays?: number[];
|
||||
/** weekly: 'HH:MM' (24h, server-local time). */
|
||||
weeklyTime?: string;
|
||||
|
||||
enabled: boolean;
|
||||
notes?: string;
|
||||
/** Applies to automatic (scheduled) runs only. Manual Run Now always warns client-side. */
|
||||
concurrencyPolicy: ConcurrencyPolicy;
|
||||
/**
|
||||
* Close the still-open session created by this job's previous run before the
|
||||
* next run launches (via the normal session-cleanup path), so unattended
|
||||
* recurring jobs don't accumulate tabs until the global session cap.
|
||||
* Default true. Ignored for 'once' schedules.
|
||||
*/
|
||||
autoClosePreviousSession?: boolean;
|
||||
|
||||
// ── Bookkeeping (server-maintained) ─────────────────────────────────────
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastRunAt: number | null;
|
||||
nextRunAt: number | null;
|
||||
lastStatus: CronJobRunStatus | null;
|
||||
/** Duplicate-launch guard: identifies the most recent due-time consumed. */
|
||||
lastDueKey: string | null;
|
||||
/** True once a 'once' job has fired (it is also disabled). */
|
||||
completedOnce?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single execution of a cron job (history record).
|
||||
*/
|
||||
export interface CronJobRun {
|
||||
id: string;
|
||||
cronJobId: string;
|
||||
sessionId: string | null;
|
||||
sessionName: string | null;
|
||||
startedAt: number;
|
||||
finishedAt: number | null;
|
||||
status: CronJobRunStatus;
|
||||
errorMessage?: string;
|
||||
triggerType: TriggerType;
|
||||
/** Best-effort deep link to the created session in the web UI. */
|
||||
createdSessionUrl: string | null;
|
||||
}
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
declare module 'heic-decode' {
|
||||
export interface DecodedHeicImage {
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8ClampedArray;
|
||||
}
|
||||
|
||||
/** Handle exposing header-declared dimensions WITHOUT decoding pixels. */
|
||||
export interface HeicImageHandle {
|
||||
width: number;
|
||||
height: number;
|
||||
decode(): Promise<DecodedHeicImage>;
|
||||
}
|
||||
|
||||
export type HeicImageHandles = HeicImageHandle[] & { dispose(): void };
|
||||
|
||||
interface HeicDecode {
|
||||
(input: { buffer: Buffer | Uint8Array }): Promise<DecodedHeicImage>;
|
||||
all(input: { buffer: Buffer | Uint8Array }): Promise<HeicImageHandles>;
|
||||
}
|
||||
|
||||
const decode: HeicDecode;
|
||||
export default decode;
|
||||
}
|
||||
@@ -68,3 +68,5 @@ export * from './plan.js';
|
||||
export * from './orchestrator.js';
|
||||
export * from './update.js';
|
||||
export * from './workflow-run.js';
|
||||
export * from './search.js';
|
||||
export * from './user.js';
|
||||
|
||||
@@ -90,6 +90,10 @@ export interface RalphTrackerState {
|
||||
cycleCount: number;
|
||||
/** Maximum iterations if detected */
|
||||
maxIterations: number | null;
|
||||
/** Max todos retained for this session before FIFO eviction (persisted; default = global cap) */
|
||||
maxTodos?: number;
|
||||
/** Todo auto-expiry in minutes (persisted; default = global TODO_EXPIRY_MS) */
|
||||
todoExpirationMinutes?: number;
|
||||
/** Timestamp of last activity */
|
||||
lastActivity: number;
|
||||
/** Elapsed hours if detected */
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @fileoverview Cross-session federated search types (COD-9).
|
||||
*
|
||||
* Defines the typed shapes for `GET /api/search` — a bounded, in-memory
|
||||
* federated search across three v1 sources: live sessions/cases, run-summary
|
||||
* timeline events, and per-session attachment file paths. Terminal-buffer scans
|
||||
* and any persisted index are explicitly out of scope for v1.
|
||||
*
|
||||
* Key exports:
|
||||
* - SearchSourceType — the federated source kinds, also the group order key.
|
||||
* - SearchResult — a single typed result card (source, session id/name,
|
||||
* timestamp, snippet, jump-to action target).
|
||||
* - SearchJumpTarget — where the frontend should navigate when a card is opened.
|
||||
* - SearchResponseData — grouped result payload returned in the ApiResponse envelope.
|
||||
*
|
||||
* No I/O, no dependencies on other domain modules. The pure search core lives
|
||||
* in `src/search-service.ts`; the route wrapper in `src/web/routes/search-routes.ts`.
|
||||
*/
|
||||
|
||||
/** Federated source kinds. Group/render order is sessions → events → files. */
|
||||
export type SearchSourceType = 'session' | 'event' | 'file';
|
||||
|
||||
/** Where the frontend should jump when a result card is activated. */
|
||||
export interface SearchJumpTarget {
|
||||
/** Kind of navigation target. */
|
||||
kind: 'session' | 'run-summary' | 'file-preview';
|
||||
/** Owning Codeman session id (always present — every result is session-scoped). */
|
||||
sessionId: string;
|
||||
/**
|
||||
* Secondary identifier for the target:
|
||||
* - kind 'run-summary': the run-summary event id
|
||||
* - kind 'file-preview': the attachment history item id
|
||||
* - kind 'session': undefined (the sessionId is sufficient)
|
||||
*/
|
||||
targetId?: string;
|
||||
/**
|
||||
* Workspace-relative path for file-preview targets. Never an absolute path —
|
||||
* server-private external paths are intentionally omitted to avoid leakage.
|
||||
*/
|
||||
relativePath?: string;
|
||||
}
|
||||
|
||||
/** A single typed search result card. */
|
||||
export interface SearchResult {
|
||||
/** Which federated source produced this result. */
|
||||
type: SearchSourceType;
|
||||
/** Owning Codeman session id. */
|
||||
sessionId: string;
|
||||
/** Display name of the owning session / case. */
|
||||
sessionName: string;
|
||||
/** Millisecond timestamp used for recency ranking and display. */
|
||||
timestamp: number;
|
||||
/** Short, already-truncated snippet describing the match. */
|
||||
snippet: string;
|
||||
/** True when the query matched the primary name/path exactly (case-insensitive). */
|
||||
exactMatch: boolean;
|
||||
/** Navigation target for the jump-to action. */
|
||||
jumpTo: SearchJumpTarget;
|
||||
}
|
||||
|
||||
/** A group of results for one source type, in render order. */
|
||||
export interface SearchResultGroup {
|
||||
type: SearchSourceType;
|
||||
results: SearchResult[];
|
||||
}
|
||||
|
||||
/** Payload returned as `data` inside the standard ApiResponse envelope. */
|
||||
export interface SearchResponseData {
|
||||
/** The normalized query that was executed. */
|
||||
query: string;
|
||||
/** Results grouped by source type, ordered sessions → events → files. */
|
||||
groups: SearchResultGroup[];
|
||||
/** Total number of results across all groups (after caps applied). */
|
||||
totalResults: number;
|
||||
/** True if any group or the total was capped (more matches existed). */
|
||||
truncated: boolean;
|
||||
}
|
||||
+248
-4
@@ -8,10 +8,12 @@
|
||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||
* - SessionOutput — captured stdout/stderr/exitCode
|
||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' (which CLI backend)
|
||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools')
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend)
|
||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
|
||||
* - SessionColor — visual differentiation color
|
||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId)
|
||||
* - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession)
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
|
||||
@@ -33,13 +35,228 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
||||
/**
|
||||
* Claude CLI startup permission mode.
|
||||
* - `'dangerously-skip-permissions'`: Bypass all permission prompts (default)
|
||||
* - `'auto'`: Anthropic's classifier-guarded low-prompt mode (`--permission-mode auto`)
|
||||
* - `'normal'`: Standard mode with permission prompts
|
||||
* - `'allowedTools'`: Only allow specific tools (requires allowedTools list)
|
||||
*/
|
||||
export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools';
|
||||
export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
|
||||
|
||||
/** Session mode: which CLI backend a session runs */
|
||||
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex';
|
||||
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini';
|
||||
|
||||
export type RemoteCommandMode = Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
|
||||
|
||||
/**
|
||||
* Advanced SSH connection options shared by RemoteHost and SessionRemote.
|
||||
*
|
||||
* COD-107 — all fields are optional; every field absent reproduces today's
|
||||
* behavior (port-22, default-identity, directly-SSH-able hosts). These describe
|
||||
* HOW Codeman reaches the host (identity, proxy, jump host, arbitrary `-o`),
|
||||
* letting it connect to e.g. a host fronted by a cloudflared SOCKS5 proxy on a
|
||||
* custom port — the same connection `ssh-aa-desktop` makes — without a wrapper.
|
||||
*/
|
||||
export interface RemoteSshOptions {
|
||||
/**
|
||||
* Path to an SSH identity (private key) file — path ONLY, never key bytes.
|
||||
* A leading `~`/`$HOME` is expanded to an absolute path at command-build time
|
||||
* (ssh does not expand `~` in `-i`).
|
||||
*/
|
||||
identityFile?: string;
|
||||
/**
|
||||
* SOCKS5 proxy as `host:port` (e.g. `127.0.0.1:1080`). Expands to
|
||||
* `-o ProxyCommand=nc -X 5 -x <host:port> %h %p` (the cloudflared/SOCKS5 case).
|
||||
*/
|
||||
socksProxy?: string;
|
||||
/** SSH jump host (`[user@]host[:port]`) emitted as `-J <jumpHost>`. */
|
||||
jumpHost?: string;
|
||||
/** Arbitrary additional `-o KEY=VALUE` options (escape hatch). Each `KEY=VALUE`. */
|
||||
extraSshOptions?: string[];
|
||||
}
|
||||
|
||||
export interface RemoteHost extends RemoteSshOptions {
|
||||
id: string;
|
||||
label: string;
|
||||
host: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
commands?: Partial<Record<RemoteCommandMode, string>>;
|
||||
}
|
||||
|
||||
export interface RemoteCase {
|
||||
name: string;
|
||||
type: 'remote';
|
||||
/** Owning username in multi-user mode; absent = legacy/unassigned (admin-only). */
|
||||
owner?: string;
|
||||
hostId: string;
|
||||
remotePath: string;
|
||||
}
|
||||
|
||||
export interface SessionRemote extends RemoteSshOptions {
|
||||
hostId: string;
|
||||
label: string;
|
||||
host: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
remotePath: string;
|
||||
commands?: Partial<Record<RemoteCommandMode, string>>;
|
||||
/**
|
||||
* COD-105 — whether THIS Codeman created the remote tmux session.
|
||||
*
|
||||
* - `true` (default for COD-104 launched sessions): we own the remote session;
|
||||
* an explicit "kill" may propagate a remote `tmux kill-session`.
|
||||
* - `false` (discovered + attached an existing remote session another Codeman
|
||||
* created): closing the local tab must DETACH only — we must NEVER issue a
|
||||
* remote `kill-session`, or we'd nuke work the remote's own Codeman (or
|
||||
* another instance) still relies on. See `killSession()` gate.
|
||||
*
|
||||
* Absent is treated as owned (legacy/COD-104 sessions persisted before this
|
||||
* field existed were all launched by us).
|
||||
*/
|
||||
owned?: boolean;
|
||||
/**
|
||||
* COD-105 — for a NON-owned (discovered + attached) session, the EXISTING
|
||||
* remote tmux session name to `attach -t` (e.g. `codeman-disco1`). It differs
|
||||
* from this Codeman's deterministic `codeman-<id>` name because the remote
|
||||
* session was created elsewhere. Only meaningful when `owned === false`.
|
||||
*/
|
||||
remoteSessionName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — a `codeman-*` tmux session discovered on a remote host's
|
||||
* `tmux -L codeman` socket (may have been created by the remote's own Codeman,
|
||||
* another instance, or this one). Returned by `listRemoteCodemanSessions`.
|
||||
*/
|
||||
export interface RemoteSessionInfo {
|
||||
/** tmux session name (always starts `codeman-`). */
|
||||
name: string;
|
||||
/** Whether at least one client is currently attached to the remote session. */
|
||||
attached: boolean;
|
||||
/** COD-106 — number of clients attached (tmux `session_attached`); >1 = shared. */
|
||||
attachedClients: number;
|
||||
/** tmux `session_created` epoch seconds. */
|
||||
created: number;
|
||||
/** Number of windows in the remote session. */
|
||||
windows: number;
|
||||
}
|
||||
|
||||
// ========== Docker cases (COD-Docker) ==========
|
||||
//
|
||||
// Docker mode is a LOCATION OVERLAY on cases (never a 6th SessionMode), the exact
|
||||
// analog of the remote-SSH feature above: instead of a local tmux pane running
|
||||
// `ssh host` into a durable remote tmux server, a local tmux pane runs
|
||||
// `docker exec -it` into a durable in-container tmux server. The container is
|
||||
// scoped to the CASE (not the session), so multiple sessions can `docker exec`
|
||||
// into the same long-lived container. See `docs/docker-cases-plan.md`.
|
||||
|
||||
/** Which CLI backends a Docker case can run (same set as remote). */
|
||||
export type DockerCommandMode = Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
|
||||
|
||||
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
|
||||
export type DockerEngine = 'docker' | 'podman';
|
||||
|
||||
/**
|
||||
* Container network mode. `host` and any inbound `-p` publish are deliberately
|
||||
* unrepresentable (never in this union, never emitted by the flag builder).
|
||||
* - `bridge`: own netns, NAT egress, no inbound (default — every API CLI needs egress)
|
||||
* - `none`: fully offline sandbox (breaks API CLIs; reserved for `shell`)
|
||||
* - `custom`: a user-defined bridge `codeman-net-<slug>` (future egress-allowlist chokepoint)
|
||||
*/
|
||||
export type DockerNetworkMode = 'bridge' | 'none' | 'custom';
|
||||
|
||||
/** Per-container resource caps. Advisory under non-delegated rootless (see `capsEnforced`). */
|
||||
export interface DockerResourceLimits {
|
||||
/** e.g. '4g' -> --memory 4g --memory-swap 4g (swap==memory: a real OOM cap) */
|
||||
memory?: string;
|
||||
/** e.g. '2' -> --cpus 2 */
|
||||
cpus?: string;
|
||||
/** e.g. 512 -> --pids-limit 512 (fork-bomb guard) */
|
||||
pidsLimit?: number;
|
||||
/** e.g. '4096:8192' -> --ulimit nofile=4096:8192 */
|
||||
nofile?: string;
|
||||
/** e.g. '256m' -> --shm-size (only when a tool needs /dev/shm) */
|
||||
shmSize?: string;
|
||||
}
|
||||
|
||||
/** A reusable Docker engine/image/network/resource profile (mirror of RemoteHost). */
|
||||
export interface DockerHost {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Engine; when absent the availability probe resolves it (docker, else podman). */
|
||||
engine?: DockerEngine;
|
||||
/** Base image ref (built locally by scripts/build-agent-image.mjs, e.g. codeman/agent:base). */
|
||||
image: string;
|
||||
/** Advanced: remote daemon (-H ssh://user@host or a DOCKER_HOST value). */
|
||||
daemonHost?: string;
|
||||
/** Advanced: docker `--context` name. */
|
||||
context?: string;
|
||||
/** Network mode (default 'bridge'). */
|
||||
network?: DockerNetworkMode;
|
||||
/** Custom bridge name when network === 'custom'. */
|
||||
networkName?: string;
|
||||
resources?: DockerResourceLimits;
|
||||
/** GPU allocation, e.g. 'all' / '1' / 'device=0,1' -> `--gpus <value>` (needs the NVIDIA container toolkit). */
|
||||
gpus?: string;
|
||||
/** true (default) = convenient: bind-mount host cred dirs RW. false = sealed (blocks full-image export). */
|
||||
mountCredentials?: boolean;
|
||||
/** true (default) = wire in-container hooks (host-gateway callback + workspace scaffold). */
|
||||
hooksEnabled?: boolean;
|
||||
/** true (default) = a relaunch resumes the last conversation from the bind-mounted transcript. */
|
||||
resumeOnStart?: boolean;
|
||||
/** Per-mode command overrides (mirror RemoteHost.commands). */
|
||||
commands?: Partial<Record<DockerCommandMode, string>>;
|
||||
/** Escape hatch: extra `docker create` args (validated like extraSshOptions). */
|
||||
extraCreateArgs?: string[];
|
||||
/** Escape hatch: extra `docker exec` args. */
|
||||
extraExecArgs?: string[];
|
||||
}
|
||||
|
||||
/** A case linked to a Docker container (mirror of RemoteCase). */
|
||||
export interface DockerCase {
|
||||
name: string;
|
||||
type: 'docker';
|
||||
/** Owning username in multi-user mode; absent = legacy/unassigned (admin-only). */
|
||||
owner?: string;
|
||||
hostId: string;
|
||||
/** Absolute HOST directory: the bind-mount source AND Session.workingDir (real host bytes). */
|
||||
hostWorkspacePath: string;
|
||||
/** Container path (default = hostWorkspacePath: mirror -> transcript projHash correlates). */
|
||||
containerWorkdir?: string;
|
||||
/** Container name (default codeman-case-<slug>). */
|
||||
container?: string;
|
||||
/** Last captured Claude conversation id, replayed via --resume on a fresh launch. */
|
||||
lastClaudeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattened Docker execution metadata carried on a live session (mirror of
|
||||
* SessionRemote). Round-trips through MuxSession/SessionState/mux-sessions.json.
|
||||
*/
|
||||
export interface SessionDocker {
|
||||
hostId: string;
|
||||
label: string;
|
||||
engine: DockerEngine;
|
||||
image: string;
|
||||
/** Per-CASE container name (shared by all sessions of the case). */
|
||||
containerName: string;
|
||||
hostWorkspacePath: string;
|
||||
containerWorkdir: string;
|
||||
network: DockerNetworkMode;
|
||||
networkName?: string;
|
||||
resources?: DockerResourceLimits;
|
||||
/** GPU allocation ('all' / '1' / 'device=0,1'). */
|
||||
gpus?: string;
|
||||
mountCredentials: boolean;
|
||||
hooksEnabled: boolean;
|
||||
resumeOnStart: boolean;
|
||||
daemonHost?: string;
|
||||
context?: string;
|
||||
commands?: Partial<Record<DockerCommandMode, string>>;
|
||||
extraCreateArgs?: string[];
|
||||
extraExecArgs?: string[];
|
||||
/** Stable hash of the drift-relevant create args (recreate-on-drift detection). */
|
||||
configHash?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid Claude CLI effort levels (claude >= 2.1.154).
|
||||
@@ -85,6 +302,16 @@ export interface CodexConfig {
|
||||
renderMode?: CodexRenderMode;
|
||||
}
|
||||
|
||||
/** Gemini CLI session configuration */
|
||||
export interface GeminiConfig {
|
||||
/** Model identifier (e.g., "gemini-2.5-pro"). Passed via --model. */
|
||||
model?: string;
|
||||
/** Gemini approval mode for tool calls. */
|
||||
approvalMode?: 'default' | 'auto_edit' | 'yolo' | 'plan';
|
||||
/** Resume a previous Gemini session ("latest", index, or session id). */
|
||||
resumeSession?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for creating a new session
|
||||
*/
|
||||
@@ -148,6 +375,12 @@ export interface SessionState {
|
||||
status: SessionStatus;
|
||||
/** Working directory path */
|
||||
workingDir: string;
|
||||
/** Remote execution metadata, present when this session runs over SSH through local tmux */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
|
||||
docker?: SessionDocker;
|
||||
/** Owning username in multi-user mode; undefined in single-user (ignored when the flag is off) */
|
||||
owner?: string;
|
||||
/** ID of currently assigned task, null if none */
|
||||
currentTaskId: string | null;
|
||||
/** Timestamp when session was created */
|
||||
@@ -172,6 +405,10 @@ export interface SessionState {
|
||||
autoResumeEnabled?: boolean;
|
||||
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
|
||||
autoResumeAt?: number;
|
||||
/** Pinned to the top of the session manager list (COD-139) */
|
||||
pinned?: boolean;
|
||||
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
|
||||
pinnedAt?: number;
|
||||
/** Image watcher enabled for this session */
|
||||
imageWatcherEnabled?: boolean;
|
||||
/** Total cost in USD */
|
||||
@@ -214,12 +451,19 @@ export interface SessionState {
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** Codex-specific configuration (only for mode === 'codex') */
|
||||
codexConfig?: CodexConfig;
|
||||
/** Gemini-specific configuration (only for mode === 'gemini') */
|
||||
geminiConfig?: GeminiConfig;
|
||||
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
||||
resumeSessionId?: string;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort?: EffortLevel;
|
||||
/** Sanitized per-session attachment history. */
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
/**
|
||||
* PTY-exit circuit breaker tripped — respawn blocked until an explicit restart
|
||||
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
|
||||
*/
|
||||
respawnBlocked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @fileoverview Multi-user mode types (opt-in `--multiuser`).
|
||||
*
|
||||
* Users live in `~/.codeman/users.json` (via `dataPath`, mode 0600). Each record
|
||||
* carries a scrypt password hash with its own parameters so hashing cost can be
|
||||
* raised later and old records rehashed on next login. `AuthUser` is the
|
||||
* request-scoped identity decorated onto Fastify requests; in SINGLE-user mode a
|
||||
* synthetic `{ username: 'admin', role: 'admin' }` is used so downstream code has
|
||||
* one code path. See `src/user-store.ts` and `docs/multi-user-plan.md`.
|
||||
*/
|
||||
|
||||
export type UserRole = 'admin' | 'user';
|
||||
|
||||
/** Per-record scrypt parameters + salt/hash (all hex). */
|
||||
export interface PasswordHash {
|
||||
algo: 'scrypt';
|
||||
N: number;
|
||||
r: number;
|
||||
p: number;
|
||||
salt: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface UserRecord {
|
||||
/** Canonical lowercase slug; also the user's folder name under USER_SPACES_DIR. */
|
||||
username: string;
|
||||
role: UserRole;
|
||||
password: PasswordHash;
|
||||
/** Disabled accounts fail auth closed but keep their space on disk. */
|
||||
disabled?: boolean;
|
||||
/** Set by an admin reset; gates all API access until the user changes it. */
|
||||
mustChangePassword?: boolean;
|
||||
/**
|
||||
* Permission-mode grant (section 6.3). When false (the default for new users),
|
||||
* the user's Claude sessions are forced to `--permission-mode auto`, shell mode
|
||||
* and cron `launchCommand` are refused, and other CLIs' bypass flags are dropped.
|
||||
*/
|
||||
canBypassPermissions?: boolean;
|
||||
createdAt: number;
|
||||
lastLoginAt?: number;
|
||||
}
|
||||
|
||||
/** On-disk shape of `users.json`. */
|
||||
export interface UsersFile {
|
||||
version: 1;
|
||||
users: UserRecord[];
|
||||
}
|
||||
|
||||
/** Request-scoped identity (decorated as `req.authUser`). */
|
||||
export interface AuthUser {
|
||||
username: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
/** Admin-facing projection of a user: never carries the password hash. */
|
||||
export interface PublicUser {
|
||||
username: string;
|
||||
role: UserRole;
|
||||
disabled: boolean;
|
||||
mustChangePassword: boolean;
|
||||
canBypassPermissions: boolean;
|
||||
createdAt: number;
|
||||
lastLoginAt?: number;
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* @fileoverview Multi-user store: `~/.codeman/users.json` (via `dataPath`, 0600).
|
||||
*
|
||||
* Mirrors the storage-module pattern of `remote-hosts.ts` / `docker-hosts.ts`, but
|
||||
* because it holds password hashes it writes atomically (tmp + rename) at mode
|
||||
* 0600 and keeps only a SHORT in-process cache so the CLI (`codeman users …`) can
|
||||
* edit the file while the server runs and have changes picked up within the TTL.
|
||||
*
|
||||
* Pure, IO-free helpers (`isValidUsername`, `hashPassword`, `verifyPasswordHash`,
|
||||
* `needsRehash`, `resolveClaudeModeForUser`, the last-admin invariants) are split
|
||||
* out so they are unit-testable without a server. Hashing is `scrypt` from
|
||||
* `node:crypto` (no new deps), compared via `timingSafeEqual`; parameters are
|
||||
* stored per record so cost can be raised later and old records rehashed on their
|
||||
* next successful login.
|
||||
*
|
||||
* See `docs/multi-user-plan.md` sections 4.1, 5, 6.3.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { isAbsolute, join, relative } from 'node:path';
|
||||
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import { dataPath, getDataDir } from './config/instance.js';
|
||||
import { getUserSpacesDir, isMultiUserMode, maxUsers } from './config/multiuser.js';
|
||||
import type { AuthUser, ClaudeMode, PasswordHash, PublicUser, UserRecord, UserRole, UsersFile } from './types.js';
|
||||
|
||||
const scrypt = promisify(scryptCb) as (
|
||||
password: string | Buffer,
|
||||
salt: string | Buffer,
|
||||
keylen: number,
|
||||
options: { N: number; r: number; p: number; maxmem: number }
|
||||
) => Promise<Buffer>;
|
||||
|
||||
const USERS_FILE = 'users.json';
|
||||
const CACHE_TTL_MS = 1000;
|
||||
const KEYLEN = 64;
|
||||
const SALT_BYTES = 32;
|
||||
/** Generous ceiling so raising N/r later does not trip scrypt's memory guard. */
|
||||
const SCRYPT_MAXMEM = 256 * 1024 * 1024;
|
||||
|
||||
/** Current hashing parameters. Stored per record; raise these to increase cost. */
|
||||
export const DEFAULT_SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 } as const;
|
||||
|
||||
/** Username: lowercase, first char alphanumeric, 2-32 chars total. Becomes a folder name. */
|
||||
const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{1,31}$/;
|
||||
|
||||
/** Typed error whose `.code` maps to an API errorCode at the route layer. */
|
||||
export class UserStoreError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: 'USER_EXISTS' | 'USER_NOT_FOUND' | 'LAST_ADMIN' | 'INVALID_INPUT'
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'UserStoreError';
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────── pure helpers ───────────────────────────────
|
||||
|
||||
export function normalizeUsername(name: string): string {
|
||||
return String(name ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isValidUsername(name: string): boolean {
|
||||
return USERNAME_RE.test(normalizeUsername(name));
|
||||
}
|
||||
|
||||
/** Hash a password with the given (or current) scrypt params + a fresh random salt. */
|
||||
export async function hashPassword(
|
||||
password: string,
|
||||
params: { N: number; r: number; p: number } = DEFAULT_SCRYPT_PARAMS
|
||||
): Promise<PasswordHash> {
|
||||
const salt = randomBytes(SALT_BYTES);
|
||||
const derived = await scrypt(password, salt, KEYLEN, { ...params, maxmem: SCRYPT_MAXMEM });
|
||||
return {
|
||||
algo: 'scrypt',
|
||||
N: params.N,
|
||||
r: params.r,
|
||||
p: params.p,
|
||||
salt: salt.toString('hex'),
|
||||
hash: derived.toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Constant-time verify of a password against a stored hash record. Never throws. */
|
||||
export async function verifyPasswordHash(password: string, record: PasswordHash): Promise<boolean> {
|
||||
if (!record || record.algo !== 'scrypt') return false;
|
||||
let salt: Buffer;
|
||||
let expected: Buffer;
|
||||
try {
|
||||
salt = Buffer.from(record.salt, 'hex');
|
||||
expected = Buffer.from(record.hash, 'hex');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (expected.length === 0) return false;
|
||||
let derived: Buffer;
|
||||
try {
|
||||
derived = await scrypt(password, salt, expected.length, {
|
||||
N: record.N,
|
||||
r: record.r,
|
||||
p: record.p,
|
||||
maxmem: SCRYPT_MAXMEM,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (derived.length !== expected.length) return false;
|
||||
return timingSafeEqual(derived, expected);
|
||||
}
|
||||
|
||||
/** True when a stored hash uses weaker params than current and should be rehashed. */
|
||||
export function needsRehash(record: PasswordHash, params = DEFAULT_SCRYPT_PARAMS): boolean {
|
||||
return record.algo !== 'scrypt' || record.N !== params.N || record.r !== params.r || record.p !== params.p;
|
||||
}
|
||||
|
||||
/** URL-safe one-time password (16 chars) for admin create/reset flows. */
|
||||
export function generateOneTimePassword(): string {
|
||||
return randomBytes(12).toString('base64url');
|
||||
}
|
||||
|
||||
export function toPublicUser(u: UserRecord): PublicUser {
|
||||
return {
|
||||
username: u.username,
|
||||
role: u.role,
|
||||
disabled: !!u.disabled,
|
||||
mustChangePassword: !!u.mustChangePassword,
|
||||
canBypassPermissions: !!u.canBypassPermissions,
|
||||
createdAt: u.createdAt,
|
||||
lastLoginAt: u.lastLoginAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function countEnabledAdmins(users: UserRecord[]): number {
|
||||
return users.filter((u) => u.role === 'admin' && !u.disabled).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section 6.3: resolve the effective Claude permission mode for a user. Admins and
|
||||
* granted users get the global mode as-is; a non-granted regular user whose mode
|
||||
* would be `dangerously-skip-permissions` is silently downgraded to `auto` (all
|
||||
* other modes are already <= auto and pass through). Pure.
|
||||
*/
|
||||
export function resolveClaudeModeForUser(
|
||||
globalMode: ClaudeMode | undefined,
|
||||
grant: { role: UserRole; canBypassPermissions?: boolean }
|
||||
): ClaudeMode {
|
||||
const mode: ClaudeMode = globalMode ?? 'dangerously-skip-permissions';
|
||||
if (grant.role === 'admin' || grant.canBypassPermissions) return mode;
|
||||
return mode === 'dangerously-skip-permissions' ? 'auto' : mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section 6.3: whether a user may run arbitrary commands as the host account
|
||||
* (shell-mode sessions, cron `launchCommand`, other CLIs' bypass flags). Same
|
||||
* one-bit grant as bypass. Admins always may.
|
||||
*/
|
||||
export function canRunPrivilegedCommands(grant: { role: UserRole; canBypassPermissions?: boolean }): boolean {
|
||||
return grant.role === 'admin' || !!grant.canBypassPermissions;
|
||||
}
|
||||
|
||||
// ─────────────────────────────── IO layer ───────────────────────────────
|
||||
|
||||
let cache: { users: UserRecord[]; ts: number } | null = null;
|
||||
|
||||
/** Drop the in-process cache (called after every write; exported for tests). */
|
||||
export function invalidateUsersCache(): void {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
export async function readUsers(force = false): Promise<UserRecord[]> {
|
||||
const now = Date.now();
|
||||
if (!force && cache && now - cache.ts < CACHE_TTL_MS) return cache.users;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(dataPath(USERS_FILE), 'utf-8');
|
||||
} catch (err) {
|
||||
// ENOENT is the ONLY legitimately-empty store (first boot). Any other read
|
||||
// error (EIO/EACCES/EMFILE/EBUSY) is a transient/permission failure, NOT an
|
||||
// empty store — do NOT cache [] and do NOT let it look empty, or a following
|
||||
// createUser/bootstrap would overwrite users.json and destroy every account.
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
cache = { users: [], ts: now };
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// A present-but-corrupt file (invalid JSON) must also fail loud rather than
|
||||
// read as empty, so mutators/bootstrap abort instead of clobbering it.
|
||||
const parsed = JSON.parse(raw) as Partial<UsersFile>;
|
||||
const users = Array.isArray(parsed.users) ? parsed.users : [];
|
||||
cache = { users, ts: now };
|
||||
return users;
|
||||
}
|
||||
|
||||
async function writeUsers(users: UserRecord[]): Promise<void> {
|
||||
const dir = getDataDir();
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
const finalPath = dataPath(USERS_FILE);
|
||||
// Unique per-writer tmp name (pid + random) so the CLI (`codeman users …`) and
|
||||
// the live server — designed to write this file concurrently across processes —
|
||||
// never share a single `users.json.tmp` inode and tear each other's payload.
|
||||
// Matches the state-store.ts / self-update.ts convention.
|
||||
const tmpPath = `${finalPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
||||
const payload: UsersFile = { version: 1, users };
|
||||
try {
|
||||
await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
||||
await fs.chmod(tmpPath, 0o600).catch(() => {});
|
||||
await fs.rename(tmpPath, finalPath);
|
||||
} catch (err) {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
cache = { users, ts: Date.now() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize every read-modify-write on users.json. Without this a fire-and-forget
|
||||
* touchLastLogin (fired on each Basic auth) can interleave with a route's
|
||||
* create/update and clobber records, since both do readUsers(true) → mutate →
|
||||
* writeUsers against a single shared file + tmp path.
|
||||
*/
|
||||
let mutateChain: Promise<unknown> = Promise.resolve();
|
||||
function withUsersLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const run = mutateChain.then(fn, fn);
|
||||
mutateChain = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function hasUsers(): Promise<boolean> {
|
||||
return (await readUsers()).length > 0;
|
||||
}
|
||||
|
||||
// A precomputed dummy hash so an unknown/disabled user costs the same scrypt work
|
||||
// as a real verify (defeats username-enumeration by timing). Created once, lazily.
|
||||
let dummyHashPromise: Promise<PasswordHash> | null = null;
|
||||
function getDummyHash(): Promise<PasswordHash> {
|
||||
if (!dummyHashPromise) dummyHashPromise = hashPassword('codeman-timing-equalization-placeholder');
|
||||
return dummyHashPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a username/password against the store. Returns the record (plus whether it
|
||||
* should be rehashed) on success, or null for wrong password / unknown / disabled
|
||||
* user. Runs a dummy scrypt on the miss path so timing does not reveal which users
|
||||
* exist. Never writes (the caller decides when to persist lastLogin / rehash).
|
||||
*/
|
||||
export async function verifyPassword(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<{ user: UserRecord; needsRehash: boolean } | null> {
|
||||
const user = await findUser(username);
|
||||
if (!user || user.disabled) {
|
||||
await verifyPasswordHash(password, await getDummyHash());
|
||||
return null;
|
||||
}
|
||||
const ok = await verifyPasswordHash(password, user.password);
|
||||
if (!ok) return null;
|
||||
return { user, needsRehash: needsRehash(user.password) };
|
||||
}
|
||||
|
||||
export async function findUser(username: string): Promise<UserRecord | undefined> {
|
||||
const norm = normalizeUsername(username);
|
||||
if (!norm) return undefined;
|
||||
const users = await readUsers();
|
||||
return users.find((u) => u.username === norm);
|
||||
}
|
||||
|
||||
export interface CreateUserOptions {
|
||||
username: string;
|
||||
role: UserRole;
|
||||
password: string;
|
||||
mustChangePassword?: boolean;
|
||||
canBypassPermissions?: boolean;
|
||||
}
|
||||
|
||||
export async function createUser(opts: CreateUserOptions): Promise<UserRecord> {
|
||||
const username = normalizeUsername(opts.username);
|
||||
if (!isValidUsername(username)) {
|
||||
throw new UserStoreError(
|
||||
'Username must be lowercase, start alphanumeric, 2-32 chars ([a-z0-9_-])',
|
||||
'INVALID_INPUT'
|
||||
);
|
||||
}
|
||||
if (opts.role !== 'admin' && opts.role !== 'user') {
|
||||
throw new UserStoreError('Role must be "admin" or "user"', 'INVALID_INPUT');
|
||||
}
|
||||
if (!opts.password || opts.password.length < 8) {
|
||||
throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT');
|
||||
}
|
||||
return withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
if (users.some((u) => u.username === username)) {
|
||||
throw new UserStoreError(`User "${username}" already exists`, 'USER_EXISTS');
|
||||
}
|
||||
if (users.length >= maxUsers()) {
|
||||
throw new UserStoreError(`Maximum number of users (${maxUsers()}) reached`, 'INVALID_INPUT');
|
||||
}
|
||||
const record: UserRecord = {
|
||||
username,
|
||||
role: opts.role,
|
||||
password: await hashPassword(opts.password),
|
||||
disabled: false,
|
||||
mustChangePassword: !!opts.mustChangePassword,
|
||||
canBypassPermissions: !!opts.canBypassPermissions,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
users.push(record);
|
||||
await writeUsers(users);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
/** Set a user's password. `mustChangePassword` is left unchanged unless specified. */
|
||||
export async function setPassword(
|
||||
username: string,
|
||||
password: string,
|
||||
opts: { mustChangePassword?: boolean } = {}
|
||||
): Promise<UserRecord> {
|
||||
if (!password || password.length < 8) {
|
||||
throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT');
|
||||
}
|
||||
const norm = normalizeUsername(username);
|
||||
return withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
||||
record.password = await hashPassword(password);
|
||||
if (opts.mustChangePassword !== undefined) record.mustChangePassword = opts.mustChangePassword;
|
||||
await writeUsers(users);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
export interface UpdateUserPatch {
|
||||
role?: UserRole;
|
||||
disabled?: boolean;
|
||||
canBypassPermissions?: boolean;
|
||||
mustChangePassword?: boolean;
|
||||
}
|
||||
|
||||
export async function updateUser(username: string, patch: UpdateUserPatch): Promise<UserRecord> {
|
||||
const norm = normalizeUsername(username);
|
||||
return withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
||||
|
||||
// Guard the last-enabled-admin invariant against demote/disable.
|
||||
const before = countEnabledAdmins(users);
|
||||
const projected: UserRecord = {
|
||||
...record,
|
||||
role: patch.role ?? record.role,
|
||||
disabled: patch.disabled ?? record.disabled,
|
||||
};
|
||||
const after = countEnabledAdmins(users.map((u) => (u.username === norm ? projected : u)));
|
||||
if (before > 0 && after === 0) {
|
||||
throw new UserStoreError('Cannot demote or disable the last enabled admin', 'LAST_ADMIN');
|
||||
}
|
||||
|
||||
if (patch.role !== undefined) record.role = patch.role;
|
||||
if (patch.disabled !== undefined) record.disabled = patch.disabled;
|
||||
if (patch.canBypassPermissions !== undefined) record.canBypassPermissions = patch.canBypassPermissions;
|
||||
if (patch.mustChangePassword !== undefined) record.mustChangePassword = patch.mustChangePassword;
|
||||
await writeUsers(users);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful login timestamp. Best-effort + throttled: skips the write if
|
||||
* the last login was within the last minute (Basic clients re-send credentials on
|
||||
* every request, so this fires often — the throttle keeps disk churn bounded).
|
||||
*/
|
||||
export async function touchLastLogin(username: string): Promise<void> {
|
||||
const norm = normalizeUsername(username);
|
||||
try {
|
||||
await withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) return;
|
||||
if (record.lastLoginAt && Date.now() - record.lastLoginAt < 60_000) return;
|
||||
record.lastLoginAt = Date.now();
|
||||
await writeUsers(users);
|
||||
});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(username: string): Promise<void> {
|
||||
const norm = normalizeUsername(username);
|
||||
await withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
||||
const before = countEnabledAdmins(users);
|
||||
const remaining = users.filter((u) => u.username !== norm);
|
||||
const after = countEnabledAdmins(remaining);
|
||||
if (before > 0 && after === 0) {
|
||||
throw new UserStoreError('Cannot delete the last enabled admin', 'LAST_ADMIN');
|
||||
}
|
||||
await writeUsers(remaining);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* First-boot bootstrap: in multi-user mode with no users yet, create the initial
|
||||
* admin from `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` if both are set. Returns a
|
||||
* status the caller (server start / CLI) uses to decide whether to refuse boot.
|
||||
*/
|
||||
export async function bootstrapInitialAdmin(): Promise<{
|
||||
status: 'created' | 'exists' | 'missing-env';
|
||||
username?: string;
|
||||
}> {
|
||||
if (await hasUsers()) return { status: 'exists' };
|
||||
const username = process.env.CODEMAN_USERNAME;
|
||||
const password = process.env.CODEMAN_PASSWORD;
|
||||
if (!username || !password) return { status: 'missing-env' };
|
||||
const created = await createUser({ username, role: 'admin', password });
|
||||
return { status: 'created', username: created.username };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user's on-disk space (`<USER_SPACES_DIR>/<username>`) with the section 8
|
||||
* guard rails: the top-level dir must not be a symlink, and its realpath must
|
||||
* resolve strictly inside USER_SPACES_DIR (so a symlinked or `..`-escaping target
|
||||
* can never be used to rm an arbitrary tree). No-op if the space does not exist.
|
||||
*/
|
||||
export async function deleteUserSpace(username: string): Promise<void> {
|
||||
const norm = normalizeUsername(username);
|
||||
if (!isValidUsername(norm)) throw new UserStoreError('Invalid username', 'INVALID_INPUT');
|
||||
const root = getUserSpacesDir();
|
||||
const target = join(root, norm);
|
||||
let lst;
|
||||
try {
|
||||
lst = await fs.lstat(target);
|
||||
} catch {
|
||||
return; // nothing to delete
|
||||
}
|
||||
if (lst.isSymbolicLink()) {
|
||||
throw new UserStoreError('Refusing to delete a symlinked user space', 'INVALID_INPUT');
|
||||
}
|
||||
const realRoot = await fs.realpath(root).catch(() => root);
|
||||
const realTarget = await fs.realpath(target);
|
||||
const rel = relative(realRoot, realTarget);
|
||||
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
|
||||
throw new UserStoreError('User space escapes USER_SPACES_DIR', 'INVALID_INPUT');
|
||||
}
|
||||
await fs.rm(realTarget, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** The synthetic admin used in single-user mode so downstream has one code path. */
|
||||
export const SYNTHETIC_ADMIN: AuthUser = { username: 'admin', role: 'admin' };
|
||||
|
||||
/**
|
||||
* Whether a username may run arbitrary commands (shell mode, cron launchCommand,
|
||||
* other CLIs' bypass). Single-user or an unset owner: allowed. In multi-user a
|
||||
* MISSING user (e.g. deleted) fails closed (non-privileged). Used at cron fire time.
|
||||
*/
|
||||
export async function canUsernameRunPrivilegedCommands(username: string | undefined): Promise<boolean> {
|
||||
if (!isMultiUserMode() || !username) return true;
|
||||
const user = await findUser(username);
|
||||
return canRunPrivilegedCommands(user ?? { role: 'user' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective Claude mode for a username by looking up the grant. In
|
||||
* single-user mode (or for an unknown owner) the global mode passes through.
|
||||
*/
|
||||
export async function resolveClaudeModeForUsername(
|
||||
globalMode: ClaudeMode | undefined,
|
||||
username: string | undefined
|
||||
): Promise<ClaudeMode> {
|
||||
const fallback: ClaudeMode = globalMode ?? 'dangerously-skip-permissions';
|
||||
if (!isMultiUserMode() || !username) return fallback;
|
||||
// Fail closed: an unknown/deleted owner in multi-user mode is treated as a
|
||||
// non-granted regular user so a stale-owned spawn (e.g. an orphaned cron job)
|
||||
// is downgraded to `auto` rather than inheriting the global bypass.
|
||||
const user = await findUser(username);
|
||||
return resolveClaudeModeForUser(globalMode, user ?? { role: 'user' });
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
* @module utils/claude-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { execSync, execFileSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { delimiter, dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
@@ -83,3 +83,43 @@ export function getAugmentedPath(): string {
|
||||
_augmentedPath = currentPath;
|
||||
return _augmentedPath;
|
||||
}
|
||||
|
||||
/** Cached `claude --version` result: string = version, null = probed but unavailable, undefined = not probed */
|
||||
let _claudeVersion: string | null | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Returns the installed Claude CLI version (e.g. `"2.1.210"`), or null if it
|
||||
* can't be determined. Runs `claude --version` once and caches the result.
|
||||
*
|
||||
* This is a deterministic alternative to scraping the interactive startup
|
||||
* banner (`parseClaudeCodeInfo` in session.ts): newer Claude Code builds don't
|
||||
* reliably print `Claude Code vX.Y.Z` at startup, and resumed sessions never
|
||||
* show it, which left `cliVersion` undefined and silently disabled features
|
||||
* gated on it (e.g. wheel-forwarding to Claude's transcript — issue #154).
|
||||
*/
|
||||
export function getClaudeCliVersion(): string | null {
|
||||
if (_claudeVersion !== undefined) return _claudeVersion;
|
||||
// Keep the test suite hermetic — never spawn a real `claude` subprocess under
|
||||
// vitest (matches IS_TEST_MODE in tmux-manager). Tests that need a version set
|
||||
// it on the session directly.
|
||||
if (process.env.VITEST) {
|
||||
_claudeVersion = null;
|
||||
return _claudeVersion;
|
||||
}
|
||||
try {
|
||||
const dir = findClaudeDir();
|
||||
const bin = dir ? join(dir, 'claude') : 'claude';
|
||||
// execFileSync (no shell) — the resolved path may contain spaces, and there
|
||||
// is no untrusted input, but avoid a shell either way.
|
||||
const out = execFileSync(bin, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
env: { ...process.env, PATH: getAugmentedPath() },
|
||||
});
|
||||
const match = out.match(/(\d+\.\d+\.\d+)/);
|
||||
_claudeVersion = match ? match[1] : null;
|
||||
} catch {
|
||||
_claudeVersion = null;
|
||||
}
|
||||
return _claudeVersion;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @fileoverview Resolve the Gemini CLI binary across common install paths.
|
||||
*
|
||||
* Mirrors codex-cli-resolver.ts and opencode-cli-resolver.ts. Finds the
|
||||
* `gemini` binary and provides an augmented PATH directory for tmux sessions.
|
||||
*
|
||||
* @module utils/gemini-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
|
||||
/** Common directories where the Gemini CLI binary may be installed */
|
||||
const GEMINI_SEARCH_DIRS = [
|
||||
join(homedir(), '.gemini', 'bin'),
|
||||
join(homedir(), '.local', 'bin'),
|
||||
'/usr/local/bin',
|
||||
join(homedir(), '.bun', 'bin'),
|
||||
join(homedir(), '.npm-global', 'bin'),
|
||||
join(homedir(), 'bin'),
|
||||
];
|
||||
|
||||
/** Cached directory containing the gemini binary (empty string = searched but not found) */
|
||||
let _geminiDir: string | null = null;
|
||||
|
||||
/**
|
||||
* Finds the directory containing the `gemini` binary.
|
||||
* Checks `which gemini` first, then falls back to common install locations.
|
||||
*
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveGeminiDir(): string | null {
|
||||
if (_geminiDir !== null) return _geminiDir || null;
|
||||
|
||||
try {
|
||||
const result = execSync('which gemini', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_geminiDir = dirname(result);
|
||||
return _geminiDir;
|
||||
}
|
||||
} catch {
|
||||
// Gemini not in PATH, will check common locations
|
||||
}
|
||||
|
||||
for (const dir of GEMINI_SEARCH_DIRS) {
|
||||
if (existsSync(join(dir, 'gemini'))) {
|
||||
_geminiDir = dir;
|
||||
return _geminiDir;
|
||||
}
|
||||
}
|
||||
|
||||
_geminiDir = '';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Gemini CLI is available on the system.
|
||||
*/
|
||||
export function isGeminiAvailable(): boolean {
|
||||
return resolveGeminiDir() !== null;
|
||||
}
|
||||
+2
-1
@@ -26,6 +26,7 @@ export { isSafePushEndpoint } from './push-endpoint-validation.js';
|
||||
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
||||
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion } from './claude-cli-resolver.js';
|
||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
||||
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @fileoverview Append-only admin audit log (~/.codeman/admin-audit.jsonl).
|
||||
*
|
||||
* Every user-management action (create/patch/reset/delete/logout/assign) writes one
|
||||
* JSON line: timestamp, acting admin, action, target, request IP. Same idiom as
|
||||
* session-lifecycle.jsonl. Best-effort: a write failure never blocks the action.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
|
||||
export interface AdminAuditEntry {
|
||||
ts: number;
|
||||
admin: string;
|
||||
action: string;
|
||||
target?: string;
|
||||
ip?: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function appendAdminAudit(entry: Omit<AdminAuditEntry, 'ts'>): Promise<void> {
|
||||
try {
|
||||
const line = JSON.stringify({ ts: Date.now(), ...entry }) + '\n';
|
||||
await fs.appendFile(dataPath('admin-audit.jsonl'), line, { mode: 0o600 });
|
||||
} catch {
|
||||
/* best-effort audit; never block the action */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import type { LifecycleEntry, RunSummary, RunSummaryEvent, TokenUsageEntry } from '../types.js';
|
||||
|
||||
export type AwayDigestRangeName = 'since-last-visit' | '1h' | 'today' | '24h' | 'custom';
|
||||
export type AwayDigestCategory = 'needs_attention' | 'completed' | 'still_running' | 'idle' | 'informational';
|
||||
export type AwayDigestSectionName = 'needsAttention' | 'completed' | 'stillRunning' | 'idle' | 'informational';
|
||||
export type AwayDigestSeverity = 'info' | 'success' | 'warning' | 'error';
|
||||
export type AwayDigestSource = 'lifecycle' | 'run_summary' | 'status' | 'token_stats' | 'subagent';
|
||||
export type AwayDigestTokenWindowPrecision = 'day' | 'none';
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
const VALID_RANGES = new Set<AwayDigestRangeName>(['since-last-visit', '1h', 'today', '24h', 'custom']);
|
||||
|
||||
export interface AwayDigestRange {
|
||||
range: AwayDigestRangeName;
|
||||
since: number;
|
||||
until: number;
|
||||
}
|
||||
|
||||
export interface AwayDigestRangeInput {
|
||||
range?: string;
|
||||
since?: number;
|
||||
until?: number;
|
||||
lastViewed?: number;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
export interface AwayDigestSession {
|
||||
id: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalCost?: number;
|
||||
}
|
||||
|
||||
export interface AwayDigestSubagent {
|
||||
id?: string;
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
lastUpdated?: number;
|
||||
updatedAt?: number;
|
||||
completedAt?: number;
|
||||
modifiedAt?: number;
|
||||
lastActivityAt?: number;
|
||||
}
|
||||
|
||||
export interface AwayDigestItem {
|
||||
id: string;
|
||||
sessionId?: string;
|
||||
sessionName?: string;
|
||||
timestamp: number;
|
||||
category: AwayDigestCategory;
|
||||
severity: AwayDigestSeverity;
|
||||
title: string;
|
||||
detail?: string;
|
||||
source: AwayDigestSource;
|
||||
link?: {
|
||||
type: 'session' | 'run_summary' | 'lifecycle' | 'notification';
|
||||
sessionId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AwayDigestTotals {
|
||||
sessionsCreated: number;
|
||||
sessionsExited: number;
|
||||
activeSessions: number;
|
||||
needsAttention: number;
|
||||
completed: number;
|
||||
errors: number;
|
||||
warnings: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
estimatedCost?: number;
|
||||
tokenWindowPrecision: AwayDigestTokenWindowPrecision;
|
||||
}
|
||||
|
||||
export interface AwayDigestResponse {
|
||||
range: AwayDigestRange;
|
||||
generatedAt: number;
|
||||
dataFreshness: {
|
||||
lifecyclePersisted: true;
|
||||
tokenStatsPersisted: true;
|
||||
runSummariesLiveOnly: true;
|
||||
subagentsLiveOnly: true;
|
||||
};
|
||||
totals: AwayDigestTotals;
|
||||
sections: Record<AwayDigestSectionName, AwayDigestItem[]>;
|
||||
}
|
||||
|
||||
export interface AwayDigestInput {
|
||||
range: AwayDigestRange;
|
||||
lifecycleEntries: LifecycleEntry[];
|
||||
runSummaries: RunSummary[];
|
||||
sessions: AwayDigestSession[];
|
||||
dailyTokenStats: TokenUsageEntry[];
|
||||
subagents: AwayDigestSubagent[];
|
||||
now?: number;
|
||||
}
|
||||
|
||||
export function resolveAwayDigestRange(input: AwayDigestRangeInput): AwayDigestRange {
|
||||
const now = input.now ?? Date.now();
|
||||
const range = (input.range ?? 'since-last-visit') as AwayDigestRangeName;
|
||||
if (!VALID_RANGES.has(range)) {
|
||||
throw new Error(`Invalid away digest range: ${input.range}`);
|
||||
}
|
||||
|
||||
let since: number;
|
||||
const until = finiteOrDefault(input.until, now);
|
||||
|
||||
switch (range) {
|
||||
case 'since-last-visit':
|
||||
since = finiteOrDefault(input.lastViewed, now - DAY_MS);
|
||||
break;
|
||||
case '1h':
|
||||
since = now - HOUR_MS;
|
||||
break;
|
||||
case 'today': {
|
||||
const start = new Date(now);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
since = start.getTime();
|
||||
break;
|
||||
}
|
||||
case '24h':
|
||||
since = now - DAY_MS;
|
||||
break;
|
||||
case 'custom':
|
||||
if (!Number.isFinite(input.since)) {
|
||||
throw new Error('Custom away digest range requires a finite since timestamp');
|
||||
}
|
||||
since = input.since as number;
|
||||
break;
|
||||
}
|
||||
|
||||
if (until < since) {
|
||||
throw new Error('Away digest until timestamp must be greater than or equal to since');
|
||||
}
|
||||
|
||||
return { range, since, until };
|
||||
}
|
||||
|
||||
export function buildAwayDigest(input: AwayDigestInput): AwayDigestResponse {
|
||||
const now = input.now ?? Date.now();
|
||||
const sections: Record<AwayDigestSectionName, AwayDigestItem[]> = {
|
||||
needsAttention: [],
|
||||
completed: [],
|
||||
stillRunning: [],
|
||||
idle: [],
|
||||
informational: [],
|
||||
};
|
||||
|
||||
const sessionsById = new Map(input.sessions.map((session) => [session.id, session]));
|
||||
const lifecycleEntries = input.lifecycleEntries.filter((entry) => isInRange(entry.ts, input.range));
|
||||
|
||||
for (const entry of lifecycleEntries) {
|
||||
addItem(sections, lifecycleEntryToItem(entry));
|
||||
}
|
||||
|
||||
for (const summary of input.runSummaries) {
|
||||
for (const event of summary.events) {
|
||||
if (!isInRange(event.timestamp, input.range)) continue;
|
||||
addItem(sections, runSummaryEventToItem(summary, event));
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of input.sessions) {
|
||||
const item = sessionToItem(session, now);
|
||||
addItem(sections, item);
|
||||
}
|
||||
|
||||
for (const subagent of input.subagents) {
|
||||
const timestamp = subagentTimestamp(subagent, now);
|
||||
if (!isInRange(timestamp, input.range) || subagent.status !== 'completed') continue;
|
||||
addItem(sections, subagentToItem(subagent, sessionsById, timestamp));
|
||||
}
|
||||
|
||||
const tokenTotals = aggregateTokenStats(input.dailyTokenStats, input.range);
|
||||
const totals = calculateTotals(sections, lifecycleEntries, input.sessions, tokenTotals);
|
||||
|
||||
return {
|
||||
range: input.range,
|
||||
generatedAt: now,
|
||||
dataFreshness: {
|
||||
lifecyclePersisted: true,
|
||||
tokenStatsPersisted: true,
|
||||
runSummariesLiveOnly: true,
|
||||
subagentsLiveOnly: true,
|
||||
},
|
||||
totals,
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
function finiteOrDefault(value: number | undefined, fallback: number): number {
|
||||
return Number.isFinite(value) ? (value as number) : fallback;
|
||||
}
|
||||
|
||||
function isInRange(timestamp: number, range: AwayDigestRange): boolean {
|
||||
return timestamp >= range.since && timestamp <= range.until;
|
||||
}
|
||||
|
||||
function addItem(sections: Record<AwayDigestSectionName, AwayDigestItem[]>, item: AwayDigestItem): void {
|
||||
sections[sectionNameForCategory(item.category)].push(item);
|
||||
}
|
||||
|
||||
function sectionNameForCategory(category: AwayDigestCategory): AwayDigestSectionName {
|
||||
switch (category) {
|
||||
case 'needs_attention':
|
||||
return 'needsAttention';
|
||||
case 'still_running':
|
||||
return 'stillRunning';
|
||||
case 'completed':
|
||||
case 'idle':
|
||||
case 'informational':
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
function lifecycleEntryToItem(entry: LifecycleEntry): AwayDigestItem {
|
||||
const needsAttention = entry.event === 'mux_died' || (entry.event === 'exit' && (entry.exitCode ?? 0) !== 0);
|
||||
return {
|
||||
id: `lifecycle-${entry.ts}-${entry.event}-${entry.sessionId}`,
|
||||
sessionId: entry.sessionId,
|
||||
sessionName: entry.name,
|
||||
timestamp: entry.ts,
|
||||
category: needsAttention ? 'needs_attention' : 'informational',
|
||||
severity: needsAttention ? 'error' : entry.event === 'exit' ? 'info' : 'info',
|
||||
title: lifecycleTitle(entry),
|
||||
detail: lifecycleDetail(entry),
|
||||
source: 'lifecycle',
|
||||
link: { type: 'lifecycle', sessionId: entry.sessionId },
|
||||
};
|
||||
}
|
||||
|
||||
function lifecycleTitle(entry: LifecycleEntry): string {
|
||||
if (entry.event === 'exit') {
|
||||
return (entry.exitCode ?? 0) === 0 ? 'Session exited' : 'Session exited with error';
|
||||
}
|
||||
if (entry.event === 'mux_died') return 'Tmux session died';
|
||||
return `Session ${entry.event.replaceAll('_', ' ')}`;
|
||||
}
|
||||
|
||||
function lifecycleDetail(entry: LifecycleEntry): string | undefined {
|
||||
if (entry.reason) return entry.reason;
|
||||
if (entry.event === 'exit' && entry.exitCode !== undefined && entry.exitCode !== null) {
|
||||
return `Exit code ${entry.exitCode}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function runSummaryEventToItem(summary: RunSummary, event: RunSummaryEvent): AwayDigestItem {
|
||||
const category = runSummaryCategory(event);
|
||||
return {
|
||||
id: `run-summary-${summary.sessionId}-${event.id}`,
|
||||
sessionId: summary.sessionId,
|
||||
sessionName: summary.sessionName,
|
||||
timestamp: event.timestamp,
|
||||
category,
|
||||
severity: runSummarySeverity(event, category),
|
||||
title: event.title,
|
||||
detail: event.details,
|
||||
source: 'run_summary',
|
||||
link: { type: 'run_summary', sessionId: summary.sessionId },
|
||||
};
|
||||
}
|
||||
|
||||
function runSummaryCategory(event: RunSummaryEvent): AwayDigestCategory {
|
||||
if (event.type === 'ralph_completion') return 'completed';
|
||||
if (event.severity === 'error' || event.severity === 'warning' || event.type === 'state_stuck') {
|
||||
return 'needs_attention';
|
||||
}
|
||||
return 'informational';
|
||||
}
|
||||
|
||||
function runSummarySeverity(event: RunSummaryEvent, category: AwayDigestCategory): AwayDigestSeverity {
|
||||
if (category === 'completed') return 'success';
|
||||
return event.severity;
|
||||
}
|
||||
|
||||
function sessionToItem(session: AwayDigestSession, now: number): AwayDigestItem {
|
||||
const isIdle = session.status === 'idle';
|
||||
return {
|
||||
id: `status-${session.id}`,
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
timestamp: now,
|
||||
category: isIdle ? 'idle' : 'still_running',
|
||||
severity: isIdle ? 'info' : 'success',
|
||||
title: isIdle ? 'Session idle' : 'Session still running',
|
||||
detail: session.status ? `Status: ${session.status}` : undefined,
|
||||
source: 'status',
|
||||
link: { type: 'session', sessionId: session.id },
|
||||
};
|
||||
}
|
||||
|
||||
function subagentToItem(
|
||||
subagent: AwayDigestSubagent,
|
||||
sessionsById: Map<string, AwayDigestSession>,
|
||||
timestamp: number
|
||||
): AwayDigestItem {
|
||||
const session = subagent.sessionId ? sessionsById.get(subagent.sessionId) : undefined;
|
||||
const agentId = subagent.id ?? subagent.agentId ?? 'unknown';
|
||||
return {
|
||||
id: `subagent-${agentId}`,
|
||||
sessionId: subagent.sessionId,
|
||||
sessionName: session?.name,
|
||||
timestamp,
|
||||
category: 'informational',
|
||||
severity: 'success',
|
||||
title: 'Subagent completed',
|
||||
detail: subagent.description,
|
||||
source: 'subagent',
|
||||
link: subagent.sessionId ? { type: 'session', sessionId: subagent.sessionId } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function subagentTimestamp(subagent: AwayDigestSubagent, fallback: number): number {
|
||||
return (
|
||||
subagent.completedAt ??
|
||||
subagent.lastUpdated ??
|
||||
subagent.updatedAt ??
|
||||
subagent.modifiedAt ??
|
||||
subagent.lastActivityAt ??
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateTokenStats(
|
||||
dailyTokenStats: TokenUsageEntry[],
|
||||
range: AwayDigestRange
|
||||
): { inputTokens: number; outputTokens: number; estimatedCost: number; precision: AwayDigestTokenWindowPrecision } {
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let estimatedCost = 0;
|
||||
|
||||
for (const day of dailyTokenStats) {
|
||||
if (!dayOverlapsRange(day.date, range)) continue;
|
||||
inputTokens += day.inputTokens;
|
||||
outputTokens += day.outputTokens;
|
||||
estimatedCost += day.estimatedCost;
|
||||
}
|
||||
|
||||
return {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
estimatedCost,
|
||||
precision: inputTokens > 0 || outputTokens > 0 || estimatedCost > 0 ? 'day' : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
function dayOverlapsRange(date: string, range: AwayDigestRange): boolean {
|
||||
const dayStart = new Date(`${date}T00:00:00`).getTime();
|
||||
const dayEnd = dayStart + DAY_MS - 1;
|
||||
return dayStart <= range.until && dayEnd >= range.since;
|
||||
}
|
||||
|
||||
function calculateTotals(
|
||||
sections: Record<AwayDigestSectionName, AwayDigestItem[]>,
|
||||
lifecycleEntries: LifecycleEntry[],
|
||||
sessions: AwayDigestSession[],
|
||||
tokenTotals: ReturnType<typeof aggregateTokenStats>
|
||||
): AwayDigestTotals {
|
||||
const allItems = Object.values(sections).flat();
|
||||
return {
|
||||
sessionsCreated: lifecycleEntries.filter((entry) => entry.event === 'created').length,
|
||||
sessionsExited: lifecycleEntries.filter((entry) => entry.event === 'exit').length,
|
||||
activeSessions: sessions.length,
|
||||
needsAttention: sections.needsAttention.length,
|
||||
completed: sections.completed.length,
|
||||
errors: allItems.filter((item) => item.severity === 'error').length,
|
||||
warnings: allItems.filter((item) => item.severity === 'warning').length,
|
||||
inputTokens: tokenTotals.inputTokens,
|
||||
outputTokens: tokenTotals.outputTokens,
|
||||
estimatedCost: tokenTotals.estimatedCost,
|
||||
tokenWindowPrecision: tokenTotals.precision,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @fileoverview Main-thread wrapper for HEIC/HEIF → JPEG conversion.
|
||||
*
|
||||
* The actual decode/encode (`heic-jpeg-worker.ts`) is CPU-synchronous WASM + JS,
|
||||
* so it runs in a dedicated `worker_threads` Worker per conversion — never on
|
||||
* the event loop that serves every session's SSE/PTY/WS traffic. On top of
|
||||
* the worker isolation this wrapper enforces:
|
||||
* - the global converter concurrency cap (`runWithConversionLimit`, shared
|
||||
* with the pdftoppm/soffice document converters) so N simultaneous uploads
|
||||
* can't pin N cores / N × 256MB decode buffers at once;
|
||||
* - a hard timeout that terminates the worker (a wedged WASM decode can't be
|
||||
* cancelled cooperatively);
|
||||
* - the paste-image size cap on the *output* — jpeg-js is a far less
|
||||
* efficient encoder than HEVC, so a within-limit HEIC can inflate past
|
||||
* MAX_PASTE_IMAGE_BYTES.
|
||||
*/
|
||||
|
||||
import { Worker } from 'node:worker_threads';
|
||||
import { runWithConversionLimit } from '../document-conversion-limiter.js';
|
||||
import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
|
||||
import { HEIC_JPEG_QUALITY, type HeicWorkerInput, type HeicWorkerResult } from './heic-jpeg-worker.js';
|
||||
|
||||
/** Hard cap on a single conversion; the worker is terminated when it fires. */
|
||||
export const HEIC_CONVERSION_TIMEOUT_MS = 30_000;
|
||||
|
||||
// V8-heap guardrails for the conversion worker — defense in depth only: large
|
||||
// TypedArray/WASM backing stores are external to the V8 heap, so the real
|
||||
// memory bound is the 64MP dimension pre-check in heic-jpeg-worker.ts.
|
||||
const WORKER_RESOURCE_LIMITS = { maxOldGenerationSizeMb: 1024, maxYoungGenerationSizeMb: 128, stackSizeMb: 8 };
|
||||
|
||||
function workerUrl(): URL {
|
||||
// Compiled installs run the tsc-emitted .js sibling in dist/; dev under tsx
|
||||
// runs the .ts source directly (tsx's loader propagates to worker threads).
|
||||
const file = import.meta.url.endsWith('.ts') ? './heic-jpeg-worker.ts' : './heic-jpeg-worker.js';
|
||||
return new URL(file, import.meta.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HEIC/HEIF bytes to JPEG bytes off-thread. Rejects on invalid input,
|
||||
* over-limit dimensions, oversized output, timeout, or worker failure.
|
||||
*/
|
||||
export async function convertHeicToJpeg(imageBytes: Buffer): Promise<Buffer> {
|
||||
return runWithConversionLimit(
|
||||
() =>
|
||||
new Promise<Buffer>((resolve, reject) => {
|
||||
const worker = new Worker(workerUrl(), {
|
||||
workerData: { heicInput: imageBytes, quality: HEIC_JPEG_QUALITY } satisfies HeicWorkerInput,
|
||||
resourceLimits: WORKER_RESOURCE_LIMITS,
|
||||
});
|
||||
let settled = false;
|
||||
const settle = (fn: () => void): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
fn();
|
||||
void worker.terminate();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
settle(() => reject(new Error(`HEIC conversion timed out after ${HEIC_CONVERSION_TIMEOUT_MS}ms`)));
|
||||
}, HEIC_CONVERSION_TIMEOUT_MS);
|
||||
worker.on('message', (msg: HeicWorkerResult) => {
|
||||
settle(() => {
|
||||
if (!msg.ok) {
|
||||
reject(new Error(msg.error));
|
||||
return;
|
||||
}
|
||||
const out = Buffer.from(msg.data.buffer, msg.data.byteOffset, msg.data.byteLength);
|
||||
if (out.length > MAX_PASTE_IMAGE_BYTES) {
|
||||
const maxMb = Math.round(MAX_PASTE_IMAGE_BYTES / (1024 * 1024));
|
||||
reject(new Error(`converted JPEG (${out.length} bytes) exceeds the ${maxMb}MB upload limit`));
|
||||
return;
|
||||
}
|
||||
resolve(out);
|
||||
});
|
||||
});
|
||||
worker.on('error', (err) => settle(() => reject(err)));
|
||||
worker.on('exit', (code) => {
|
||||
settle(() => reject(new Error(`HEIC conversion worker exited unexpectedly (code ${code})`)));
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @fileoverview HEIC/HEIF → JPEG conversion core + worker-thread entry.
|
||||
*
|
||||
* Spawned per conversion by `heic-jpeg-converter.ts` so the CPU-synchronous
|
||||
* libheif WASM decode + jpeg-js encode never run on the server's main thread
|
||||
* (on the event loop they would freeze every session's SSE/PTY/WS handling
|
||||
* for seconds per photo). Input arrives via `workerData`; the result (or
|
||||
* error message) is posted back as a single message and the thread exits.
|
||||
*
|
||||
* The conversion logic lives in this same file (exported, guarded bootstrap)
|
||||
* rather than a sibling module: the worker runs from `.ts` source under tsx
|
||||
* in dev, where relative `.js` imports don't resolve inside worker threads —
|
||||
* only `node:` builtins are imported at top level. Unit tests import
|
||||
* `convertHeicBufferToJpeg` directly; the bootstrap only runs when spawned
|
||||
* with our `workerData` shape.
|
||||
*
|
||||
* Decompression-bomb guard: heic-decode's `.all` path exposes the
|
||||
* header-declared {width, height} per image WITHOUT decoding pixels, while
|
||||
* its plain decode path allocates `width * height * 4` bytes straight from
|
||||
* those header values — a <1KB crafted file declaring 30000×30000 would
|
||||
* demand a 3.6GB allocation. We reject anything above MAX_HEIC_DECODE_PIXELS
|
||||
* before calling `decode()`.
|
||||
*/
|
||||
|
||||
import { parentPort, workerData } from 'node:worker_threads';
|
||||
|
||||
/** Max header-declared pixel count we will decode (64MP ≈ 256MB RGBA). */
|
||||
export const MAX_HEIC_DECODE_PIXELS = 64_000_000;
|
||||
|
||||
/** JPEG quality used for converted HEIC uploads (matches heic-convert's default). */
|
||||
export const HEIC_JPEG_QUALITY = 0.92;
|
||||
|
||||
export interface HeicWorkerInput {
|
||||
heicInput: Uint8Array;
|
||||
quality: number;
|
||||
}
|
||||
|
||||
export type HeicWorkerResult = { ok: true; data: Uint8Array } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Convert HEIC/HEIF bytes to JPEG bytes. Throws on non-HEIC input, empty
|
||||
* containers, over-limit dimensions, and non-JPEG encoder output.
|
||||
*/
|
||||
export async function convertHeicBufferToJpeg(input: Uint8Array, quality: number = HEIC_JPEG_QUALITY): Promise<Buffer> {
|
||||
const { default: decode } = await import('heic-decode');
|
||||
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
||||
const images = await decode.all({ buffer });
|
||||
try {
|
||||
if (images.length === 0) throw new Error('no image found in HEIC container');
|
||||
const { width, height } = images[0];
|
||||
if (
|
||||
!Number.isSafeInteger(width) ||
|
||||
!Number.isSafeInteger(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0 ||
|
||||
width * height > MAX_HEIC_DECODE_PIXELS
|
||||
) {
|
||||
throw new Error(
|
||||
`HEIC dimensions ${width}x${height} exceed the ${Math.floor(MAX_HEIC_DECODE_PIXELS / 1_000_000)}MP decode limit`
|
||||
);
|
||||
}
|
||||
const decoded = await images[0].decode();
|
||||
const { encode } = await import('jpeg-js');
|
||||
// Same output path as heic-convert's JPEG format (jpeg-js at quality*100).
|
||||
const jpeg = encode(
|
||||
{ data: decoded.data, width: decoded.width, height: decoded.height },
|
||||
Math.floor(quality * 100)
|
||||
).data;
|
||||
const jpegBytes = Buffer.isBuffer(jpeg) ? jpeg : Buffer.from(jpeg);
|
||||
if (jpegBytes.length < 3 || jpegBytes[0] !== 0xff || jpegBytes[1] !== 0xd8 || jpegBytes[2] !== 0xff) {
|
||||
throw new Error('HEIC conversion did not produce JPEG bytes');
|
||||
}
|
||||
return jpegBytes;
|
||||
} finally {
|
||||
images.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Worker bootstrap ──────────────────────────────────────────────────────
|
||||
// Runs only when spawned by heic-jpeg-converter.ts: requires a parent port
|
||||
// AND our exact workerData shape, so importing this module from the main
|
||||
// thread (or a test runner's own worker pool) stays inert.
|
||||
const request = workerData as HeicWorkerInput | null | undefined;
|
||||
if (parentPort && request && request.heicInput instanceof Uint8Array && typeof request.quality === 'number') {
|
||||
const port = parentPort;
|
||||
try {
|
||||
const jpegBytes = await convertHeicBufferToJpeg(request.heicInput, request.quality);
|
||||
port.postMessage({ ok: true, data: jpegBytes } satisfies HeicWorkerResult);
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
port.postMessage({ ok: false, error } satisfies HeicWorkerResult);
|
||||
}
|
||||
}
|
||||
+286
-53
@@ -8,7 +8,7 @@
|
||||
* - CORS (localhost only)
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { StaleExpirationMap } from '../../utils/index.js';
|
||||
import type { AuthSessionRecord } from '../ports/auth-port.js';
|
||||
@@ -20,6 +20,17 @@ import {
|
||||
AUTH_FAILURE_WINDOW_MS,
|
||||
} from '../../config/auth-config.js';
|
||||
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
|
||||
|
||||
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
|
||||
// ownership helpers default to a synthetic admin (see route-helpers).
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
authUser?: AuthUser;
|
||||
}
|
||||
}
|
||||
|
||||
// Auth session cookie name
|
||||
export const AUTH_COOKIE_NAME = 'codeman_session';
|
||||
@@ -30,6 +41,83 @@ interface AuthState {
|
||||
authFailures: StaleExpirationMap<string, number> | null;
|
||||
qrAuthFailures: StaleExpirationMap<string, number> | null;
|
||||
hookSecretFailures: StaleExpirationMap<string, number> | null;
|
||||
/** Per-username Basic-auth failure bucket (multi-user only). */
|
||||
userFailures: StaleExpirationMap<string, number> | null;
|
||||
}
|
||||
|
||||
/** Rate-limit response for a client that exceeded the failure cap. */
|
||||
function sendAuthRateLimit(reply: FastifyReply, failures: StaleExpirationMap<string, number>, key: string): void {
|
||||
const remainingMs = failures.getRemainingTtl(key) ?? AUTH_FAILURE_WINDOW_MS;
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
|
||||
reply.header('Retry-After', String(retryAfterSeconds));
|
||||
reply.code(429).send('Too Many Requests — try again later');
|
||||
}
|
||||
|
||||
/** Parse a `Basic base64(user:pass)` header into its parts, or null if malformed. */
|
||||
function parseBasicAuth(header?: string): { username: string; password: string } | null {
|
||||
if (!header || !header.startsWith('Basic ')) return null;
|
||||
try {
|
||||
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf-8');
|
||||
const idx = decoded.indexOf(':');
|
||||
if (idx < 0) return null;
|
||||
return { username: decoded.slice(0, idx), password: decoded.slice(idx + 1) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `/api/hook-event` + `/api/status-telemetry` localhost bypass, shared by the
|
||||
* single-user and multi-user auth hooks so the security-critical logic has ONE
|
||||
* source of truth. Returns:
|
||||
* - 'bypass' : loopback + valid hook secret; the caller should allow the request
|
||||
* - 'rejected' : a reply was already sent (wrong secret rate-limited / 401)
|
||||
* - 'continue' : not a hook request (or non-loopback); fall through to normal auth
|
||||
*
|
||||
* COD-91: the shared hook secret is required UNCONDITIONALLY on the loopback bypass
|
||||
* (a user's own loopback reverse proxy is indistinguishable from a real local hook).
|
||||
*/
|
||||
function checkHookSecretBypass(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
hookSecretFailures: StaleExpirationMap<string, number>
|
||||
): 'bypass' | 'rejected' | 'continue' {
|
||||
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
|
||||
const ip = req.ip;
|
||||
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
||||
if (isLoopback) {
|
||||
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
|
||||
const expected = Buffer.from(getHookSecret());
|
||||
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
|
||||
return 'bypass';
|
||||
}
|
||||
const hookIp = req.ip;
|
||||
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
|
||||
if (hookFailures >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, hookSecretFailures, hookIp);
|
||||
return 'rejected';
|
||||
}
|
||||
hookSecretFailures.set(hookIp, hookFailures + 1);
|
||||
reply.code(401).send('Unauthorized: hook secret required');
|
||||
return 'rejected';
|
||||
}
|
||||
// Non-localhost hook requests fall through to normal auth
|
||||
}
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests that a `mustChangePassword` user may still reach: the identity probe,
|
||||
* the password-change endpoint, and any non-API path (static assets / index.html,
|
||||
* so the browser can load the app and render the change-password modal).
|
||||
*/
|
||||
function isPasswordChangeExempt(req: FastifyRequest): boolean {
|
||||
const url = (req.url ?? '').split('?')[0];
|
||||
if (url === '/api/me' || url === '/api/me/password') return true;
|
||||
// Security: the WebSocket terminal (/ws/...) is a functional channel, not a static
|
||||
// asset, so it must NOT be exempt, or a locked user keeps a working terminal.
|
||||
if (url.startsWith('/ws/')) return false;
|
||||
return !url.startsWith('/api/');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,13 +135,20 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
authFailures: null,
|
||||
qrAuthFailures: null,
|
||||
hookSecretFailures: null,
|
||||
userFailures: null,
|
||||
};
|
||||
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
if (!authPassword) return state;
|
||||
// Always declare req.authUser so downstream reads are safe (single-user leaves it
|
||||
// undefined; the ownership helpers then default to a synthetic admin).
|
||||
if (!app.hasRequestDecorator('authUser')) app.decorateRequest('authUser', undefined);
|
||||
|
||||
const authUsername = process.env.CODEMAN_USERNAME || 'admin';
|
||||
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
|
||||
const multiUser = isMultiUserMode();
|
||||
const authPassword = process.env.CODEMAN_PASSWORD;
|
||||
|
||||
// No auth at all: single-user with no password (byte-identical to legacy). In
|
||||
// multi-user mode auth is ALWAYS active (users authenticate individually), even
|
||||
// without CODEMAN_PASSWORD.
|
||||
if (!multiUser && !authPassword) return state;
|
||||
|
||||
// Session token store — active sessions extend TTL on access
|
||||
state.authSessions = new StaleExpirationMap<string, AuthSessionRecord>({
|
||||
@@ -87,57 +182,28 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
const authFailures = state.authFailures;
|
||||
const hookSecretFailures = state.hookSecretFailures;
|
||||
|
||||
function sendAuthRateLimit(
|
||||
reply: FastifyReply,
|
||||
clientIp: string,
|
||||
failures: StaleExpirationMap<string, number> = authFailures
|
||||
): void {
|
||||
const remainingMs = failures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
|
||||
reply.header('Retry-After', String(retryAfterSeconds));
|
||||
reply.code(429).send('Too Many Requests — try again later');
|
||||
if (multiUser) {
|
||||
// Per-username failure bucket: a botnet can't brute-force one account across
|
||||
// many IPs, and one user behind a NAT can't lock out everyone else.
|
||||
state.userFailures = new StaleExpirationMap<string, number>({
|
||||
ttlMs: AUTH_FAILURE_WINDOW_MS,
|
||||
refreshOnGet: false,
|
||||
});
|
||||
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures);
|
||||
return state;
|
||||
}
|
||||
|
||||
// ── Single-user Basic Auth (unchanged behavior; CODEMAN_PASSWORD required) ──
|
||||
const authUsername = process.env.CODEMAN_USERNAME || 'admin';
|
||||
const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
|
||||
|
||||
app.addHook('onRequest', (req, reply, done) => {
|
||||
// Hook events + statusline telemetry come from local Claude Code (curl from
|
||||
// localhost) — no Basic-Auth credentials available. Validated downstream by
|
||||
// HookEventSchema / StatusTelemetrySchema. Same loopback+hook-secret gate.
|
||||
//
|
||||
// COD-54: the bare localhost bypass is unsafe while a tunnel is running, because
|
||||
// `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
|
||||
// loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and
|
||||
// would pass. COD-91: require the shared hook secret on the loopback bypass
|
||||
// UNCONDITIONALLY (not just while the managed tunnel is up). Codeman can't detect
|
||||
// a user's own loopback reverse proxy (their own `cloudflared --url`, `tailscale
|
||||
// serve`, nginx → 127.0.0.1), so tunnel-gating left that path with the unsafe plain
|
||||
// bypass. Managed-session hooks always present the secret (X-Codeman-Hook-Secret,
|
||||
// from $CODEMAN_HOOK_SECRET_FILE — generated for every instance), so requiring it
|
||||
// always closes the gap without breaking the legitimate hook channel.
|
||||
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
|
||||
const ip = req.ip;
|
||||
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
||||
if (isLoopback) {
|
||||
// Always require the shared secret (constant-time compare).
|
||||
const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
|
||||
const expected = Buffer.from(getHookSecret());
|
||||
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
// Wrong/absent secret — rate-limit per IP in the DEDICATED hook bucket
|
||||
// (never authFailures, which would lock out the login path).
|
||||
const hookIp = req.ip;
|
||||
const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
|
||||
if (hookFailures >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, hookIp, hookSecretFailures);
|
||||
return;
|
||||
}
|
||||
hookSecretFailures.set(hookIp, hookFailures + 1);
|
||||
reply.code(401).send('Unauthorized: hook secret required');
|
||||
return;
|
||||
}
|
||||
// Non-localhost hook requests fall through to normal auth
|
||||
const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
|
||||
if (bypass === 'bypass') {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
if (bypass === 'rejected') return;
|
||||
|
||||
// QR auth path — handled by the route itself (token validation + rate limiting)
|
||||
if (req.url?.startsWith('/q/')) {
|
||||
@@ -151,6 +217,15 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
// Use get() instead of has() so refreshOnGet extends the TTL on active sessions
|
||||
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
||||
if (sessionToken && authSessions.get(sessionToken) !== undefined) {
|
||||
// Sliding cookie: re-issue on every authenticated request so the browser
|
||||
// cookie lifetime tracks the server-side sliding TTL (refreshOnGet above).
|
||||
reply.setCookie(AUTH_COOKIE_NAME, sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: https,
|
||||
sameSite: 'lax',
|
||||
maxAge: AUTH_SESSION_TTL_MS / 1000, // seconds
|
||||
path: '/',
|
||||
});
|
||||
done();
|
||||
return;
|
||||
}
|
||||
@@ -193,7 +268,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
// Rate limit only requests that failed to authenticate on this attempt.
|
||||
const failures = authFailures.get(clientIp) ?? 0;
|
||||
if (failures >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, clientIp);
|
||||
sendAuthRateLimit(reply, authFailures, clientIp);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -207,6 +282,164 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-user auth hook (async, because password verification runs scrypt). Verifies
|
||||
* `username:password` against the user store, mints an identity-carrying cookie,
|
||||
* decorates `req.authUser`, enforces the per-IP + per-username rate limits, and the
|
||||
* `mustChangePassword` lockbox. The single-user hook above is left untouched.
|
||||
*/
|
||||
function registerMultiUserAuthHook(
|
||||
app: FastifyInstance,
|
||||
https: boolean,
|
||||
authSessions: StaleExpirationMap<string, AuthSessionRecord>,
|
||||
authFailures: StaleExpirationMap<string, number>,
|
||||
hookSecretFailures: StaleExpirationMap<string, number>,
|
||||
userFailures: StaleExpirationMap<string, number>
|
||||
): void {
|
||||
const setSessionCookie = (reply: FastifyReply, token: string) =>
|
||||
reply.setCookie(AUTH_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: https,
|
||||
sameSite: 'lax',
|
||||
maxAge: AUTH_SESSION_TTL_MS / 1000,
|
||||
path: '/',
|
||||
});
|
||||
|
||||
// Evict the oldest cookie session of the SAME user first (so one user logging in
|
||||
// 100 times cannot flush everyone else's sessions), falling back to global-oldest.
|
||||
const evictForCapacity = (username: string) => {
|
||||
let userKey: string | undefined;
|
||||
let userTs = Infinity;
|
||||
let globalKey: string | undefined;
|
||||
let globalTs = Infinity;
|
||||
for (const [k, v] of authSessions) {
|
||||
if (v.createdAt < globalTs) {
|
||||
globalTs = v.createdAt;
|
||||
globalKey = k;
|
||||
}
|
||||
if (v.username === username && v.createdAt < userTs) {
|
||||
userTs = v.createdAt;
|
||||
userKey = k;
|
||||
}
|
||||
}
|
||||
const key = userKey ?? globalKey;
|
||||
if (key !== undefined) authSessions.delete(key);
|
||||
};
|
||||
|
||||
const enforcePasswordChange = (req: FastifyRequest, reply: FastifyReply, mustChange: boolean): boolean => {
|
||||
if (mustChange && !isPasswordChangeExempt(req)) {
|
||||
reply.code(403).send(createErrorResponse(ApiErrorCode.PASSWORD_CHANGE_REQUIRED));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
|
||||
if (bypass === 'bypass' || bypass === 'rejected') return;
|
||||
|
||||
// QR redemption path — handled by the route itself.
|
||||
if (req.url?.startsWith('/q/')) return;
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// 1. Cookie session (carries identity + mustChangePassword snapshot).
|
||||
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
||||
const record = sessionToken ? authSessions.get(sessionToken) : undefined;
|
||||
if (record && record.username) {
|
||||
// Security: re-validate the cookie identity against the store on every request so
|
||||
// an out-of-band mutation the in-memory map can't see (the `codeman users` CLI,
|
||||
// a separate process, deleting/disabling/demoting a user) takes effect promptly
|
||||
// instead of riding the 24h cookie. findUser is cached ~1s, so this is cheap.
|
||||
let live: Awaited<ReturnType<typeof findUser>>;
|
||||
try {
|
||||
live = await findUser(record.username);
|
||||
} catch {
|
||||
// The store is transiently unreadable/corrupt (readUsers throws on a non-ENOENT
|
||||
// read, #23). Fall back to the cookie's snapshot for THIS request rather than
|
||||
// 500-ing an already-authenticated client (pre-#24 behaviour); a persistently
|
||||
// corrupt store still fails all WRITES loudly at the mutator/bootstrap layer.
|
||||
req.authUser = { username: record.username, role: record.role ?? 'user' };
|
||||
setSessionCookie(reply, sessionToken!);
|
||||
enforcePasswordChange(req, reply, !!record.mustChangePassword);
|
||||
return;
|
||||
}
|
||||
if (!live || live.disabled) {
|
||||
authSessions.delete(sessionToken!);
|
||||
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
||||
reply.code(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
// Trust the LIVE role/mustChangePassword, not the (possibly stale) cookie snapshot
|
||||
// (also defends #9/#13: a CLI demotion is reflected without a revoke).
|
||||
req.authUser = { username: live.username, role: live.role };
|
||||
setSessionCookie(reply, sessionToken!); // sliding re-issue
|
||||
enforcePasswordChange(req, reply, !!live.mustChangePassword);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Basic Auth against the user store (scrypt verify).
|
||||
// Per-IP pre-gate bounds scrypt CPU cost from one source (does NOT gate on the
|
||||
// per-username bucket here; see below).
|
||||
const ipFail = authFailures.get(clientIp) ?? 0;
|
||||
if (ipFail >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, authFailures, clientIp);
|
||||
return;
|
||||
}
|
||||
const creds = parseBasicAuth(req.headers.authorization);
|
||||
if (creds) {
|
||||
const normUser = creds.username.trim().toLowerCase();
|
||||
// Security: VERIFY FIRST, then throttle only FAILED attempts. Consulting the
|
||||
// per-username bucket before verifying let throwaway IPs lock out a known account
|
||||
// (incl. admin) even with the correct password. A correct password must always
|
||||
// win and self-heal both buckets, regardless of the username-failure count.
|
||||
const result = await verifyPassword(creds.username, creds.password);
|
||||
if (result) {
|
||||
const { user, needsRehash: rehash } = result;
|
||||
if (rehash) void setPassword(user.username, creds.password).catch(() => {});
|
||||
void touchLastLogin(user.username).catch(() => {});
|
||||
authFailures.delete(clientIp);
|
||||
userFailures.delete(normUser);
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username);
|
||||
authSessions.set(token, {
|
||||
ip: clientIp,
|
||||
ua: req.headers['user-agent'] ?? '',
|
||||
createdAt: Date.now(),
|
||||
method: 'basic',
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
mustChangePassword: !!user.mustChangePassword,
|
||||
});
|
||||
req.authUser = { username: user.username, role: user.role };
|
||||
setSessionCookie(reply, token);
|
||||
enforcePasswordChange(req, reply, !!user.mustChangePassword);
|
||||
return;
|
||||
}
|
||||
// Failed guess: count it against BOTH buckets. Once the per-username bucket
|
||||
// reaches the cap, further FAILED attempts get 429 (throttles distributed
|
||||
// brute-force), but this path is only reached on a wrong password, so it can
|
||||
// never deny a correct one.
|
||||
const uFail = (userFailures.get(normUser) ?? 0) + 1;
|
||||
userFailures.set(normUser, uFail);
|
||||
authFailures.set(clientIp, ipFail + 1);
|
||||
if (uFail >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, userFailures, normUser);
|
||||
return;
|
||||
}
|
||||
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
|
||||
reply.code(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
// No credentials presented: count against the per-IP bucket and challenge.
|
||||
authFailures.set(clientIp, ipFail + 1);
|
||||
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
|
||||
reply.code(401).send('Unauthorized');
|
||||
});
|
||||
}
|
||||
|
||||
/** Methods that don't change server state and so skip the cross-site Origin check. */
|
||||
const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
|
||||
@@ -39,6 +39,16 @@ export function isLoopbackBindHost(host: string): boolean {
|
||||
*/
|
||||
export const DEFAULT_TRUSTED_HOST_SUFFIXES = ['.ts.net', '.trycloudflare.com', '.cfargotunnel.com'];
|
||||
|
||||
/**
|
||||
* Container-to-host gateway aliases (Docker / Podman). A hook `curl` from INSIDE a
|
||||
* docker case carries `Host: host.docker.internal:<port>` (the derived
|
||||
* CODEMAN_API_URL), so the always-on host guard must allow it or every in-container
|
||||
* hook is blocked 403. These names only resolve to the host from within a
|
||||
* container's network namespace, so they are not a DNS-rebinding surface for a
|
||||
* normal browser. Both engines' aliases are allowed so a mixed fleet keeps working.
|
||||
*/
|
||||
export const DOCKER_HOST_GATEWAY_ALIASES = ['host.docker.internal', 'host.containers.internal'];
|
||||
|
||||
/** Policy inputs for the anti-DNS-rebinding Host allowlist + cross-site Origin guard. */
|
||||
export interface HostPolicy {
|
||||
/** The host the server is bound to (e.g. '127.0.0.1', '0.0.0.0', or a hostname). */
|
||||
@@ -98,6 +108,8 @@ function matchesHost(hostname: string, policy: HostPolicy): boolean {
|
||||
const bind = parseAuthorityHostname(policy.bindHost);
|
||||
if (bind && hostname === bind) return true;
|
||||
if (policy.tunnelHost && hostname === policy.tunnelHost) return true;
|
||||
// Docker/Podman container-to-host gateway aliases (for in-container hook curls).
|
||||
if (DOCKER_HOST_GATEWAY_ALIASES.includes(hostname)) return true;
|
||||
for (const suffix of DEFAULT_TRUSTED_HOST_SUFFIXES) {
|
||||
if (hostname === suffix.slice(1) || hostname.endsWith(suffix)) return true;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,19 @@ export interface AuthSessionRecord {
|
||||
ua: string;
|
||||
createdAt: number;
|
||||
method: 'qr' | 'basic';
|
||||
/**
|
||||
* Multi-user identity carried by the cookie (single-user leaves these unset).
|
||||
* Snapshotted at mint time. Authorization-relevant admin changes (password reset,
|
||||
* disable, delete, role change, bypass-grant change) revoke the user's sessions so
|
||||
* a stale snapshot can't outlive the change; additionally the cookie fast-path
|
||||
* re-reads role/disabled/mustChangePassword live from the store each request, so an
|
||||
* out-of-band CLI mutation also takes effect promptly. See docs/multi-user-plan.md
|
||||
* section 5.
|
||||
*/
|
||||
username?: string;
|
||||
role?: 'admin' | 'user';
|
||||
/** Whether this user must change their password before other actions are allowed. */
|
||||
mustChangePassword?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthPort {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import type { ClaudeMode, NiceConfig } from '../../types.js';
|
||||
import type { StateStore } from '../../state-store.js';
|
||||
import type { TerminalHistoryConfig } from '../../config/terminal-history.js';
|
||||
|
||||
export interface ConfigPort {
|
||||
readonly store: StateStore;
|
||||
@@ -15,8 +16,9 @@ export interface ConfigPort {
|
||||
getGlobalNiceConfig(): Promise<NiceConfig | undefined>;
|
||||
getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record<string, string> } | null>;
|
||||
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
|
||||
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
|
||||
getDefaultClaudeMdPath(): Promise<string | undefined>;
|
||||
getLightState(): unknown;
|
||||
getLightState(identity?: { username: string; role: 'admin' | 'user' }): unknown;
|
||||
getLightSessionsState(): unknown[];
|
||||
startTranscriptWatcher(sessionId: string, transcriptPath: string): void;
|
||||
stopTranscriptWatcher(sessionId: string): void;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* @fileoverview Cron port — exposes the CronService to
|
||||
* route handlers via the shared route context.
|
||||
*/
|
||||
|
||||
import type { CronService } from '../../cron/cron-service.js';
|
||||
|
||||
export interface CronPort {
|
||||
readonly cron: CronService;
|
||||
}
|
||||
@@ -13,3 +13,4 @@ export type { ConfigPort } from './config-port.js';
|
||||
export type { InfraPort, ScheduledRun } from './infra-port.js';
|
||||
export type { AuthPort } from './auth-port.js';
|
||||
export type { OrchestratorPort } from './orchestrator-port.js';
|
||||
export type { CronPort } from './cron-port.js';
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface ScheduledRun {
|
||||
completedTasks: number;
|
||||
totalCost: number;
|
||||
logs: string[];
|
||||
/** Multi-user owner (username) — undefined in single-user mode. Used to scope
|
||||
* list/delete and to downgrade the spawned Session's permission mode. */
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
export interface InfraPort {
|
||||
@@ -33,6 +36,6 @@ export interface InfraPort {
|
||||
readonly teamWatcher: TeamWatcher;
|
||||
readonly tunnelManager: TunnelManager;
|
||||
readonly pushStore: PushSubscriptionStore;
|
||||
startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise<ScheduledRun>;
|
||||
startScheduledRun(prompt: string, workingDir: string, durationMinutes: number, owner?: string): Promise<ScheduledRun>;
|
||||
stopScheduledRun(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* @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
|
||||
*
|
||||
* In single-user mode GET /api/me returns a synthetic admin with multiUser:false,
|
||||
* so none of the admin UI is shown and behavior is unchanged.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const unwrap = (body) => (body && typeof body === 'object' && 'data' in body ? body.data : body);
|
||||
|
||||
async function apiGet(path) {
|
||||
const res = await window.fetch(path, { headers: { Accept: 'application/json' } });
|
||||
return unwrap(await res.json());
|
||||
}
|
||||
async function apiSend(method, path, body) {
|
||||
const res = await window.fetch(path, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
let json = null;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
/* empty body */
|
||||
}
|
||||
return { ok: res.ok, status: res.status, body: json, data: unwrap(json) };
|
||||
}
|
||||
|
||||
// ── Change-password modal ─────────────────────────────────────────────────
|
||||
let cpModal = null;
|
||||
function buildChangePasswordModal() {
|
||||
if (cpModal) return cpModal;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'modal';
|
||||
el.id = 'changePasswordModal';
|
||||
el.style.zIndex = '3100';
|
||||
el.innerHTML = `
|
||||
<div class="modal-content" style="max-width:420px">
|
||||
<div class="modal-header"><h2>Change Password</h2></div>
|
||||
<div class="modal-body">
|
||||
<p id="cpMustNote" class="form-hint" style="display:none;color:var(--warning,#c80)">
|
||||
You must change your password before continuing.</p>
|
||||
<div class="form-row"><label>Current password</label>
|
||||
<input type="password" id="cpCurrent" class="form-input" autocomplete="current-password"></div>
|
||||
<div class="form-row"><label>New password (min 8)</label>
|
||||
<input type="password" id="cpNew" class="form-input" autocomplete="new-password"></div>
|
||||
<div class="form-row"><label>Confirm new password</label>
|
||||
<input type="password" id="cpConfirm" class="form-input" autocomplete="new-password"></div>
|
||||
<p id="cpError" style="color:var(--error,#c33);min-height:1.2em"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" id="cpCancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="cpSubmit">Change password</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(el);
|
||||
el.querySelector('#cpCancel').onclick = () => (el.style.display = 'none');
|
||||
el.querySelector('#cpSubmit').onclick = async () => {
|
||||
const current = el.querySelector('#cpCurrent').value;
|
||||
const nw = el.querySelector('#cpNew').value;
|
||||
const confirm = el.querySelector('#cpConfirm').value;
|
||||
const err = el.querySelector('#cpError');
|
||||
err.textContent = '';
|
||||
if (nw.length < 8) return (err.textContent = 'New password must be at least 8 characters.');
|
||||
if (nw !== confirm) return (err.textContent = 'Passwords do not match.');
|
||||
const r = await apiSend('POST', '/api/me/password', { currentPassword: current, newPassword: nw });
|
||||
if (!r.ok) return (err.textContent = (r.body && r.body.error) || 'Change failed.');
|
||||
el.style.display = 'none';
|
||||
if (window.app && window.app.showToast) window.app.showToast('Password changed');
|
||||
};
|
||||
cpModal = el;
|
||||
return el;
|
||||
}
|
||||
function openChangePassword(forced) {
|
||||
const el = buildChangePasswordModal();
|
||||
el.querySelector('#cpMustNote').style.display = forced ? '' : 'none';
|
||||
el.querySelector('#cpCancel').style.display = forced ? 'none' : '';
|
||||
el.querySelector('#cpError').textContent = '';
|
||||
el.style.display = 'flex';
|
||||
}
|
||||
|
||||
// ── Fetch interceptor: surface PASSWORD_CHANGE_REQUIRED ───────────────────
|
||||
function installInterceptor() {
|
||||
const orig = window.fetch;
|
||||
window.fetch = async function (...args) {
|
||||
const res = await orig.apply(this, args);
|
||||
if (res.status === 403) {
|
||||
try {
|
||||
const clone = res.clone();
|
||||
const j = await clone.json();
|
||||
if (j && j.errorCode === 'PASSWORD_CHANGE_REQUIRED') openChangePassword(true);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Admin Users panel (injected into the App Settings modal) ──────────────
|
||||
function injectUsersTab() {
|
||||
const modal = document.getElementById('appSettingsModal');
|
||||
if (!modal || modal.querySelector('[data-tab="settings-users"]')) return;
|
||||
const tabs = modal.querySelector('.modal-tabs');
|
||||
const body = modal.querySelector('.modal-body');
|
||||
if (!tabs || !body) return;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'modal-tab-btn';
|
||||
btn.dataset.tab = 'settings-users';
|
||||
btn.textContent = 'Users';
|
||||
tabs.appendChild(btn);
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal-tab-content hidden';
|
||||
content.id = 'settings-users';
|
||||
content.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<strong>Users</strong>
|
||||
<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>
|
||||
<div id="adminUsersTable"></div>
|
||||
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--muted,#888)"></p>`;
|
||||
body.appendChild(content);
|
||||
// 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) {
|
||||
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
||||
}
|
||||
|
||||
async function renderUsers() {
|
||||
const table = document.getElementById('adminUsersTable');
|
||||
if (!table) return;
|
||||
table.innerHTML = 'Loading…';
|
||||
let users;
|
||||
try {
|
||||
users = await apiGet('/api/admin/users');
|
||||
} catch {
|
||||
table.innerHTML = 'Failed to load users.';
|
||||
return;
|
||||
}
|
||||
const rows = users
|
||||
.map((u) => {
|
||||
const flags = [
|
||||
u.role === 'admin' ? 'admin' : 'user',
|
||||
u.disabled ? 'disabled' : 'enabled',
|
||||
u.canBypassPermissions ? 'can-bypass' : '',
|
||||
u.mustChangePassword ? 'must-change-pw' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const st = u.stats || {};
|
||||
return `<tr data-u="${esc(u.username)}">
|
||||
<td>${esc(u.username)}</td>
|
||||
<td style="font-size:.85em;color:var(--muted,#888)">${esc(flags)}</td>
|
||||
<td style="font-size:.85em">${st.liveSessions ?? 0} live · ${st.caseCount ?? 0} cases</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="delete">Delete</button>
|
||||
</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">Flags</th><th align="left">Usage</th><th></th></tr></thead>
|
||||
<tbody>${rows}</tbody></table>`;
|
||||
table.querySelectorAll('button[data-act]').forEach((b) => {
|
||||
b.onclick = () =>
|
||||
userAction(
|
||||
b.closest('tr').dataset.u,
|
||||
b.dataset.act,
|
||||
users.find((x) => x.username === b.closest('tr').dataset.u)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function setMsg(t) {
|
||||
const m = document.getElementById('adminUsersMsg');
|
||||
if (m) m.textContent = t || '';
|
||||
}
|
||||
|
||||
async function userAction(username, act, u) {
|
||||
if (act === 'role') {
|
||||
const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, {
|
||||
role: u.role === 'admin' ? 'user' : 'admin',
|
||||
});
|
||||
setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'disabled') {
|
||||
const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, { disabled: !u.disabled });
|
||||
setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'bypass') {
|
||||
const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, {
|
||||
canBypassPermissions: !u.canBypassPermissions,
|
||||
});
|
||||
setMsg(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', `/api/admin/users/${encodeURIComponent(username)}/reset-password`);
|
||||
if (r.ok && r.data && r.data.oneTimePassword) {
|
||||
window.prompt(`One-time password for ${username} (copy it now — shown once):`, r.data.oneTimePassword);
|
||||
} else setMsg((r.body && r.body.error) || 'Reset failed.');
|
||||
} else if (act === 'delete') {
|
||||
const typed = window.prompt(`Type "${username}" to delete this user. Add " +space" to also delete their files.`);
|
||||
if (typed !== username && typed !== `${username} +space`) return setMsg('Delete cancelled.');
|
||||
const deleteSpace = typed.endsWith(' +space');
|
||||
const r = await apiSend('DELETE', `/api/admin/users/${encodeURIComponent(username)}`, { deleteSpace });
|
||||
setMsg(r.ok ? `Deleted ${username}.` : (r.body && r.body.error) || 'Delete failed.');
|
||||
}
|
||||
renderUsers();
|
||||
}
|
||||
|
||||
async function addUserFlow() {
|
||||
const username = window.prompt('New username (lowercase, 2-32 chars, [a-z0-9_-]):');
|
||||
if (!username) return;
|
||||
const admin = window.confirm('Make this user an admin? (OK = admin, Cancel = regular user)');
|
||||
const r = await apiSend('POST', '/api/admin/users', { username: username.trim(), role: admin ? 'admin' : 'user' });
|
||||
if (r.ok && r.data && r.data.oneTimePassword) {
|
||||
window.prompt(`Created ${username}. One-time password (copy it now — shown once):`, r.data.oneTimePassword);
|
||||
} else setMsg((r.body && r.body.error) || 'Create failed.');
|
||||
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();
|
||||
let me = null;
|
||||
try {
|
||||
me = await apiGet('/api/me');
|
||||
} catch {
|
||||
/* server may be pre-auth */
|
||||
}
|
||||
window.__codemanUser = me || { username: 'admin', role: 'admin', multiUser: false };
|
||||
document.dispatchEvent(new CustomEvent('codeman:me', { detail: window.__codemanUser }));
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
|
||||
window.codemanAdmin = { openChangePassword, renderUsers, openAdminPanel, onUsersChanged };
|
||||
})();
|
||||
+1067
-164
File diff suppressed because it is too large
Load Diff
@@ -111,6 +111,36 @@ function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK
|
||||
return recent.length >= config.LONGTASK_COUNT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision for whether to skip the WebGL renderer at terminal init, and
|
||||
* whether to clear the auto-fallback sticky marker. Keeps the interaction
|
||||
* between device type, URL params, the sticky marker, and the user's settings
|
||||
* toggle in one testable place (terminal-ui.js calls this).
|
||||
*
|
||||
* Precedence (desktop only — mobile always skips):
|
||||
* 1. user toggle OFF -> skip (one-shot opt-out, sticky untouched)
|
||||
* 2. ?nowebgl -> skip (one-shot opt-out, sticky untouched)
|
||||
* 3. ?webgl=force -> enable + clear stale sticky marker
|
||||
* 4. toggle ON / untouched -> respect the auto-fallback sticky marker
|
||||
*
|
||||
* A stored `true` is treated like the untouched default here: the checkbox
|
||||
* ships checked on desktop, so any unrelated settings save stores `true` —
|
||||
* letting it clear the marker would permanently defeat the GPU-stall
|
||||
* auto-fallback safety net. The marker is only retired by ?webgl=force or by
|
||||
* a real OFF->ON toggle flip, which saveAppSettings() detects at save time.
|
||||
*
|
||||
* @param {{deviceType?: string, noWebglParam?: boolean, forceParam?: boolean,
|
||||
* stickyDisabled?: boolean, userPrefEnabled?: (boolean|undefined)}} [input]
|
||||
* @returns {{skip: boolean, clearSticky: boolean}}
|
||||
*/
|
||||
function shouldSkipWebGL(input = {}) {
|
||||
if (input.deviceType !== 'desktop') return { skip: true, clearSticky: false };
|
||||
if (input.userPrefEnabled === false) return { skip: true, clearSticky: false };
|
||||
if (input.noWebglParam) return { skip: true, clearSticky: false };
|
||||
if (input.forceParam) return { skip: false, clearSticky: true };
|
||||
return { skip: !!input.stickyDisabled, clearSticky: false };
|
||||
}
|
||||
|
||||
// Expose for tests. `const` declarations at the top of a non-module script
|
||||
// are global lexical bindings but not `window` properties, so explicit
|
||||
// assignment is the test-visible API surface.
|
||||
@@ -126,12 +156,40 @@ function shouldAutoWrapTabs(input) {
|
||||
return scrollWidth > clientWidth + 1;
|
||||
}
|
||||
|
||||
// COD-134 — Terminal WebSocket reconnect policy.
|
||||
//
|
||||
// Decide what to do after a terminal WebSocket closes, given the close `code`
|
||||
// and `attempt` (0-based count of consecutive reconnects already made):
|
||||
// - transient closes (code < 4004: 1000/1001/1005/1006/etc.) → 'reconnect'
|
||||
// with exponential backoff (0 on the first attempt; the caller adds jitter),
|
||||
// 250ms → 500 → 1000 → ... capped at 10s.
|
||||
// - 4004 (session not found) / 4009 (session terminated) → 'give-up': the
|
||||
// session is gone, retrying only wastes connections.
|
||||
// - 4008 (too many connections) and any other code >= 4004 → 'retry-fallback':
|
||||
// show the HTTP fallback but keep retrying on a bounded 5s timer so the
|
||||
// transport returns to WS once the transient condition clears (un-stick).
|
||||
// Pure: no DOM, no side effects.
|
||||
function planWsReconnect(code, attempt) {
|
||||
if (code === 4004 || code === 4009) {
|
||||
return { action: 'give-up', delayMs: 0 };
|
||||
}
|
||||
if (code >= 4004) {
|
||||
return { action: 'retry-fallback', delayMs: 5000 };
|
||||
}
|
||||
const delayMs = attempt <= 0 ? 0 : Math.min(250 * Math.pow(2, attempt - 1), 10000);
|
||||
return { action: 'reconnect', delayMs };
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||
window.shouldSkipWebGL = shouldSkipWebGL;
|
||||
window.CodemanTabOverflow = {
|
||||
shouldAutoWrapTabs,
|
||||
};
|
||||
window.CodemanWsReconnect = {
|
||||
plan: planWsReconnect,
|
||||
};
|
||||
}
|
||||
|
||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||
@@ -262,7 +320,9 @@ const SSE_EVENTS = {
|
||||
SESSION_LIMIT_PAUSE_SCHEDULED: 'session:limitPauseScheduled',
|
||||
SESSION_LIMIT_RESUME: 'session:limitResume',
|
||||
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
|
||||
SESSION_RESPAWN_BREAKER_TRIPPED: 'session:respawnBreakerTripped',
|
||||
SESSION_CLI_INFO: 'session:cliInfo',
|
||||
SESSION_PINNED: 'session:pinned',
|
||||
SESSION_MESSAGE: 'session:message',
|
||||
SESSION_INTERACTIVE: 'session:interactive',
|
||||
SESSION_RUNNING: 'session:running',
|
||||
@@ -276,6 +336,12 @@ const SSE_EVENTS = {
|
||||
SCHEDULED_LOG: 'scheduled:log',
|
||||
SCHEDULED_DELETED: 'scheduled:deleted',
|
||||
|
||||
// Cron jobs
|
||||
CRON_JOBS_CHANGED: 'cron:jobsChanged',
|
||||
CRON_JOB_DELETED: 'cron:jobDeleted',
|
||||
CRON_RUN_CREATED: 'cron:runCreated',
|
||||
CRON_RUN_UPDATED: 'cron:runUpdated',
|
||||
|
||||
// Respawn
|
||||
RESPAWN_STARTED: 'respawn:started',
|
||||
RESPAWN_STOPPED: 'respawn:stopped',
|
||||
@@ -314,6 +380,11 @@ const SSE_EVENTS = {
|
||||
MUX_DIED: 'mux:died',
|
||||
MUX_STATS_UPDATED: 'mux:statsUpdated',
|
||||
|
||||
// Remote auto-reconnect (COD-108)
|
||||
REMOTE_SESSION_DROPPED: 'remote:sessionDropped',
|
||||
REMOTE_SESSION_RECONNECTED: 'remote:sessionReconnected',
|
||||
REMOTE_RECONNECT_EXHAUSTED: 'remote:reconnectExhausted',
|
||||
|
||||
// Ralph
|
||||
SESSION_RALPH_LOOP_UPDATE: 'session:ralphLoopUpdate',
|
||||
SESSION_RALPH_TODO_UPDATE: 'session:ralphTodoUpdate',
|
||||
@@ -409,6 +480,20 @@ const SSE_EVENTS = {
|
||||
CASE_LINKED: 'case:linked',
|
||||
CASE_DELETED: 'case:deleted',
|
||||
CASE_ORDER_CHANGED: 'case:order-changed',
|
||||
DOCKER_EXPORT_COMPLETE: 'docker:exportComplete',
|
||||
DOCKER_EXPORT_FAILED: 'docker:exportFailed',
|
||||
DOCKER_IMPORT_COMPLETE: 'docker:importComplete',
|
||||
DOCKER_IMAGE_BUILD_STARTED: 'docker:imageBuildStarted',
|
||||
DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress',
|
||||
DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete',
|
||||
DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed',
|
||||
// Multi-user (admin-only / targeted)
|
||||
ADMIN_USERS_CHANGED: 'admin:usersChanged',
|
||||
AUTH_PASSWORD_CHANGE_REQUIRED: 'auth:passwordChangeRequired',
|
||||
DOCKER_CONTAINER_RECREATED: 'docker:containerRecreated',
|
||||
|
||||
// Session order (global tab order sync)
|
||||
SESSION_ORDER_CHANGED: 'session:orderChanged',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* @fileoverview Cron Jobs UI mixed into
|
||||
* CodemanApp.prototype. Renders the job list + create/edit form in the
|
||||
* #cronModal, and reacts to cron:* SSE events.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js, api-client.js, constants.js (escapeHtml)
|
||||
*/
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ── SSE handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
_onCronJobsChanged(data) {
|
||||
if (data && Array.isArray(data.jobs)) {
|
||||
this._cronJobs = data.jobs;
|
||||
if (this._isCronOpen()) this.renderCronJobs();
|
||||
} else if (this._isCronOpen()) {
|
||||
this.refreshCron();
|
||||
}
|
||||
},
|
||||
|
||||
_onCronRunChanged() {
|
||||
// A run's status changed — refresh the list so lastStatus stays current.
|
||||
if (this._isCronOpen()) this.refreshCron();
|
||||
},
|
||||
|
||||
// ── Modal open/close ──────────────────────────────────────────────────────
|
||||
|
||||
_isCronOpen() {
|
||||
const el = document.getElementById('cronModal');
|
||||
return !!el && el.classList.contains('active');
|
||||
},
|
||||
|
||||
openCron() {
|
||||
const el = document.getElementById('cronModal');
|
||||
if (!el) return;
|
||||
el.classList.add('active');
|
||||
this.cancelCronJobForm();
|
||||
this.refreshCron();
|
||||
},
|
||||
|
||||
closeCron() {
|
||||
const el = document.getElementById('cronModal');
|
||||
if (el) el.classList.remove('active');
|
||||
},
|
||||
|
||||
async refreshCron() {
|
||||
const jobs = await this._apiJson('/api/cron/jobs');
|
||||
this._cronJobs = Array.isArray(jobs) ? jobs : [];
|
||||
this.renderCronJobs();
|
||||
},
|
||||
|
||||
// ── List rendering ────────────────────────────────────────────────────────
|
||||
|
||||
renderCronJobs() {
|
||||
const list = document.getElementById('cronJobList');
|
||||
if (!list) return;
|
||||
const jobs = this._cronJobs || [];
|
||||
if (jobs.length === 0) {
|
||||
list.innerHTML = '<div class="form-hint">No cron jobs yet. Click “+ New Job”.</div>';
|
||||
return;
|
||||
}
|
||||
const rows = jobs.map((j) => {
|
||||
const next = j.enabled ? this._fmtTime(j.nextRunAt) : '—';
|
||||
const last = this._fmtTime(j.lastRunAt);
|
||||
const status = j.lastStatus ? escapeHtml(j.lastStatus) : '—';
|
||||
return `
|
||||
<div class="cron-job-row">
|
||||
<div class="cron-job-main">
|
||||
<div class="cron-job-name">${escapeHtml(j.name || '(unnamed)')}
|
||||
<span class="cron-badge">${escapeHtml(j.agentType)}</span>
|
||||
<span class="cron-badge">${escapeHtml(this._fmtSchedule(j))}</span>
|
||||
${j.enabled ? '' : '<span class="cron-badge cron-badge-off">disabled</span>'}
|
||||
</div>
|
||||
<div class="cron-job-meta">
|
||||
<span title="${escapeHtml(j.workingDir || '')}">${escapeHtml(j.workingDir || '')}</span>
|
||||
· next: ${escapeHtml(next)} · last: ${escapeHtml(last)} · status: ${status}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cron-job-actions">
|
||||
<button class="btn-toolbar btn-sm btn-primary" onclick="app.runCronJob('${j.id}')">Run Now</button>
|
||||
<button class="btn-toolbar btn-sm" onclick="app.toggleCronJob('${j.id}', ${j.enabled ? 'false' : 'true'})">${j.enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button class="btn-toolbar btn-sm" onclick="app.editCronJob('${j.id}')">Edit</button>
|
||||
<button class="btn-toolbar btn-sm btn-danger" onclick="app.deleteCronJob('${j.id}')">Delete</button>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
list.innerHTML = rows.join('');
|
||||
},
|
||||
|
||||
_fmtTime(ts) {
|
||||
if (!ts) return '—';
|
||||
try {
|
||||
return new Date(ts).toLocaleString();
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
},
|
||||
|
||||
_fmtSchedule(j) {
|
||||
switch (j.scheduleType) {
|
||||
case 'once':
|
||||
return 'once';
|
||||
case 'interval':
|
||||
return `every ${j.intervalMinutes}m`;
|
||||
case 'daily':
|
||||
return `daily ${j.dailyTime || ''}`;
|
||||
case 'weekly': {
|
||||
const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const days = (j.weeklyDays || []).map((d) => names[d] || d).join(',');
|
||||
return `weekly ${days} ${j.weeklyTime || ''}`;
|
||||
}
|
||||
default:
|
||||
return j.scheduleType || '';
|
||||
}
|
||||
},
|
||||
|
||||
// ── Create / edit form ────────────────────────────────────────────────────
|
||||
|
||||
openCronJobForm(job) {
|
||||
const form = document.getElementById('cronJobForm');
|
||||
if (!form) return;
|
||||
document.getElementById('cronFormError').textContent = '';
|
||||
document.getElementById('cronFormTitle').textContent = job ? 'Edit Cron Job' : 'New Cron Job';
|
||||
document.getElementById('schJobId').value = job ? job.id : '';
|
||||
document.getElementById('schName').value = job ? job.name || '' : '';
|
||||
document.getElementById('schAgentType').value = job ? job.agentType || 'claude' : 'claude';
|
||||
document.getElementById('schWorkingDir').value = job ? job.workingDir || '' : '';
|
||||
document.getElementById('schLaunchCommand').value = job ? job.launchCommand || '' : '';
|
||||
document.getElementById('schPromptMode').value = job ? job.promptMode || 'inline_text' : 'inline_text';
|
||||
document.getElementById('schPromptText').value = job ? job.promptText || '' : '';
|
||||
document.getElementById('schPromptFilePath').value = job ? job.promptFilePath || '' : '';
|
||||
document.getElementById('schInputMode').value = job ? job.inputMode || 'typed' : 'typed';
|
||||
document.getElementById('schScheduleType').value = job ? job.scheduleType || 'once' : 'once';
|
||||
document.getElementById('schRunAt').value = job && job.runAt ? this._toLocalInput(job.runAt) : '';
|
||||
document.getElementById('schIntervalMinutes').value = job && job.intervalMinutes ? job.intervalMinutes : 60;
|
||||
document.getElementById('schDailyTime').value = job ? job.dailyTime || '' : '';
|
||||
document.getElementById('schWeeklyTime').value = job ? job.weeklyTime || '' : '';
|
||||
const weekly = (job && job.weeklyDays) || [];
|
||||
document.querySelectorAll('#schWeeklyDays input[type=checkbox]').forEach((cb) => {
|
||||
cb.checked = weekly.includes(Number(cb.value));
|
||||
});
|
||||
document.getElementById('schConcurrencyPolicy').value = job ? job.concurrencyPolicy || 'warn_only' : 'warn_only';
|
||||
document.getElementById('schAutoClosePrev').checked = job ? job.autoClosePreviousSession !== false : true;
|
||||
document.getElementById('schEnabled').checked = job ? !!job.enabled : true;
|
||||
document.getElementById('schNotes').value = job ? job.notes || '' : '';
|
||||
|
||||
this.onCronAgentTypeChange();
|
||||
this.onCronPromptModeChange();
|
||||
this.onCronScheduleTypeChange();
|
||||
form.classList.remove('hidden');
|
||||
},
|
||||
|
||||
editCronJob(id) {
|
||||
const job = (this._cronJobs || []).find((j) => j.id === id);
|
||||
if (job) this.openCronJobForm(job);
|
||||
},
|
||||
|
||||
cancelCronJobForm() {
|
||||
const form = document.getElementById('cronJobForm');
|
||||
if (form) form.classList.add('hidden');
|
||||
},
|
||||
|
||||
onCronAgentTypeChange() {
|
||||
// Launch command is only meaningful for shell mode (first input line).
|
||||
const isShell = document.getElementById('schAgentType').value === 'shell';
|
||||
document.getElementById('schLaunchCommandRow').classList.toggle('hidden', !isShell);
|
||||
},
|
||||
|
||||
onCronPromptModeChange() {
|
||||
const mode = document.getElementById('schPromptMode').value;
|
||||
document.getElementById('schPromptTextRow').classList.toggle('hidden', mode !== 'inline_text');
|
||||
document.getElementById('schPromptFileRow').classList.toggle('hidden', mode !== 'prompt_file_path');
|
||||
},
|
||||
|
||||
onCronScheduleTypeChange() {
|
||||
const t = document.getElementById('schScheduleType').value;
|
||||
document.getElementById('schRunAtRow').classList.toggle('hidden', t !== 'once');
|
||||
document.getElementById('schIntervalRow').classList.toggle('hidden', t !== 'interval');
|
||||
document.getElementById('schDailyRow').classList.toggle('hidden', t !== 'daily');
|
||||
document.getElementById('schWeeklyDaysRow').classList.toggle('hidden', t !== 'weekly');
|
||||
document.getElementById('schWeeklyTimeRow').classList.toggle('hidden', t !== 'weekly');
|
||||
},
|
||||
|
||||
_toLocalInput(ts) {
|
||||
// epoch-ms → 'YYYY-MM-DDTHH:MM' in local time for <input datetime-local>.
|
||||
const d = new Date(ts);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
},
|
||||
|
||||
_collectCronForm() {
|
||||
const t = document.getElementById('schScheduleType').value;
|
||||
const promptMode = document.getElementById('schPromptMode').value;
|
||||
const body = {
|
||||
name: document.getElementById('schName').value.trim(),
|
||||
agentType: document.getElementById('schAgentType').value,
|
||||
workingDir: document.getElementById('schWorkingDir').value.trim(),
|
||||
promptMode,
|
||||
inputMode: document.getElementById('schInputMode').value,
|
||||
scheduleType: t,
|
||||
concurrencyPolicy: document.getElementById('schConcurrencyPolicy').value,
|
||||
autoClosePreviousSession: document.getElementById('schAutoClosePrev').checked,
|
||||
enabled: document.getElementById('schEnabled').checked,
|
||||
notes: document.getElementById('schNotes').value.trim() || undefined,
|
||||
};
|
||||
// Always sent for shell (an emptied field must clear a saved command on edit).
|
||||
if (body.agentType === 'shell') body.launchCommand = document.getElementById('schLaunchCommand').value.trim();
|
||||
if (promptMode === 'inline_text') {
|
||||
// Prompt delivery is single-line only; trailing newlines are harmless, strip them.
|
||||
body.promptText = document.getElementById('schPromptText').value.replace(/[\r\n]+$/, '');
|
||||
} else {
|
||||
body.promptFilePath = document.getElementById('schPromptFilePath').value.trim();
|
||||
}
|
||||
|
||||
if (t === 'once') {
|
||||
const v = document.getElementById('schRunAt').value;
|
||||
body.runAt = v ? new Date(v).getTime() : undefined;
|
||||
} else if (t === 'interval') {
|
||||
body.intervalMinutes = Number(document.getElementById('schIntervalMinutes').value);
|
||||
} else if (t === 'daily') {
|
||||
body.dailyTime = document.getElementById('schDailyTime').value;
|
||||
} else if (t === 'weekly') {
|
||||
body.weeklyTime = document.getElementById('schWeeklyTime').value;
|
||||
body.weeklyDays = Array.from(document.querySelectorAll('#schWeeklyDays input:checked')).map((cb) =>
|
||||
Number(cb.value)
|
||||
);
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async saveCronJob() {
|
||||
const errEl = document.getElementById('cronFormError');
|
||||
errEl.textContent = '';
|
||||
const body = this._collectCronForm();
|
||||
if (!body.name) {
|
||||
errEl.textContent = 'Name is required.';
|
||||
return;
|
||||
}
|
||||
if (!body.workingDir) {
|
||||
errEl.textContent = 'Working directory is required.';
|
||||
return;
|
||||
}
|
||||
if (body.promptText !== undefined && /[\r\n]/.test(body.promptText)) {
|
||||
errEl.textContent = 'Prompt must be a single line — multi-line prompts are not supported.';
|
||||
return;
|
||||
}
|
||||
const id = document.getElementById('schJobId').value;
|
||||
const res = id ? await this._apiPut(`/api/cron/jobs/${id}`, body) : await this._apiPost('/api/cron/jobs', body);
|
||||
if (!res || !res.ok) {
|
||||
let msg = 'Failed to save job.';
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (j && j.error) msg = j.error;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
errEl.textContent = msg;
|
||||
return;
|
||||
}
|
||||
this.showToast?.(id ? 'Cron job updated' : 'Cron job created', 'success');
|
||||
this.cancelCronJobForm();
|
||||
this.refreshCron();
|
||||
},
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────
|
||||
|
||||
async runCronJob(id) {
|
||||
const job = (this._cronJobs || []).find((j) => j.id === id);
|
||||
if (job) {
|
||||
const active = this._countActiveAgents(job.agentType);
|
||||
if (active > 0 && !confirm(`${active} ${job.agentType} session(s) already active. Run this job anyway?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const res = await this._apiPost(`/api/cron/jobs/${id}/run`, {});
|
||||
if (res && res.ok) {
|
||||
this.showToast?.('Run started — opening session', 'success');
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const run = data && (data.data ? data.data.run : data.run);
|
||||
if (run && run.sessionId) this._focusCronSession(run.sessionId);
|
||||
this.refreshCron();
|
||||
} else {
|
||||
this.showToast?.('Failed to run job', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
_focusCronSession(sessionId) {
|
||||
// Best-effort: switch to the created session tab if it exists.
|
||||
if (this.sessions && this.sessions.has(sessionId) && typeof this.switchSession === 'function') {
|
||||
this.closeCron();
|
||||
this.switchSession(sessionId);
|
||||
}
|
||||
},
|
||||
|
||||
_countActiveAgents(agentType) {
|
||||
// Mirrors the server's countActiveAgents: only LIVE sessions count — a
|
||||
// tab whose CLI already exited (stopped/error) doesn't block anything.
|
||||
if (!this.sessions) return 0;
|
||||
let n = 0;
|
||||
for (const s of this.sessions.values()) {
|
||||
if (s && s.mode === agentType && s.status !== 'stopped' && s.status !== 'error') n++;
|
||||
}
|
||||
return n;
|
||||
},
|
||||
|
||||
async toggleCronJob(id, enabled) {
|
||||
const res = await this._apiPut(`/api/cron/jobs/${id}/enabled`, { enabled });
|
||||
if (res && res.ok) this.refreshCron();
|
||||
else this.showToast?.('Failed to update job', 'error');
|
||||
},
|
||||
|
||||
async deleteCronJob(id) {
|
||||
if (!confirm('Delete this cron job and its run history?')) return;
|
||||
const res = await this._apiDelete(`/api/cron/jobs/${id}`);
|
||||
if (res && res.ok) {
|
||||
this.showToast?.('Cron job deleted', 'success');
|
||||
this.refreshCron();
|
||||
} else {
|
||||
this.showToast?.('Failed to delete job', 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -4449,6 +4449,7 @@ var GestureController = class {
|
||||
// packages/gesture-control/src/codeman/entry.ts
|
||||
var TAB_SELECTOR = ".session-tab";
|
||||
var PANEL_SELECTOR = ".cg-float";
|
||||
var WINDOW_SELECTOR = ".subagent-window, .ultracode-window";
|
||||
var DOCK_SELECTOR = ".session-tabs";
|
||||
var CLICK_SELECTOR = "#runBtn, .btn-shell";
|
||||
var Z2 = 2147483e3;
|
||||
@@ -4476,6 +4477,8 @@ var GestureBridge = class {
|
||||
__publicField(this, "taps", /* @__PURE__ */ new Map());
|
||||
/** Live floating panels, keyed by session id (idempotent per id). */
|
||||
__publicField(this, "floats", /* @__PURE__ */ new Map());
|
||||
/** rAF coalescing for connector-line redraws while dragging an agent window. */
|
||||
__publicField(this, "connectorRedrawScheduled", false);
|
||||
injectStyles();
|
||||
this.surface = el("div", "cg-surface");
|
||||
this.canvas = el("canvas", "cg-canvas");
|
||||
@@ -4530,7 +4533,7 @@ var GestureBridge = class {
|
||||
await this.gc.start();
|
||||
this.running = true;
|
||||
this.button.classList.add("on");
|
||||
this.status.textContent = "on \u2014 pinch a tab or button";
|
||||
this.status.textContent = "on \u2014 pinch a tab, window, or button";
|
||||
} catch (err) {
|
||||
const msg = describeError(err);
|
||||
this.status.textContent = `failed: ${msg}`;
|
||||
@@ -4575,6 +4578,16 @@ var GestureBridge = class {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const win = this.hitClosest(x2, y2, WINDOW_SELECTOR);
|
||||
if (win) {
|
||||
const rect = win.getBoundingClientRect();
|
||||
win.style.bottom = "auto";
|
||||
win.classList.add("cg-win-grabbed");
|
||||
this.bringWindowToFront(win);
|
||||
this.grabs.set(hand, { kind: "window", el: win, dx: x2 - rect.left, dy: y2 - rect.top });
|
||||
this.status.textContent = "moving window";
|
||||
return;
|
||||
}
|
||||
const tab = this.hitClosest(x2, y2, TAB_SELECTOR);
|
||||
const id = tab?.dataset.id;
|
||||
if (tab && id) {
|
||||
@@ -4620,11 +4633,15 @@ var GestureBridge = class {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === "window") {
|
||||
this.moveWindow(grab.el, x2 - grab.dx, y2 - grab.dy);
|
||||
return;
|
||||
}
|
||||
const tap = this.taps.get(hand);
|
||||
if (tap && Math.hypot(x2 - tap.ox, y2 - tap.oy) > TAP_CANCEL_PX) {
|
||||
tap.el.classList.remove("cg-tap-armed");
|
||||
this.taps.delete(hand);
|
||||
this.status.textContent = "on \u2014 pinch a tab or button";
|
||||
this.status.textContent = "on \u2014 pinch a tab, window, or button";
|
||||
}
|
||||
}
|
||||
onDrop(hand, x2, y2) {
|
||||
@@ -4645,6 +4662,18 @@ var GestureBridge = class {
|
||||
else this.flash("placed");
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === "window") {
|
||||
this.grabs.delete(hand);
|
||||
grab.el.classList.remove("cg-win-grabbed");
|
||||
this.connectorRedrawScheduled = false;
|
||||
this.redrawWindowConnectors();
|
||||
try {
|
||||
window.app?.saveSubagentWindowStates?.();
|
||||
} catch {
|
||||
}
|
||||
this.flash("placed window");
|
||||
return;
|
||||
}
|
||||
const tap = this.taps.get(hand);
|
||||
if (tap) {
|
||||
this.taps.delete(hand);
|
||||
@@ -4694,6 +4723,54 @@ var GestureBridge = class {
|
||||
float.el.style.left = `${l}px`;
|
||||
float.el.style.top = `${t2}px`;
|
||||
}
|
||||
/** Move a dashboard-owned agent window by its top-left, clamped on-screen, then
|
||||
* redraw its connector line. The window self-positions via `style.left/top` and
|
||||
* app.js's connector redraw reads live rects, so this tracks without touching
|
||||
* app.js internals. Guards on `isConnected`: ultracode windows can be torn down
|
||||
* (SSE reconnect / auto-close) while still held. Clamps to `innerWidth/Height`,
|
||||
* which equals the *spanned* viewport in a multi-monitor window — so the window
|
||||
* can still travel across the physical monitor seam, just not off-screen. */
|
||||
moveWindow(el2, left, top) {
|
||||
if (!el2.isConnected) return;
|
||||
const w2 = el2.offsetWidth || 380;
|
||||
const h2 = el2.offsetHeight || 320;
|
||||
const l = Math.min(Math.max(4, left), Math.max(4, window.innerWidth - w2 - 4));
|
||||
const t2 = Math.min(Math.max(4, top), Math.max(4, window.innerHeight - h2 - 4));
|
||||
el2.style.left = `${l}px`;
|
||||
el2.style.top = `${t2}px`;
|
||||
this.redrawWindowConnectors();
|
||||
}
|
||||
/** Ask app.js to redraw all connector lines (subagent + ultracode), coalesced to
|
||||
* one per frame so per-frame drags don't thrash. `updateConnectionLines()` is
|
||||
* itself debounced in app.js, but we rAF-gate too in case an older dashboard
|
||||
* build isn't, and to no-op cleanly when app.js isn't present (standalone). */
|
||||
redrawWindowConnectors() {
|
||||
if (this.connectorRedrawScheduled) return;
|
||||
this.connectorRedrawScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.connectorRedrawScheduled = false;
|
||||
try {
|
||||
window.app?.updateConnectionLines?.();
|
||||
} catch {
|
||||
}
|
||||
});
|
||||
}
|
||||
/** Pop a grabbed window above its siblings using app.js's own z-counter, so a
|
||||
* picked-up window comes to the front like a real focus. Cosmetic + best-effort. */
|
||||
bringWindowToFront(el2) {
|
||||
const app = window.app;
|
||||
if (!app) return;
|
||||
try {
|
||||
if (el2.classList.contains("ultracode-window")) {
|
||||
app.ultracodeWindowZIndex = (app.ultracodeWindowZIndex ?? 1e3) + 1;
|
||||
el2.style.zIndex = String(app.ultracodeWindowZIndex);
|
||||
} else {
|
||||
app.subagentWindowZIndex = (app.subagentWindowZIndex ?? 1e3) + 1;
|
||||
el2.style.zIndex = String(app.subagentWindowZIndex);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
positionGhost(ghost, x2, y2) {
|
||||
ghost.style.left = `${x2}px`;
|
||||
ghost.style.top = `${y2}px`;
|
||||
@@ -4703,15 +4780,17 @@ var GestureBridge = class {
|
||||
if (grab.kind === "tab") {
|
||||
grab.ghost.remove();
|
||||
grab.tab.classList.remove("cg-grabbed");
|
||||
} else {
|
||||
} else if (grab.kind === "panel") {
|
||||
grab.panel.el.style.pointerEvents = "";
|
||||
grab.panel.el.classList.remove("cg-float-grabbed", "cg-redock");
|
||||
} else {
|
||||
grab.el.classList.remove("cg-win-grabbed");
|
||||
}
|
||||
}
|
||||
this.grabs.clear();
|
||||
for (const tap of this.taps.values()) tap.el.classList.remove("cg-tap-armed");
|
||||
this.taps.clear();
|
||||
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed"));
|
||||
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed", "cg-win-grabbed"));
|
||||
}
|
||||
onStatus(fps, hands) {
|
||||
const { width, height } = this.canvas;
|
||||
@@ -4797,6 +4876,10 @@ function injectStyles() {
|
||||
.cg-status { color: #9aa0a6; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.session-tab.cg-grabbed { opacity: .35; outline: 2px dashed #4ade80; outline-offset: -2px; }
|
||||
.cg-tap-armed { outline: 2px solid #4ade80 !important; outline-offset: 2px; box-shadow: 0 0 0 4px rgba(74,222,128,.25) !important; }
|
||||
.subagent-window.cg-win-grabbed, .ultracode-window.cg-win-grabbed {
|
||||
outline: 2px solid #4ade80 !important; outline-offset: -2px;
|
||||
box-shadow: 0 12px 48px rgba(74,222,128,.5) !important;
|
||||
}
|
||||
.cg-float {
|
||||
position: fixed; left: 0; top: 0; width: ${FLOAT_W}px; height: ${FLOAT_H}px;
|
||||
z-index: ${Z2}; display: flex; flex-direction: column; overflow: hidden;
|
||||
|
||||
@@ -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);
|
||||
@@ -104,34 +104,78 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.execCommand('paste');
|
||||
},
|
||||
|
||||
async _uploadAndInsertImages(files) {
|
||||
// Max images accepted in one batch (paste / drop / mobile picker). Each is
|
||||
// uploaded as its own request, so 20 stays under the server's 30 uploads/min
|
||||
// rate limit while covering "select a bunch of photos at once".
|
||||
_maxBatchImages: 20,
|
||||
// How many uploads to run concurrently. Small enough that decoding several
|
||||
// large images through <canvas> at once won't OOM a phone, large enough that
|
||||
// 20 photos don't crawl through serially.
|
||||
_uploadConcurrency: 3,
|
||||
|
||||
async _uploadAndInsertImages(fileList) {
|
||||
const sessionId = this.activeSessionId;
|
||||
if (!sessionId) return;
|
||||
|
||||
this.showToast('Uploading ' + files.length + ' image' + (files.length > 1 ? 's' : '') + '...', 'info');
|
||||
let files = Array.from(fileList || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const paths = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Re-encode to a standard JPEG/PNG before upload. Galleries on some
|
||||
// phones (notably Android/MIUI) hand back a WebP/HEIF whose filename and
|
||||
// MIME claim "image/jpeg", which passes the server's extension allowlist
|
||||
// but fails its magic-byte check ("bytes do not match declared type").
|
||||
// Decoding through the browser and re-encoding guarantees the bytes
|
||||
// match the extension we send.
|
||||
const normalized = await this._normalizeImageForUpload(file);
|
||||
const path = await this._uploadPasteImage(sessionId, normalized);
|
||||
paths.push(path);
|
||||
} catch (err) {
|
||||
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
|
||||
// Cap the batch and tell the user what got dropped (no silent truncation).
|
||||
let capped = false;
|
||||
if (files.length > this._maxBatchImages) {
|
||||
files = files.slice(0, this._maxBatchImages);
|
||||
capped = true;
|
||||
}
|
||||
|
||||
const total = files.length;
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
const results = new Array(total); // preserve selection order for insertion
|
||||
const progress = () =>
|
||||
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}…`, 'info');
|
||||
progress();
|
||||
|
||||
// Bounded-concurrency worker pool over the file list.
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
for (;;) {
|
||||
const i = next++;
|
||||
if (i >= total) return;
|
||||
try {
|
||||
// Re-encode to a standard JPEG/PNG (and downscale very large images)
|
||||
// before upload. Galleries on some phones (notably Android/MIUI) hand
|
||||
// back a WebP/HEIF whose filename and MIME claim "image/jpeg", which
|
||||
// passes the server's extension allowlist but fails its magic-byte
|
||||
// check. Decoding through the browser and re-encoding guarantees the
|
||||
// bytes match the extension we send — and shrinks huge photos so they
|
||||
// fit the upload limit and iOS's <canvas> area cap.
|
||||
const normalized = await this._normalizeImageForUpload(files[i]);
|
||||
results[i] = await this._uploadPasteImage(sessionId, normalized);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.warn('Image upload failed:', err);
|
||||
results[i] = null;
|
||||
} finally {
|
||||
done++;
|
||||
if (done < total) progress();
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(this._uploadConcurrency, total) }, () => worker()));
|
||||
|
||||
const paths = results.filter(Boolean);
|
||||
if (paths.length > 0) {
|
||||
// Insert all paths in one shot, space-separated, in selection order.
|
||||
await this.sendInput(paths.join(' '));
|
||||
}
|
||||
|
||||
if (paths.length > 0) {
|
||||
const pathStr = paths.join(' ');
|
||||
await this.sendInput(pathStr);
|
||||
this.showToast(paths.length + ' image' + (paths.length > 1 ? 's' : '') + ' ready', 'success');
|
||||
}
|
||||
// Final status: successes, plus any failures / cap so nothing is silent.
|
||||
const parts = [];
|
||||
if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`);
|
||||
if (failed > 0) parts.push(`${failed} failed`);
|
||||
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
||||
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
||||
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
|
||||
},
|
||||
|
||||
async _uploadPasteImage(sessionId, file) {
|
||||
@@ -176,12 +220,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
const height = img.naturalHeight;
|
||||
if (!width || !height) return file;
|
||||
|
||||
// Downscale very large images. Two reasons: (1) iOS Safari refuses to
|
||||
// render a <canvas> larger than ~16.7M px (it returns a blank/null
|
||||
// blob), so a 48MP photo would otherwise fail to re-encode and fall back
|
||||
// to the original — which then trips the server's magic-byte check for
|
||||
// HEIF mislabeled as JPEG. (2) It keeps multi-photo uploads fast and well
|
||||
// under the size limit. Cap the longest edge so area stays safely below
|
||||
// the canvas limit while still uploading a large, high-quality image.
|
||||
const MAX_EDGE = 4096;
|
||||
const scale = Math.min(1, MAX_EDGE / Math.max(width, height));
|
||||
const w = Math.max(1, Math.round(width * scale));
|
||||
const h = Math.max(1, Math.round(height * scale));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return file;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
|
||||
const mime = toPng ? 'image/png' : 'image/jpeg';
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92));
|
||||
|
||||
+591
-17
@@ -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">⊞</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>
|
||||
@@ -116,11 +126,15 @@
|
||||
<span class="stat-value" id="statMem">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-header btn-redraw-terminal btn-redraw-terminal--hidden" onclick="app.restoreTerminalSize()" title="Redraw terminal to fit current screen (Ctrl+Shift+R)" aria-label="Redraw terminal"><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"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
|
||||
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="btn-icon-header btn-away-digest btn-away-digest--hidden" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><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="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
|
||||
<button class="btn-icon-header btn-session-manager btn-session-manager--hidden" onclick="app.openSessionManager()" title="Session Manager" aria-label="Open session manager"><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"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg></button>
|
||||
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-file-viewer btn-file-viewer--hidden" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
|
||||
@@ -304,16 +318,59 @@
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run OpenCode
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-gemini" onclick="app.setRunMode('gemini'); app.runGemini()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Gemini
|
||||
</button>
|
||||
</div>
|
||||
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
|
||||
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
||||
<div class="welcome-qr-url" id="welcomeQrUrl"></div>
|
||||
</div>
|
||||
<div class="history-sessions" id="historySessions" style="display:none">
|
||||
<h3 class="history-title">Resume Conversation</h3>
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-input-row">
|
||||
<input
|
||||
type="search"
|
||||
id="searchInput"
|
||||
class="search-input"
|
||||
placeholder="Search sessions, events, files…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
maxlength="200"
|
||||
aria-label="Search across sessions"
|
||||
/>
|
||||
<button type="button" id="searchClearBtn" class="search-clear-btn" aria-label="Clear search" hidden>×</button>
|
||||
</div>
|
||||
<div class="search-filters" id="searchFilters">
|
||||
<div class="search-filter-group" role="group" aria-label="Source type filter">
|
||||
<button type="button" class="search-filter-chip active" data-type-filter="session">Sessions</button>
|
||||
<button type="button" class="search-filter-chip active" data-type-filter="event">Events</button>
|
||||
<button type="button" class="search-filter-chip active" data-type-filter="file">Files</button>
|
||||
</div>
|
||||
<div class="search-filter-group search-filter-secondary">
|
||||
<select id="searchCaseFilter" class="search-select" aria-label="Filter by case">
|
||||
<option value="">All cases</option>
|
||||
</select>
|
||||
<select id="searchStatusFilter" class="search-select" aria-label="Filter by session status">
|
||||
<option value="">Any status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="history">History</option>
|
||||
</select>
|
||||
<select id="searchDateFilter" class="search-select" aria-label="Filter by date range">
|
||||
<option value="">Any time</option>
|
||||
<option value="1">Past 24h</option>
|
||||
<option value="7">Past 7 days</option>
|
||||
<option value="30">Past 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-results" id="searchResults" hidden></div>
|
||||
</div>
|
||||
<h3 class="history-title" id="historyTitle">Resume Conversation</h3>
|
||||
<div class="history-list" id="historyList"></div>
|
||||
</div>
|
||||
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start</p>
|
||||
<p class="welcome-hint">Or click Run to start</p>
|
||||
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop →</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -382,7 +439,7 @@
|
||||
<!-- Run AI -->
|
||||
<div class="toolbar-group">
|
||||
<div class="run-btn-group">
|
||||
<button class="btn-toolbar btn-run" id="runBtn" onclick="app.run()" title="Run (Ctrl+Enter)">
|
||||
<button class="btn-toolbar btn-run" id="runBtn" onclick="app.run()" title="Run">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
<span id="runBtnLabel">Run</span>
|
||||
</button>
|
||||
@@ -399,6 +456,9 @@
|
||||
<button class="run-mode-option" data-mode="codex" onclick="app.setRunMode('codex')">
|
||||
<span class="run-mode-dot codex"></span>Codex
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="gemini" onclick="app.setRunMode('gemini')">
|
||||
<span class="run-mode-dot gemini"></span>Gemini
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<div class="run-mode-header">Recent Sessions</div>
|
||||
<div class="run-mode-history" id="runModeHistory"></div>
|
||||
@@ -421,7 +481,22 @@
|
||||
<button class="tab-count-btn" onclick="app.incrementShellCount()">+</button>
|
||||
</div>
|
||||
<div class="case-select-group">
|
||||
<select id="quickStartCase" class="toolbar-select" title="Select case">
|
||||
<div class="case-combobox" id="quickStartCasePicker">
|
||||
<input
|
||||
type="text"
|
||||
id="quickStartCaseSearch"
|
||||
class="case-combobox-input"
|
||||
role="combobox"
|
||||
aria-controls="quickStartCaseList"
|
||||
aria-expanded="false"
|
||||
aria-autocomplete="list"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
title="Select case"
|
||||
>
|
||||
<div id="quickStartCaseList" class="case-combobox-list hidden" role="listbox"></div>
|
||||
</div>
|
||||
<select id="quickStartCase" class="toolbar-select case-native-select" title="Select case" aria-hidden="true" tabindex="-1">
|
||||
<option value="testcase">testcase</option>
|
||||
</select>
|
||||
<button class="btn-case-add" onclick="app.showCreateCaseModal()" title="Create new case">+</button>
|
||||
@@ -493,6 +568,7 @@
|
||||
<div class="toolbar-right">
|
||||
<!-- Orchestrator button hidden until feature is ready -->
|
||||
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">⚙ Orchestrator</button> -->
|
||||
<button class="btn-toolbar btn-sm btn-cron btn-cron--hidden" onclick="app.openCron()" title="Cron Jobs">⏰ Cron</button>
|
||||
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -506,17 +582,158 @@
|
||||
<button class="modal-close" onclick="app.closeHelp()" aria-label="Close help">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Help</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>V</kbd></div><div>Voice Input</div>
|
||||
<div><kbd>Escape</kbd></div><div>Close Panels</div>
|
||||
<section class="shortcut-section">
|
||||
<h4>Session</h4>
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
||||
<div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="shortcut-section">
|
||||
<h4>Tabs</h4>
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>{</kbd></div><div>Move Active Tab Left</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>}</kbd></div><div>Move Active Tab Right</div>
|
||||
<div><kbd>ArrowLeft</kbd></div><div>Focus Previous Tab</div>
|
||||
<div><kbd>ArrowRight</kbd></div><div>Focus Next Tab</div>
|
||||
<div><kbd>Home</kbd></div><div>Focus First Tab</div>
|
||||
<div><kbd>End</kbd></div><div>Focus Last Tab</div>
|
||||
<div><kbd>Enter</kbd> / <kbd>Space</kbd></div><div>Activate Focused Tab</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="shortcut-section">
|
||||
<h4>Terminal</h4>
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></div><div>Restore Terminal Size</div>
|
||||
<div><kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Insert Newline</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Enter</kbd></div><div>Insert Newline</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>V</kbd></div><div>Voice Input</div>
|
||||
<div><kbd>Shift</kbd>+<kbd>Wheel</kbd></div><div>Scroll local history (when mouse passthrough is active)</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="shortcut-section">
|
||||
<h4>Panels</h4>
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div>
|
||||
<div><kbd>Escape</kbd></div><div>Close Panels</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cron Jobs Modal -->
|
||||
<div class="modal" id="cronModal">
|
||||
<div class="modal-backdrop" onclick="app.closeCron()"></div>
|
||||
<div class="modal-content modal-lg">
|
||||
<div class="modal-header">
|
||||
<h3>Cron Jobs</h3>
|
||||
<button class="modal-close" onclick="app.closeCron()" aria-label="Close cron">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="form-hint cron-modal-hint">Times use the server's local timezone.</p>
|
||||
<div class="cron-toolbar">
|
||||
<button class="btn-toolbar btn-primary" onclick="app.openCronJobForm()">+ New Job</button>
|
||||
<button class="btn-toolbar" onclick="app.refreshCron()">Refresh</button>
|
||||
</div>
|
||||
<!-- Job list -->
|
||||
<div id="cronJobList" class="cron-job-list"></div>
|
||||
|
||||
<!-- Create/Edit form (hidden until New/Edit) -->
|
||||
<div id="cronJobForm" class="cron-job-form hidden">
|
||||
<div class="cron-form-title" id="cronFormTitle">New Cron Job</div>
|
||||
<input type="hidden" id="schJobId">
|
||||
|
||||
<div class="form-section-header">Basics</div>
|
||||
<div class="form-row"><label>Name</label><input type="text" id="schName" placeholder="My nightly job"></div>
|
||||
<div class="form-row"><label>Agent Type</label>
|
||||
<select id="schAgentType" class="form-select" onchange="app.onCronAgentTypeChange()">
|
||||
<option value="claude">Claude</option>
|
||||
<option value="shell">Terminal / Shell</option>
|
||||
<option value="opencode">OpenCode</option>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
|
||||
<div class="form-row hidden" id="schLaunchCommandRow"><label>Launch Command</label><input type="text" id="schLaunchCommand" placeholder="Optional — runs as the first command in the new shell"></div>
|
||||
|
||||
<div class="form-section-header">Prompt</div>
|
||||
<div class="form-row"><label>Prompt Source</label>
|
||||
<select id="schPromptMode" class="form-select" onchange="app.onCronPromptModeChange()">
|
||||
<option value="inline_text">Inline text</option>
|
||||
<option value="prompt_file_path">Prompt file path</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" id="schPromptTextRow"><label>Prompt</label><textarea id="schPromptText" rows="4" placeholder="Prompt to send into the session"></textarea></div>
|
||||
<div class="form-row hidden" id="schPromptFileRow"><label>Prompt File Path</label><input type="text" id="schPromptFilePath" placeholder="/absolute/path/to/prompt.md"></div>
|
||||
<div class="form-row"><label>Input Mode</label>
|
||||
<select id="schInputMode" class="form-select">
|
||||
<option value="typed">Typed (via tmux)</option>
|
||||
<option value="paste">Paste (direct)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-section-header">Schedule</div>
|
||||
<div class="form-row"><label>Schedule Type</label>
|
||||
<select id="schScheduleType" class="form-select" onchange="app.onCronScheduleTypeChange()">
|
||||
<option value="once">Once</option>
|
||||
<option value="interval">Interval</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" id="schRunAtRow"><label>Run At</label><input type="datetime-local" id="schRunAt"></div>
|
||||
<div class="form-row hidden" id="schIntervalRow"><label>Every (minutes)</label><input type="number" id="schIntervalMinutes" min="1" value="60"></div>
|
||||
<div class="form-row hidden" id="schDailyRow"><label>Daily Time (HH:MM)</label><input type="time" id="schDailyTime"></div>
|
||||
<div class="form-row hidden" id="schWeeklyDaysRow"><label>Weekdays</label>
|
||||
<span id="schWeeklyDays" class="cron-weekdays">
|
||||
<label><input type="checkbox" value="0">Sun</label>
|
||||
<label><input type="checkbox" value="1">Mon</label>
|
||||
<label><input type="checkbox" value="2">Tue</label>
|
||||
<label><input type="checkbox" value="3">Wed</label>
|
||||
<label><input type="checkbox" value="4">Thu</label>
|
||||
<label><input type="checkbox" value="5">Fri</label>
|
||||
<label><input type="checkbox" value="6">Sat</label>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-row hidden" id="schWeeklyTimeRow"><label>Weekly Time (HH:MM)</label><input type="time" id="schWeeklyTime"></div>
|
||||
|
||||
<div class="form-section-header">Options</div>
|
||||
<div class="form-row"><label>On auto-run, if same agent running</label>
|
||||
<select id="schConcurrencyPolicy" class="form-select">
|
||||
<option value="warn_only">Run anyway</option>
|
||||
<option value="skip_if_same_agent_running">Skip this run</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row form-row-switch cron-switch-row">
|
||||
<div class="cron-switch-text">
|
||||
<span class="cron-switch-label">Auto-close previous run's session</span>
|
||||
<span class="cron-switch-desc">Recurring schedules only: close the prior run's still-open session before the next run fires</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="schAutoClosePrev" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="form-row form-row-switch cron-switch-row">
|
||||
<div class="cron-switch-text">
|
||||
<span class="cron-switch-label">Enabled</span>
|
||||
<span class="cron-switch-desc">Off keeps the job saved but skips its schedule</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="schEnabled" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="form-row"><label>Notes</label><input type="text" id="schNotes" placeholder="Optional"></div>
|
||||
|
||||
<div id="cronFormError" class="form-hint cron-form-error"></div>
|
||||
<div class="cron-form-actions">
|
||||
<button class="btn-toolbar" onclick="app.cancelCronJobForm()">Cancel</button>
|
||||
<button class="btn-toolbar btn-primary" onclick="app.saveCronJob()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -948,11 +1165,26 @@
|
||||
<button class="modal-tab-btn" data-tab="settings-paths">Paths</button>
|
||||
<button class="modal-tab-btn" data-tab="settings-notifications">Notifications</button>
|
||||
<button class="modal-tab-btn" data-tab="settings-voice">Voice</button>
|
||||
<button class="modal-tab-btn" data-tab="settings-shortcuts">Shortcuts</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Display Tab -->
|
||||
<div class="modal-tab-content" id="settings-display">
|
||||
<div class="settings-grid">
|
||||
<!-- Branding & Language Section -->
|
||||
<div class="settings-section-header">Branding & 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)">
|
||||
@@ -963,8 +1195,25 @@
|
||||
<option value="og">OG Codeman</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-item" id="appSettingsWebglRendererItem" title="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.">
|
||||
<span class="settings-item-label">WebGL Renderer</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsWebglRenderer">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Input Section -->
|
||||
<div class="settings-section-header">Input</div>
|
||||
<div class="settings-item settings-item-multiline" title="Scroll the terminal's own local scrollback with a plain mouse wheel / two-finger swipe, instead of forwarding the wheel to the CLI's transcript. Turn on if scrolling back through history doesn't work (e.g. macOS trackpad in Claude sessions). Shift+wheel always reaches local scrollback regardless.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Wheel Scrolls Local History</span>
|
||||
<span class="settings-item-desc">Plain wheel/trackpad pages the terminal scrollback</span>
|
||||
</div>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsTerminalWheelLocal">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item settings-item-multiline" title="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.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Local Echo</span>
|
||||
@@ -1044,6 +1293,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the file viewer button in header (opens the file browser panel for the active session)">
|
||||
<span class="settings-item-label">File Viewer</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowFileViewerButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the attachments button in header (opens the attachment history drawer)">
|
||||
<span class="settings-item-label">Attachments Button</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -1058,6 +1314,34 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the session manager button in the header (opens the session manager — sessions also stay reachable via the Ctrl+K palette)">
|
||||
<span class="settings-item-label">Session Manager Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowSessionButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the away digest button in the header (opens the 'what happened while you were away' summary)">
|
||||
<span class="settings-item-label">Away Digest Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowAwayDigestButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the Cron button in the footer toolbar (opens the cron jobs manager)">
|
||||
<span class="settings-item-label">Cron Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowCronButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show a terminal redraw button in the header — refit the terminal to the current screen size (useful when switching between devices)">
|
||||
<span class="settings-item-label">Redraw Terminal Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowRedrawButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar Section -->
|
||||
<div class="settings-section-header">Tab Bar</div>
|
||||
@@ -1189,10 +1473,11 @@
|
||||
<label>Startup Mode</label>
|
||||
<select id="appSettingsClaudeMode" class="form-select">
|
||||
<option value="dangerously-skip-permissions">Skip Permissions (default)</option>
|
||||
<option value="auto">Auto (classifier-guarded, low prompts)</option>
|
||||
<option value="normal">Normal (with prompts)</option>
|
||||
<option value="allowedTools">Allowed Tools Only</option>
|
||||
</select>
|
||||
<span class="form-hint">How Claude CLI is started in screen sessions</span>
|
||||
<span class="form-hint">How Claude CLI is started in screen sessions. Auto Mode runs without routine prompts behind a background safety classifier (needs Claude Code 2.1.207+ and Opus 4.6+/Sonnet 4.6+/Fable 5)</span>
|
||||
</div>
|
||||
<div class="form-row" id="allowedToolsRow" style="display: none;">
|
||||
<label>Allowed Tools</label>
|
||||
@@ -1240,6 +1525,14 @@
|
||||
</label>
|
||||
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions — ignored when a Claude Model is selected above</span>
|
||||
</div>
|
||||
<div class="form-row form-row-switch">
|
||||
<label>Remote auto-reconnect</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="appSettingsRemoteAutoReconnect">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="form-hint">Automatically re-establish remote (SSH) sessions when the connection drops, reattaching to the durable remote tmux session (on by default; bounded backoff)</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Thinking Effort</label>
|
||||
<select id="appSettingsThinkingEffort" class="form-select">
|
||||
@@ -1547,6 +1840,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shortcuts tab -->
|
||||
<div class="modal-tab-content hidden" id="settings-shortcuts">
|
||||
<div class="settings-grid">
|
||||
<div class="settings-section-header" style="grid-column: 1 / -1;">Keyboard Shortcuts</div>
|
||||
<p class="form-hint" style="grid-column: 1 / -1; margin: 0 0 0.5rem;">
|
||||
Customize keyboard shortcuts. Click the binding to capture a new key combination.
|
||||
</p>
|
||||
<div id="appSettingsShortcutsList" style="grid-column: 1 / -1;">
|
||||
<!-- Populated by app.renderShortcutSettingsList() -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn-toolbar" onclick="app.closeAppSettings()">Cancel</button>
|
||||
@@ -1555,6 +1861,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shortcut Overlay Modal -->
|
||||
<div class="modal shortcut-overlay-modal" id="shortcutOverlayModal" tabindex="-1">
|
||||
<div class="modal-backdrop" onclick="app.closeShortcutOverlay()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Keyboard Shortcuts</span>
|
||||
<button class="modal-close" onclick="app.closeShortcutOverlay()" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="shortcutOverlayList"></div>
|
||||
<div class="shortcut-overlay-footer">
|
||||
<button class="btn btn-sm" onclick="app.closeShortcutOverlay(); app.showHelp()">Full shortcut reference</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Case Modal -->
|
||||
<div class="modal" id="createCaseModal">
|
||||
<div class="modal-backdrop" onclick="app.closeCreateCaseModal()"></div>
|
||||
@@ -1566,6 +1889,8 @@
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
|
||||
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
|
||||
<button class="modal-tab-btn" data-tab="case-remote">Remote</button>
|
||||
<button class="modal-tab-btn" data-tab="case-docker">Docker</button>
|
||||
<button class="modal-tab-btn" data-tab="case-manage">Manage</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -1580,6 +1905,53 @@
|
||||
<label>Description (optional)</label>
|
||||
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row docker-quick-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
|
||||
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
|
||||
</div>
|
||||
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
|
||||
<summary>Container settings (optional, sensible defaults)</summary>
|
||||
<div class="advanced-options-content">
|
||||
<div class="form-row">
|
||||
<label>Template</label>
|
||||
<select id="quickDockerTemplate" onchange="app.applyDockerTemplate()">
|
||||
<option value="small">Small — 2 GB RAM, 1 CPU</option>
|
||||
<option value="medium" selected>Medium — 4 GB RAM, 2 CPU (default)</option>
|
||||
<option value="large">Large — 8 GB RAM, 4 CPU</option>
|
||||
<option value="gpu">GPU — 8 GB RAM, 4 CPU, all GPUs</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<span class="form-hint">Disk is elastic: storage grows automatically as data flows in (no fixed cap).</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Memory</label>
|
||||
<input type="text" id="quickDockerMemory" placeholder="4g" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>CPUs</label>
|
||||
<input type="text" id="quickDockerCpus" placeholder="2" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>GPUs</label>
|
||||
<input type="text" id="quickDockerGpus" placeholder="none (e.g. all, or 1)" autocomplete="off" spellcheck="false">
|
||||
<span class="form-hint">Needs the NVIDIA container toolkit on the host.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Network</label>
|
||||
<select id="quickDockerNetwork">
|
||||
<option value="bridge">bridge (internet on)</option>
|
||||
<option value="none">none (fully isolated)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Image</label>
|
||||
<input type="text" id="quickDockerImage" placeholder="codeman/agent:base" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="quickDockerMountCreds" checked> Mount host credentials (~/.claude etc.)</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<!-- Link Existing Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-link">
|
||||
@@ -1594,12 +1966,143 @@
|
||||
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Remote Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-remote">
|
||||
<div class="form-row">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="remoteCaseName" placeholder="gpu-work" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Name to identify this remote case in Codeman</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Remote Path</label>
|
||||
<input type="text" id="remoteCasePath" placeholder="/home/user/projects/work" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Absolute path on the remote host. Codeman will not create or delete it.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Host ID</label>
|
||||
<input type="text" id="remoteHostId" placeholder="gpu-box" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>SSH Host/IP</label>
|
||||
<input type="text" id="remoteHostAddress" placeholder="10.0.0.42" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>SSH Username</label>
|
||||
<input type="text" id="remoteHostUsername" placeholder="ubuntu" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>SSH Port</label>
|
||||
<input type="number" id="remoteHostPort" placeholder="22" min="1" max="65535" autocomplete="off">
|
||||
<span class="form-hint">Optional. Leave blank for the default port 22.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Codex Command Override</label>
|
||||
<input type="text" id="remoteHostCodexCommand" placeholder="exec codx personal" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Optional. Leave blank to use exec codex on the remote host.</span>
|
||||
</div>
|
||||
<details class="advanced-options">
|
||||
<summary>Advanced SSH</summary>
|
||||
<div class="advanced-options-content">
|
||||
<div class="form-row">
|
||||
<label>Identity File</label>
|
||||
<input type="text" id="remoteHostIdentityFile" placeholder="~/.ssh/remote_ed25519" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Optional. Path to a private key on this machine (passed to ssh -i). Never the key contents.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>SOCKS Proxy</label>
|
||||
<input type="text" id="remoteHostSocksProxy" placeholder="127.0.0.1:1080" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Optional. host:port of a SOCKS5 proxy (e.g. cloudflared). Routes ssh through it via a ProxyCommand.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Jump Host</label>
|
||||
<input type="text" id="remoteHostJumpHost" placeholder="bastion@10.0.0.1:22" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Optional. [user@]host[:port] for ssh -J (jump/bastion host).</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Extra -o Options</label>
|
||||
<textarea id="remoteHostExtraSshOptions" rows="3" placeholder="StrictHostKeyChecking=accept-new ConnectTimeout=10" autocomplete="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
<span class="form-hint">Optional. One KEY=VALUE per line; each becomes an ssh -o option.</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<!-- COD-105 — discover + attach existing remote tmux sessions this Codeman didn't create. -->
|
||||
<details class="advanced-options" id="remoteDiscoverSection">
|
||||
<summary>Discover existing sessions</summary>
|
||||
<div class="advanced-options-content">
|
||||
<span class="form-hint">Find <code>codeman-*</code> tmux sessions already running on this host (started by the remote's own Codeman or another instance) and attach to one. Attaching shares the session; closing the tab detaches it — it is never killed.</span>
|
||||
<div class="form-row" style="margin-top: 8px;">
|
||||
<button type="button" class="btn-toolbar" id="remoteDiscoverBtn" onclick="app.discoverRemoteSessions()">Discover existing sessions</button>
|
||||
</div>
|
||||
<div id="remoteDiscoverResults" class="remote-discover-results"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<!-- Docker Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-docker">
|
||||
<div class="form-row">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="dockerCaseName" placeholder="sandbox" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Runs inside an isolated container. Multiple sessions can share the same container.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Workspace Path</label>
|
||||
<input type="text" id="dockerWorkspacePath" placeholder="/home/user/projects/sandbox" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Host ID</label>
|
||||
<input type="text" id="dockerHostId" placeholder="local" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">A reusable docker host profile. Reuse the same ID across cases to share settings.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Image</label>
|
||||
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini + tmux.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Network</label>
|
||||
<select id="dockerNetwork">
|
||||
<option value="bridge">bridge (internet on, default)</option>
|
||||
<option value="none">none (fully isolated, no network)</option>
|
||||
<option value="custom">custom bridge</option>
|
||||
</select>
|
||||
</div>
|
||||
<details class="advanced-options">
|
||||
<summary>Advanced container settings</summary>
|
||||
<div class="advanced-options-content">
|
||||
<div class="form-row">
|
||||
<label>Memory</label>
|
||||
<input type="text" id="dockerMemory" placeholder="4g" autocomplete="off" spellcheck="false">
|
||||
<span class="form-hint">Optional, e.g. 4g / 512m. Enforced as a hard OOM cap where the engine supports it.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>CPUs</label>
|
||||
<input type="text" id="dockerCpus" placeholder="2" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="dockerMountCredentials" checked> Mount host credentials (~/.claude etc.)</label>
|
||||
<span class="form-hint">On: your existing login just works (creds stay on the host, never in exports). Off: sealed sandbox, log in inside the container.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="dockerResumeOnStart" checked> Resume last conversation on relaunch</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<span class="form-hint" id="dockerLinkStatus" style="margin-top: 8px; display: block;"></span>
|
||||
</div>
|
||||
<!-- Manage Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-manage">
|
||||
<div class="case-manage-list" id="caseManageList">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<span class="form-hint" style="margin-top: 8px; display: block;">Use arrows to reorder. Changes are saved automatically.</span>
|
||||
<div id="dockerExportsSection" style="margin-top: 16px; border-top: 1px solid var(--border, #333); padding-top: 12px;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:8px;">
|
||||
<strong style="font-size: 13px;">Docker exports</strong>
|
||||
<button class="btn-toolbar" onclick="app.refreshDockerExports()">Refresh</button>
|
||||
</div>
|
||||
<div class="case-manage-list" id="dockerExportsList"><span class="form-hint">No exports yet. Export a docker case from its tab.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
@@ -1855,6 +2358,74 @@
|
||||
<div class="notif-drawer-empty" id="notifEmpty">No notifications</div>
|
||||
</div>
|
||||
|
||||
<!-- Away Digest Modal -->
|
||||
<div class="modal" id="awayDigestModal">
|
||||
<div class="modal-backdrop" onclick="app.closeAwayDigest()"></div>
|
||||
<div class="modal-content away-digest-modal">
|
||||
<div class="modal-header">
|
||||
<h3>Away Digest</h3>
|
||||
<div class="modal-header-actions">
|
||||
<button class="btn-toolbar btn-sm" onclick="app.loadAwayDigest()" title="Refresh away digest">↻ Refresh</button>
|
||||
<button class="modal-close" onclick="app.closeAwayDigest()" aria-label="Close away digest">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="away-digest-ranges" role="group" aria-label="Away digest range">
|
||||
<button class="filter-btn active" data-away-range="since-last-visit" onclick="app.setAwayDigestRange('since-last-visit')">Since last visit</button>
|
||||
<button class="filter-btn" data-away-range="1h" onclick="app.setAwayDigestRange('1h')">Last hour</button>
|
||||
<button class="filter-btn" data-away-range="today" onclick="app.setAwayDigestRange('today')">Today</button>
|
||||
<button class="filter-btn" data-away-range="24h" onclick="app.setAwayDigestRange('24h')">24h</button>
|
||||
<button class="filter-btn" data-away-range="custom" onclick="app.setAwayDigestRange('custom')">Custom</button>
|
||||
</div>
|
||||
<div class="away-digest-custom-range" id="awayDigestCustomRange">
|
||||
<label>
|
||||
Since
|
||||
<input type="datetime-local" id="awayDigestCustomSince">
|
||||
</label>
|
||||
<label>
|
||||
Until
|
||||
<input type="datetime-local" id="awayDigestCustomUntil">
|
||||
</label>
|
||||
</div>
|
||||
<div class="away-digest-summary" id="awayDigestSummary"></div>
|
||||
<div class="away-digest-freshness" id="awayDigestFreshness"></div>
|
||||
<div class="away-digest-sections" id="awayDigestSections">
|
||||
<p class="empty-message">Open the digest to load recent activity</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Command Palette Modal -->
|
||||
<div class="modal command-palette-modal" id="commandPaletteModal">
|
||||
<div class="modal-backdrop" onclick="app.closeCommandPalette()"></div>
|
||||
<div class="command-palette-shell" role="dialog" aria-modal="true" aria-labelledby="commandPaletteTitle">
|
||||
<div class="command-palette-input-row">
|
||||
<span class="command-palette-search-icon" aria-hidden="true">⌕</span>
|
||||
<input type="search" id="commandPaletteSearch" class="command-palette-search" placeholder="Search open sessions or start a new one" autocomplete="off" maxlength="160" aria-labelledby="commandPaletteTitle">
|
||||
<kbd>Esc</kbd>
|
||||
</div>
|
||||
<div class="command-palette-label" id="commandPaletteTitle">Open sessions</div>
|
||||
<div id="commandPaletteList" class="command-palette-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Manager Modal -->
|
||||
<div class="modal" id="sessionManagerModal">
|
||||
<div class="modal-backdrop" onclick="app.closeSessionManager()"></div>
|
||||
<div class="modal-content session-manager-modal">
|
||||
<div class="modal-header">
|
||||
<h3>Sessions</h3>
|
||||
<button class="modal-close" onclick="app.closeSessionManager()" aria-label="Close session manager">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="search" id="sessionManagerSearch" class="search-input" placeholder="Search sessions by name, prompt, or path…" autocomplete="off" maxlength="200">
|
||||
<div id="sessionManagerList" class="session-manager-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Token Stats Modal -->
|
||||
<div class="modal" id="tokenStatsModal">
|
||||
<div class="modal-backdrop" onclick="app.closeTokenStats()"></div>
|
||||
@@ -1951,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>
|
||||
@@ -1963,9 +2535,11 @@
|
||||
<script defer src="respawn-ui.js"></script>
|
||||
<script defer src="ralph-panel.js"></script>
|
||||
<script defer src="orchestrator-panel.js"></script>
|
||||
<script defer src="cron-ui.js"></script>
|
||||
<script defer src="settings-ui.js"></script>
|
||||
<script defer src="panels-ui.js"></script>
|
||||
<script defer src="ultracode-panel.js"></script>
|
||||
<script defer src="admin-ui.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
+256
-122
@@ -14,13 +14,21 @@
|
||||
* This means compositionstart fires even for English text, and compositionend
|
||||
* may not fire until the user explicitly confirms (space, candidate tap).
|
||||
*
|
||||
* We use InputEvent.inputType to distinguish:
|
||||
* - `insertCompositionText`: tentative text, may change (CJK candidates, pinyin)
|
||||
* - `insertText`: final committed text (confirmed word, punctuation, space)
|
||||
* During composition, all input events are ignored — only compositionend
|
||||
* triggers a flush (CJK candidate selection).
|
||||
*
|
||||
* During composition, `insertText` events are flushed immediately (punctuation,
|
||||
* English words confirmed by IME). `insertCompositionText` waits for
|
||||
* compositionend (CJK candidate selection).
|
||||
* ## iOS dictation challenge (WebKit Bug 261764)
|
||||
*
|
||||
* iOS/iPadOS voice dictation does NOT fire composition events. Text arrives
|
||||
* as bare input events with isComposing === false. Dictation refinement is
|
||||
* a delete→reinsert cycle (deleteContentBackward + insertReplacementText),
|
||||
* all within a few ms. Flushing on every input event would send irrevocable
|
||||
* provisional text to the PTY, causing duplication when the IME replaces it.
|
||||
*
|
||||
* Solution: outside composition, flush is DEBOUNCED (200ms). The entire
|
||||
* delete→reinsert cycle collapses into one flush of the final textarea value.
|
||||
* Keyboard typing of single printable characters still goes through the
|
||||
* keydown handler (immediate, no debounce).
|
||||
*
|
||||
* ## Phantom character for Android backspace
|
||||
*
|
||||
@@ -41,16 +49,63 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const CjkInput = (() => {
|
||||
let _textarea = null;
|
||||
let _terminalContainer = null;
|
||||
let _xtermTextarea = null;
|
||||
let _send = null;
|
||||
let _initialized = false;
|
||||
let _composing = false;
|
||||
let _flushTimer = null;
|
||||
let _compositionFlushTimer = null;
|
||||
let _dictationActive = false;
|
||||
let _dictationDecayTimer = null;
|
||||
let _keydownSentAt = 0;
|
||||
let _keydownSentText = '';
|
||||
const _listeners = {};
|
||||
|
||||
// Zero-width space: always present in textarea so Android backspace has
|
||||
// something to delete, triggering the `input` event we need to detect it.
|
||||
const PHANTOM = '\u200B';
|
||||
const PHANTOM = '';
|
||||
|
||||
// ── Diagnostic trace (intermittent CJK-loss investigation) ──
|
||||
// In-memory ring buffer of every IME event + flush decision. Mirrored into
|
||||
// the crash-diag breadcrumbs (app.js), which persist to localStorage and
|
||||
// beacon to the server every 2s — after a repro, `GET /api/crash-diag`
|
||||
// shows the exact event sequence.
|
||||
// PRIVACY: because the trace leaves the page, it must stay CONTENT-FREE —
|
||||
// event types, booleans, key classes, and value LENGTHS only. Never log a
|
||||
// typed character or the textarea value (pasted secrets would be captured).
|
||||
const TRACE_MAX = 200;
|
||||
const _trace = [];
|
||||
/** Content-free value descriptor: real-text length + phantom presence. */
|
||||
function _vdesc(v) {
|
||||
const s = String(v == null ? '' : v);
|
||||
return `len=${_strip(s).length}${s.includes(PHANTOM) ? '+ph' : ''}`;
|
||||
}
|
||||
/** Content-free key descriptor: named keys (Enter, Process…) pass through; any single code point is typed content. */
|
||||
function _kdesc(key) {
|
||||
const k = String(key == null ? '' : key);
|
||||
return [...k].length === 1 ? 'printable' : k;
|
||||
}
|
||||
function _t(msg) {
|
||||
_trace.push(`${Date.now() % 1000000} ${msg}`);
|
||||
if (_trace.length > TRACE_MAX) _trace.shift();
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof _crashDiag !== 'undefined') _crashDiag.log('CJK ' + msg);
|
||||
} catch {
|
||||
/* crash-diag unavailable (tests) — ring buffer still records */
|
||||
}
|
||||
}
|
||||
|
||||
// Two-tier debounce for non-composition input:
|
||||
// - KEYBOARD: short debounce (third-party IMEs like Doubao may not fire
|
||||
// composition events even for keyboard CJK typing)
|
||||
// - DICTATION: long debounce (iOS voice dictation sends delete→reinsert
|
||||
// refinement cycles without composition events — WebKit Bug 261764)
|
||||
//
|
||||
// Dictation is detected by deleteContentBackward on non-empty text or
|
||||
// insertReplacementText — signals that the IME is rewriting provisional
|
||||
// text. Once detected, dictation mode persists for 3s (covers multi-word
|
||||
// dictation with natural pauses between words).
|
||||
const DEBOUNCE_KEYBOARD_MS = 150;
|
||||
const DEBOUNCE_DICTATION_MS = 1500;
|
||||
const DICTATION_DECAY_MS = 3000;
|
||||
|
||||
const PASSTHROUGH_KEYS = {
|
||||
ArrowUp: '\x1b[A',
|
||||
@@ -66,162 +121,196 @@ const CjkInput = (() => {
|
||||
c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05',
|
||||
};
|
||||
|
||||
/** Strip phantom characters from a string */
|
||||
function _strip(str) {
|
||||
return str.replace(/\u200B/g, '');
|
||||
return str.replace(//g, '');
|
||||
}
|
||||
|
||||
/** Reset textarea to phantom-only state with cursor at end */
|
||||
function _resetToPhantom() {
|
||||
// Skip redundant writes: every programmatic value/selection mutation can
|
||||
// desync an Android IME's input session (InputConnection) — after which
|
||||
// the keyboard composes in its own UI but NO events ever reach the page.
|
||||
// Only touch the DOM when the content actually differs.
|
||||
if (_textarea.value === PHANTOM) {
|
||||
if (_textarea.selectionStart !== 1 || _textarea.selectionEnd !== 1) {
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_textarea.value = PHANTOM;
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
|
||||
function _isMobileComposer() {
|
||||
return !!(
|
||||
_textarea &&
|
||||
typeof MobileDetection !== 'undefined' &&
|
||||
MobileDetection.isTouchDevice() &&
|
||||
_textarea.classList.contains('cjk-input-visible')
|
||||
);
|
||||
}
|
||||
|
||||
function _resetInput() {
|
||||
if (_isMobileComposer()) {
|
||||
_textarea.value = '';
|
||||
} else {
|
||||
_resetToPhantom();
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if textarea contains only phantom(s) or is empty — no real user text */
|
||||
function _isEffectivelyEmpty() {
|
||||
return !_strip(_textarea.value);
|
||||
}
|
||||
|
||||
/** Flush textarea: send real text to PTY and reset to phantom */
|
||||
function _flush() {
|
||||
// Never flush mid-composition: reading the value would send the IME's
|
||||
// provisional text, and resetting the textarea cancels the in-progress
|
||||
// composition on iOS Safari — silently eating the character being typed.
|
||||
// Any committed-but-unflushed text stays in the textarea and is sent
|
||||
// together by the next compositionend flush.
|
||||
if (_composing) {
|
||||
_t('flush SKIP composing');
|
||||
return;
|
||||
}
|
||||
const val = _strip(_textarea.value);
|
||||
_t(`flush ${val ? 'send len=' + val.length : 'empty'}`);
|
||||
if (val) {
|
||||
_send(val);
|
||||
}
|
||||
_resetToPhantom();
|
||||
}
|
||||
|
||||
/** Cancel any pending debounced flush */
|
||||
function _cancelDebouncedFlush() {
|
||||
if (_flushTimer) {
|
||||
clearTimeout(_flushTimer);
|
||||
_flushTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark that dictation rewriting is in progress */
|
||||
function _enterDictationMode() {
|
||||
_dictationActive = true;
|
||||
clearTimeout(_dictationDecayTimer);
|
||||
_dictationDecayTimer = setTimeout(() => {
|
||||
_dictationActive = false;
|
||||
_dictationDecayTimer = null;
|
||||
}, DICTATION_DECAY_MS);
|
||||
}
|
||||
|
||||
/** Schedule a flush after input settles */
|
||||
function _debouncedFlush() {
|
||||
_cancelDebouncedFlush();
|
||||
const delay = _dictationActive ? DEBOUNCE_DICTATION_MS : DEBOUNCE_KEYBOARD_MS;
|
||||
_flushTimer = setTimeout(() => {
|
||||
_flushTimer = null;
|
||||
_flush();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
return {
|
||||
init({ send }) {
|
||||
if (_initialized) this.destroy();
|
||||
|
||||
_send = send;
|
||||
_composing = false;
|
||||
_flushTimer = null;
|
||||
_textarea = document.getElementById('cjkInput');
|
||||
if (!_textarea) return this;
|
||||
_terminalContainer = document.getElementById('terminalContainer');
|
||||
|
||||
// Seed the phantom character for the hidden/immediate CJK path.
|
||||
_resetInput();
|
||||
_resetToPhantom();
|
||||
|
||||
_t('init v2-trace');
|
||||
|
||||
_listeners.mousedown = (e) => { e.stopPropagation(); };
|
||||
|
||||
// ── Wedged-IME recovery (Android ONLY) ──
|
||||
// Some Android IMEs (esp. 9-key Sogou/Xiaomi/Baidu) can wedge their
|
||||
// InputConnection: the keyboard composes in its own candidate bar but
|
||||
// delivers ZERO DOM events to the focused textarea. JS cannot detect
|
||||
// this (nothing fires) — but re-tapping the already-focused empty field
|
||||
// is the user's natural "it's stuck" gesture. A blur→focus cycle forces
|
||||
// the browser to restart the IME input session, which un-wedges it.
|
||||
// iOS is excluded: tapping the focused empty field there is normal
|
||||
// (paste callout, habitual tap), and the setTimeout refocus runs outside
|
||||
// the user-gesture stack, so the cycle would just misbehave.
|
||||
if (/Android/i.test(navigator.userAgent)) {
|
||||
_listeners.pointerdown = () => {
|
||||
if (document.activeElement === _textarea && !_composing && _isEffectivelyEmpty()) {
|
||||
_t('ime-reset (retap)');
|
||||
_textarea.blur();
|
||||
setTimeout(() => _textarea.focus(), 0);
|
||||
}
|
||||
};
|
||||
_textarea.addEventListener('pointerdown', _listeners.pointerdown);
|
||||
}
|
||||
_listeners.focus = () => {
|
||||
_t(`focus ${_vdesc(_textarea.value)}`);
|
||||
window.cjkActive = true;
|
||||
if (_isMobileComposer() && _textarea.value === PHANTOM) {
|
||||
_textarea.value = '';
|
||||
return;
|
||||
if (!_textarea.value) _resetToPhantom();
|
||||
};
|
||||
_listeners.blur = () => {
|
||||
_t(`blur composing=${_composing} ${_vdesc(_textarea.value)}`);
|
||||
// Keep cjkActive while CJK input is visible — iOS dictation and system
|
||||
// UI may steal focus temporarily, and clearing the flag during that
|
||||
// window lets xterm's onData process duplicated input.
|
||||
if (!_textarea.classList.contains('cjk-input-visible')) {
|
||||
window.cjkActive = false;
|
||||
}
|
||||
// Restore phantom if textarea was emptied while blurred
|
||||
if (!_textarea.value && !_isMobileComposer()) _resetToPhantom();
|
||||
// Reset composing state — some IMEs fire compositionstart without a
|
||||
// matching compositionend, leaving _composing stuck true and blocking
|
||||
// all subsequent input events.
|
||||
_composing = false;
|
||||
};
|
||||
_listeners.blur = () => { window.cjkActive = false; };
|
||||
_textarea.addEventListener('mousedown', _listeners.mousedown);
|
||||
_textarea.addEventListener('focus', _listeners.focus);
|
||||
_textarea.addEventListener('blur', _listeners.blur);
|
||||
|
||||
_listeners.xtermFocusRedirect = () => {
|
||||
if (!_isMobileComposer()) return;
|
||||
_textarea.focus();
|
||||
};
|
||||
if (_terminalContainer) {
|
||||
_xtermTextarea = _terminalContainer.querySelector('.xterm-helper-textarea');
|
||||
if (_xtermTextarea) {
|
||||
_xtermTextarea.addEventListener('focus', _listeners.xtermFocusRedirect, { capture: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Composition tracking ──
|
||||
// ── Composition tracking (keyboard IME — works for CJK typing) ──
|
||||
_listeners.compositionstart = () => {
|
||||
_t(`compstart ${_vdesc(_textarea.value)}`);
|
||||
_composing = true;
|
||||
if (_isMobileComposer()) {
|
||||
if (_textarea.value === PHANTOM) _textarea.value = '';
|
||||
return;
|
||||
}
|
||||
// Clear phantom so IME sees a clean textarea — some IMEs include
|
||||
// existing text in the composition region which would corrupt input.
|
||||
if (_textarea.value === PHANTOM) {
|
||||
_textarea.value = '';
|
||||
}
|
||||
_cancelDebouncedFlush();
|
||||
// Leave textarea.value untouched — programmatic changes during
|
||||
// compositionstart cancel the IME composition on iOS Safari.
|
||||
};
|
||||
_listeners.compositionend = () => {
|
||||
_t(`compend ${_vdesc(_textarea.value)}`);
|
||||
_composing = false;
|
||||
if (_isMobileComposer()) return;
|
||||
_cancelDebouncedFlush();
|
||||
// Defer flush: some Android IMEs haven't committed text to textarea
|
||||
// when compositionend fires. setTimeout(0) ensures we read the final value.
|
||||
setTimeout(_flush, 0);
|
||||
// Tracked so destroy() can cancel it; if the next composition starts
|
||||
// before it runs, _flush's _composing guard turns it into a no-op.
|
||||
clearTimeout(_compositionFlushTimer);
|
||||
_compositionFlushTimer = setTimeout(() => {
|
||||
_compositionFlushTimer = null;
|
||||
_flush();
|
||||
}, 0);
|
||||
};
|
||||
_textarea.addEventListener('compositionstart', _listeners.compositionstart);
|
||||
_textarea.addEventListener('compositionend', _listeners.compositionend);
|
||||
|
||||
// ── Keydown: special keys work REGARDLESS of composition state ──
|
||||
_listeners.keydown = (e) => {
|
||||
// Enter: flush accumulated text (or bare Enter if empty).
|
||||
// No isComposing guard — Android IMEs set isComposing=true for English
|
||||
// prediction, but Enter should ALWAYS send. We preventDefault to stop
|
||||
// the IME from also handling Enter (which could double-send or do nothing).
|
||||
_t(`keydown ${_kdesc(e.key)} kc=${e.keyCode} ic=${e.isComposing} c=${_composing}`);
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
_composing = false;
|
||||
_cancelDebouncedFlush();
|
||||
const val = _strip(_textarea.value);
|
||||
if (val) {
|
||||
_send(val + '\r');
|
||||
} else {
|
||||
_send('\r');
|
||||
}
|
||||
_resetInput();
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: clear textarea (always works)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
_composing = false;
|
||||
_resetInput();
|
||||
_cancelDebouncedFlush();
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl combos: forward to PTY (always works)
|
||||
if (e.ctrlKey && CTRL_KEYS[e.key]) {
|
||||
e.preventDefault();
|
||||
_send(CTRL_KEYS[e.key]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Below: only when NOT composing (composing keystrokes belong to IME)
|
||||
if (_composing) return;
|
||||
|
||||
if (_isMobileComposer()) {
|
||||
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send('\x7f');
|
||||
return;
|
||||
}
|
||||
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send(PASSTHROUGH_KEYS[e.key]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Below: only when NOT composing (composing keystrokes belong to IME).
|
||||
// Also check isComposing/keyCode 229 — the first keydown of a CJK
|
||||
// sequence arrives BEFORE compositionstart, so _composing is still false.
|
||||
if (_composing || e.isComposing || e.keyCode === 229) return;
|
||||
|
||||
// Backspace: forward to PTY when no real text in textarea
|
||||
// (Desktop path — Android uses the input event + phantom approach)
|
||||
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send('\x7f');
|
||||
@@ -236,62 +325,87 @@ const CjkInput = (() => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Single printable character: send immediately to PTY
|
||||
// (Desktop keyboards with physical keys — Android sends 'Unidentified')
|
||||
// Single printable character: send immediately to PTY.
|
||||
// Third-party IMEs on iOS may ignore preventDefault, so the char
|
||||
// still enters the textarea and fires an input event — _keydownSentAt
|
||||
// tells the input handler to skip that echo.
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
|
||||
e.preventDefault();
|
||||
_send(e.key);
|
||||
_keydownSentAt = performance.now();
|
||||
_keydownSentText = e.key;
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
};
|
||||
_textarea.addEventListener('keydown', _listeners.keydown);
|
||||
|
||||
// ── Input event: the primary path for Android virtual keyboards ──
|
||||
// Android sends keyCode 229 + key "Unidentified" for virtual key presses,
|
||||
// making keydown unreliable. input fires AFTER character insertion and
|
||||
// carries inputType which tells us whether the text is final or tentative.
|
||||
// ── Input event: primary path for virtual keyboards + dictation ──
|
||||
_listeners.input = (e) => {
|
||||
if (_isMobileComposer()) {
|
||||
if (_textarea.value.includes(PHANTOM)) {
|
||||
_textarea.value = _strip(_textarea.value);
|
||||
}
|
||||
return;
|
||||
_t(`input ${e.inputType || '?'} ic=${e.isComposing} c=${_composing} ${_vdesc(_textarea.value)}`);
|
||||
// ── Stuck-composition recovery ──
|
||||
// Some IMEs (WeChat/Sogou keyboards) fire compositionstart without a
|
||||
// matching compositionend. A stale _composing=true blocks every flush
|
||||
// below — committed CJK text piles up in the textarea and never
|
||||
// reaches the PTY. When the event itself says composition is over
|
||||
// (isComposing false AND a non-composition inputType), trust it.
|
||||
if (
|
||||
_composing &&
|
||||
e.isComposing === false &&
|
||||
e.inputType !== 'insertCompositionText' &&
|
||||
e.inputType !== 'deleteCompositionText'
|
||||
) {
|
||||
_t('UNSTICK composing');
|
||||
_composing = false;
|
||||
}
|
||||
|
||||
// ── Backspace / delete detection ──
|
||||
// Android long-press backspace generates rapid deleteContentBackward events.
|
||||
// The phantom character ensures the textarea is never truly empty, so each
|
||||
// press/repeat fires an input event that we can catch here.
|
||||
if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') {
|
||||
if (_composing) return;
|
||||
if (_isEffectivelyEmpty()) {
|
||||
// No real text left — forward backspace to PTY
|
||||
_cancelDebouncedFlush();
|
||||
_send('\x7f');
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
// User is editing their own text in the textarea — let it be.
|
||||
// Ensure phantom is still present for the NEXT backspace.
|
||||
// Delete on non-empty text outside composition = dictation rewrite.
|
||||
// The IME is revising provisional text — switch to long debounce.
|
||||
_enterDictationMode();
|
||||
if (!_textarea.value.startsWith(PHANTOM)) {
|
||||
_textarea.value = PHANTOM + _textarea.value;
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
_debouncedFlush();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_composing) {
|
||||
// insertText during composition = IME committed final text
|
||||
// (e.g., punctuation key inserts 。directly, or IME confirms a word).
|
||||
// Flush immediately — this text won't change.
|
||||
if (e.inputType === 'insertText') {
|
||||
_flush();
|
||||
return;
|
||||
}
|
||||
// insertCompositionText = IME is still working (pinyin, candidates,
|
||||
// English prediction). Wait for compositionend to flush.
|
||||
// insertReplacementText = dictation/autocorrect refinement
|
||||
if (e.inputType === 'insertReplacementText') {
|
||||
_enterDictationMode();
|
||||
_debouncedFlush();
|
||||
return;
|
||||
}
|
||||
// Outside composition: send immediately
|
||||
_flush();
|
||||
|
||||
if (_composing) return;
|
||||
|
||||
// Keydown handler already sent this character — clear the textarea
|
||||
// echo that the IME inserted despite preventDefault. Content-checked:
|
||||
// only a value matching the sent char is an echo. Anything else (e.g.
|
||||
// an IME committing CJK text right after a keydown-sent char) is real
|
||||
// input and must flow through to the debounced flush, not be dropped.
|
||||
if (performance.now() - _keydownSentAt < 100) {
|
||||
const cur = _strip(_textarea.value);
|
||||
if (cur === '' || cur === _keydownSentText) {
|
||||
_t('echo-drop');
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Outside composition: keyboard typing or voice dictation.
|
||||
// If dictation mode was detected (delete/replacement events seen
|
||||
// recently), use long debounce. Otherwise short debounce for keyboard.
|
||||
_debouncedFlush();
|
||||
};
|
||||
_textarea.addEventListener('input', _listeners.input);
|
||||
|
||||
@@ -299,19 +413,39 @@ const CjkInput = (() => {
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* Discard pending text and timers (e.g. on session switch, so stale text
|
||||
* can't flush into the wrong session). Restores the phantom so backspace
|
||||
* forwarding keeps working — unlike a raw `textarea.value = ''`.
|
||||
*/
|
||||
clear() {
|
||||
if (!_initialized || !_textarea) return;
|
||||
_t('clear (external)');
|
||||
_cancelDebouncedFlush();
|
||||
clearTimeout(_compositionFlushTimer);
|
||||
_compositionFlushTimer = null;
|
||||
_composing = false;
|
||||
_resetToPhantom();
|
||||
},
|
||||
|
||||
/** Diagnostic: recent IME event trace (ring buffer). */
|
||||
getTrace() {
|
||||
return _trace.slice();
|
||||
},
|
||||
|
||||
destroy() {
|
||||
_cancelDebouncedFlush();
|
||||
clearTimeout(_compositionFlushTimer);
|
||||
_compositionFlushTimer = null;
|
||||
clearTimeout(_dictationDecayTimer);
|
||||
_dictationActive = false;
|
||||
if (_textarea) {
|
||||
for (const [event, handler] of Object.entries(_listeners)) {
|
||||
if (handler) _textarea.removeEventListener(event, handler);
|
||||
}
|
||||
}
|
||||
if (_xtermTextarea && _listeners.xtermFocusRedirect) {
|
||||
_xtermTextarea.removeEventListener('focus', _listeners.xtermFocusRedirect, { capture: true });
|
||||
}
|
||||
window.cjkActive = false;
|
||||
_composing = false;
|
||||
_terminalContainer = null;
|
||||
_xtermTextarea = null;
|
||||
for (const key of Object.keys(_listeners)) delete _listeners[key];
|
||||
_initialized = false;
|
||||
},
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
* Defines two exports:
|
||||
*
|
||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, paste, Esc, and dismiss.
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss.
|
||||
* The paste button opens a dialog that handles both text paste and image attach
|
||||
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
|
||||
* Destructive actions (/clear) require double-tap confirmation (2s amber state).
|
||||
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
*
|
||||
@@ -100,6 +100,7 @@ const KeyboardAccessoryBar = {
|
||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||
<button class="accessory-btn" data-action="init" title="/init">/init</button>
|
||||
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
|
||||
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
|
||||
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
@@ -129,7 +130,7 @@ const KeyboardAccessoryBar = {
|
||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
|
||||
if (refocusActions.has(action) ||
|
||||
(action === 'clear' && this._confirmAction)) {
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
app.terminal.focus();
|
||||
}
|
||||
@@ -192,11 +193,12 @@ const KeyboardAccessoryBar = {
|
||||
case 'init':
|
||||
this.sendCommand('/init');
|
||||
break;
|
||||
case 'clear': {
|
||||
// Require double-tap: first tap turns amber, second tap within 2s sends
|
||||
case 'clear':
|
||||
case 'compact': {
|
||||
const cmd = action === 'clear' ? '/clear' : '/compact';
|
||||
if (this._confirmAction === action && this._confirmTimer) {
|
||||
this.clearConfirm();
|
||||
this.sendCommand('/clear');
|
||||
this.sendCommand(cmd);
|
||||
} else {
|
||||
this.setConfirm(action, btn);
|
||||
}
|
||||
@@ -300,7 +302,10 @@ const KeyboardAccessoryBar = {
|
||||
const sendText = () => {
|
||||
const text = textarea.value;
|
||||
close();
|
||||
if (text) app.sendInput(text);
|
||||
if (text) {
|
||||
app.sendInput(text);
|
||||
setTimeout(() => app.sendInput('\r'), 80);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter to images, close the dialog, and hand off to the shared
|
||||
|
||||
@@ -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 (
|
||||
@@ -291,57 +320,60 @@ const KeyboardHandler = {
|
||||
updateLayoutForKeyboard() {
|
||||
if (!window.visualViewport) return;
|
||||
|
||||
// Only adjust on mobile
|
||||
if (!MobileDetection.isSmallScreen() && !MobileDetection.isMediumScreen()) {
|
||||
if (!MobileDetection.isTouchDevice()) {
|
||||
this.resetLayout();
|
||||
return;
|
||||
}
|
||||
|
||||
const toolbar = document.querySelector('.toolbar');
|
||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||
const cjkInput = document.getElementById('cjkInput');
|
||||
const main = document.querySelector('.main');
|
||||
const isSmallMedium = MobileDetection.isSmallScreen() || MobileDetection.isMediumScreen();
|
||||
|
||||
if (this.keyboardVisible) {
|
||||
// Calculate how far the toolbar (position:fixed, bottom:0) needs to
|
||||
// translate up so it sits at the bottom of the visual viewport.
|
||||
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
|
||||
// when the user types in xterm's hidden textarea.
|
||||
//
|
||||
// MUST measure against the LAYOUT viewport (window.innerHeight): the
|
||||
// bars are position:fixed, which anchors to the layout viewport — on
|
||||
// iOS that keeps its full height while the keyboard is open. Measuring
|
||||
// the shrunken .app instead (its height tracks --app-height = visual
|
||||
// viewport) made the offset compute to 0 on iOS, leaving the toolbar
|
||||
// and accessory bar behind the OS keyboard (0.9.8 regression). On
|
||||
// Android the layout viewport itself shrinks with the keyboard, so
|
||||
// innerHeight === visualBottom and the offset is naturally 0 there.
|
||||
const layoutHeight = window.innerHeight;
|
||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
|
||||
|
||||
// Move toolbar and accessory bar above keyboard.
|
||||
// When keyboardOffset is 0 (viewport scrolled to layout bottom),
|
||||
// the bars are naturally positioned via their CSS bottom values —
|
||||
// just clear the transforms. Never dismiss keyboard state here;
|
||||
// that's handleViewportResize's job.
|
||||
if (toolbar) {
|
||||
toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
if (cjkInput?.classList.contains('cjk-input-visible')) {
|
||||
cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
|
||||
// Reserve only Codeman's visible controls. The OS keyboard is outside
|
||||
// the visual viewport; adding its height here creates a large blank area
|
||||
// above the mobile toolbar on iPhone.
|
||||
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
|
||||
if (main && keyboardHeight > 0) {
|
||||
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
|
||||
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
|
||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||
|
||||
if (isSmallMedium) {
|
||||
// Phones/small tablets: toolbar and accessory bar are position:fixed
|
||||
// via CSS. Use translateY to lift them above the keyboard.
|
||||
const toolbar = document.querySelector('.toolbar');
|
||||
const main = document.querySelector('.main');
|
||||
|
||||
const layoutHeight = window.innerHeight;
|
||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
|
||||
|
||||
if (toolbar) {
|
||||
toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
}
|
||||
if (main && keyboardHeight > 0) {
|
||||
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
|
||||
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
|
||||
}
|
||||
} else if (keyboardHeight > 0) {
|
||||
// iPad: use direct bottom positioning (translateY unreliable —
|
||||
// iOS auto-scrolls the visual viewport, making keyboardOffset ≈ 0).
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.bottom = `${keyboardHeight}px`;
|
||||
}
|
||||
}
|
||||
|
||||
// CJK textarea positioning (always position:fixed on touch devices).
|
||||
if (cjkInput?.classList.contains('cjk-input-visible') && keyboardHeight > 0) {
|
||||
if (isSmallMedium) {
|
||||
// Phones: use translateY like toolbar/accessory bar.
|
||||
const layoutHeight = window.innerHeight;
|
||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
|
||||
cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
|
||||
cjkInput.style.bottom = '';
|
||||
} else {
|
||||
// iPad: direct bottom = keyboard + accessory bar height.
|
||||
cjkInput.style.bottom = `${keyboardHeight + 44}px`;
|
||||
cjkInput.style.transform = '';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.resetLayout();
|
||||
@@ -360,9 +392,11 @@ const KeyboardHandler = {
|
||||
}
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = '';
|
||||
accessoryBar.style.bottom = '';
|
||||
}
|
||||
if (cjkInput) {
|
||||
cjkInput.style.transform = '';
|
||||
cjkInput.style.bottom = '';
|
||||
}
|
||||
if (main) {
|
||||
main.style.paddingBottom = '';
|
||||
|
||||
+139
-175
@@ -162,7 +162,11 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
.case-select-group {
|
||||
max-width: 150px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.case-combobox {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
@@ -194,6 +198,27 @@ html.mobile-init .file-browser-panel {
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.command-palette-modal {
|
||||
padding: 10vh 0.75rem 0;
|
||||
}
|
||||
|
||||
.command-palette-shell {
|
||||
width: 100%;
|
||||
max-height: 74vh;
|
||||
}
|
||||
|
||||
.command-palette-input-row {
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.command-palette-input-row kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.command-palette-item {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.modal-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@@ -325,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;
|
||||
@@ -333,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 */
|
||||
@@ -434,11 +468,25 @@ html.mobile-init .file-browser-panel {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Hide header settings gear and lifecycle log on mobile - settings moved to toolbar.
|
||||
/* Hide header settings gear, lifecycle log, away digest, session manager, and
|
||||
file viewer on mobile - settings moved to toolbar; the others are secondary /
|
||||
desktop-oriented controls that don't belong on the cramped phone header (the
|
||||
session manager stays reachable via the Ctrl+K palette's "Browse all sessions"
|
||||
item; the file viewer button is opt-in but its panel is desktop-sized).
|
||||
(The attachments button is opt-in / default-hidden everywhere via its own
|
||||
--hidden marker, so it needs no mobile-specific rule here.) */
|
||||
.btn-icon-header.btn-settings,
|
||||
.btn-icon-header.btn-lifecycle-log {
|
||||
.btn-icon-header.btn-lifecycle-log,
|
||||
.btn-icon-header.btn-away-digest,
|
||||
.btn-icon-header.btn-session-manager,
|
||||
.btn-icon-header.btn-file-viewer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* The big labeled Admin Panel button is desktop-only (admin-gated, revealed by
|
||||
admin-ui.js). On phones admins still reach user management via App Settings →
|
||||
Users, so the cramped header stays minimal. */
|
||||
.btn-admin-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -777,6 +825,20 @@ html.mobile-init .file-browser-panel {
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
/* Gemini mode colors on mobile */
|
||||
.btn-toolbar.btn-run.mode-gemini,
|
||||
.btn-toolbar.btn-run-gear.mode-gemini {
|
||||
background: #10243f;
|
||||
border-color: rgba(96, 165, 250, 0.3);
|
||||
color: #dbeafe;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-run.mode-gemini:active,
|
||||
.btn-toolbar.btn-run-gear.mode-gemini:active {
|
||||
background: #174ea6;
|
||||
border-color: rgba(96, 165, 250, 0.5);
|
||||
}
|
||||
|
||||
/* Run mode dropdown menu — positioned above toolbar on mobile */
|
||||
.run-mode-menu {
|
||||
bottom: 100%;
|
||||
@@ -1065,85 +1127,7 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
/* Paste overlay for iOS clipboard access */
|
||||
.paste-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
}
|
||||
|
||||
.paste-dialog {
|
||||
background: var(--bg-secondary, #1e1e2e);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
width: calc(100% - 24px);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.paste-textarea {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
max-height: 200px;
|
||||
background: var(--bg-primary, #0d0d14);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
resize: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.paste-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.paste-cancel, .paste-new, .paste-send, .paste-image {
|
||||
padding: 8px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Image attach button — left-aligned, accent outline */
|
||||
.paste-image {
|
||||
margin-right: auto;
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
border: 1px solid var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-cancel {
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--text-secondary, #aaa);
|
||||
}
|
||||
|
||||
.paste-new {
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
border: 1px solid var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-send {
|
||||
background: var(--accent-color, #7aa2f7);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Paste overlay styles extracted to universal section below (line ~2293+) */
|
||||
|
||||
/* LEGACY: Hide old toolbar select (no longer used on mobile) */
|
||||
.toolbar-select {
|
||||
@@ -1241,6 +1225,44 @@ html.mobile-init .file-browser-panel {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.away-digest-modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.away-digest-ranges {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.away-digest-ranges .filter-btn {
|
||||
min-height: 38px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
|
||||
.away-digest-custom-range,
|
||||
.away-digest-custom-range.active {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.away-digest-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.away-digest-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.away-digest-action {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.modal-footer,
|
||||
.form-actions {
|
||||
padding: 0.75rem 1rem;
|
||||
@@ -1282,13 +1304,41 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
.response-viewer {
|
||||
padding-bottom: var(--safe-area-bottom, 0px);
|
||||
/* dvh tracks the visible viewport on iOS Safari (vh = large viewport and would clip
|
||||
the header/close button off-screen); the vh line is the old-engine fallback */
|
||||
max-height: 88vh;
|
||||
max-height: 92dvh;
|
||||
}
|
||||
|
||||
.response-viewer-body {
|
||||
font-size: 12px;
|
||||
padding: 12px;
|
||||
font-size: 14.5px;
|
||||
line-height: 1.65;
|
||||
padding: 16px 16px 24px;
|
||||
--rv-content-max: 100%;
|
||||
}
|
||||
|
||||
.response-viewer-body .rv-text pre,
|
||||
.response-viewer-body pre {
|
||||
/* Slightly smaller on mobile so diagrams fit better before scrolling */
|
||||
padding: 12px 14px;
|
||||
margin-left: -4px;
|
||||
margin-right: -4px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.response-viewer-body .rv-text pre code,
|
||||
.response-viewer-body pre code {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.response-viewer-body .rv-text h1,
|
||||
.response-viewer-body > h1 { font-size: 1.35em; }
|
||||
.response-viewer-body .rv-text h2,
|
||||
.response-viewer-body > h2 { font-size: 1.2em; }
|
||||
.response-viewer-body .rv-text h3,
|
||||
.response-viewer-body > h3 { font-size: 1.08em; }
|
||||
|
||||
/* Compact welcome overlay for mobile */
|
||||
.welcome-content {
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
@@ -2177,95 +2227,9 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================================
|
||||
Keyboard Accessory Bar — all mobile/tablet sizes
|
||||
Visual styles extracted from phone breakpoint so they apply on iPad too.
|
||||
Phone-specific positioning (position: fixed) remains in @media (max-width: 430px).
|
||||
============================================================================ */
|
||||
.keyboard-accessory-bar {
|
||||
display: none;
|
||||
height: 44px;
|
||||
background: #1a1a1a;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 6px 8px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.keyboard-accessory-bar.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.keyboard-accessory-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accessory-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
color: #e5e5e5;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.accessory-btn.confirming {
|
||||
background: #6b4f00;
|
||||
border-color: #b8860b;
|
||||
color: #ffd54f;
|
||||
}
|
||||
|
||||
.accessory-btn:active {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.accessory-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow {
|
||||
padding: 6px 10px;
|
||||
background: #2563eb;
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.accessory-btn-arrow:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss {
|
||||
margin-left: auto;
|
||||
flex: 1 1 0;
|
||||
max-width: 80px;
|
||||
padding: 10px 8px;
|
||||
background: #334d6e;
|
||||
border-color: rgba(100, 150, 200, 0.4);
|
||||
color: #c0d4e8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss:active {
|
||||
background: #3d5f85;
|
||||
}
|
||||
/* Keyboard accessory bar + paste overlay base styles moved to styles.css
|
||||
(always loaded — covers iPad landscape where mobile.css doesn't load).
|
||||
Phone-specific overrides remain in @media (max-width: 430px) above. */
|
||||
|
||||
/* ============================================================================
|
||||
iOS Safari Specific Fixes
|
||||
|
||||
@@ -273,7 +273,7 @@ class NotificationManager {
|
||||
const readClass = n.read ? '' : ' unread';
|
||||
const countLabel = n.count > 1 ? `<span class="notif-item-count">×${n.count}</span>` : '';
|
||||
const sessionChip = n.sessionName ? `<span class="notif-item-session">${escapeHtml(n.sessionName)}</span>` : '';
|
||||
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification('${escapeHtml(n.id)}')">
|
||||
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification(${escapeHtml(JSON.stringify(n.id))})">
|
||||
<div class="notif-item-header">
|
||||
<span class="notif-item-title">${escapeHtml(n.title)}${countLabel}</span>
|
||||
<span class="notif-item-time">${this.relativeTime(n.timestamp)}</span>
|
||||
@@ -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
|
||||
|
||||
@@ -392,10 +392,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
let actions = '';
|
||||
if (orchState === 'executing' || orchState === 'failed') {
|
||||
if (phase.status === 'pending') {
|
||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase('${phase.id}')" title="Skip">skip</button>`;
|
||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase(${escapeHtml(JSON.stringify(phase.id))})" title="Skip">skip</button>`;
|
||||
}
|
||||
if (phase.status === 'failed') {
|
||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase('${phase.id}')" title="Retry">retry</button>`;
|
||||
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase(${escapeHtml(JSON.stringify(phase.id))})" title="Retry">retry</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+763
-17
@@ -13,6 +13,15 @@
|
||||
* @loadorder 11 of 15 — loaded after settings-ui.js, before session-ui.js
|
||||
*/
|
||||
|
||||
const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed';
|
||||
const AWAY_DIGEST_SECTIONS = [
|
||||
['needsAttention', 'Needs Attention'],
|
||||
['completed', 'Completed'],
|
||||
['stillRunning', 'Still Running'],
|
||||
['idle', 'Idle'],
|
||||
['informational', 'Informational'],
|
||||
];
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
_addActivityEntry(agentId, entry, maxSize = 50) {
|
||||
const activity = this.subagentActivity.get(agentId) || [];
|
||||
@@ -73,6 +82,33 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Remote auto-reconnect (COD-108)
|
||||
_onRemoteSessionReconnected(data) {
|
||||
const id = this.getShortId(data.sessionId);
|
||||
this.showToast(`Remote session ${id} reconnected`, 'success');
|
||||
},
|
||||
|
||||
_onRemoteReconnectExhausted(data) {
|
||||
const sessionId = data.sessionId;
|
||||
const id = this.getShortId(sessionId);
|
||||
// Auto-reconnect gave up after the bounded backoff. Surface a manual
|
||||
// "Reconnect" affordance that re-triggers the attach path (force-reload the
|
||||
// session, which re-runs the create/attach flow against the durable remote).
|
||||
this.showToast(`Remote session ${id} dropped — auto-reconnect gave up`, 'error', {
|
||||
duration: 15000,
|
||||
action: {
|
||||
label: 'Reconnect',
|
||||
onClick: () => {
|
||||
if (this.sessions && this.sessions.has(sessionId)) {
|
||||
this.selectSession(sessionId, { forceReload: true });
|
||||
} else {
|
||||
this.showToast('Session no longer available', 'warning');
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
// Bash tools
|
||||
_onBashToolStart(data) {
|
||||
@@ -241,6 +277,686 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.openImagePopup(data);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Command Palette (COD-153)
|
||||
// Fast Cmd/Ctrl+K switcher for currently open sessions, plus launch-new.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
shouldOpenCommandPaletteFromShortcut(e) {
|
||||
if (!e) return false;
|
||||
// Every palette chord requires Ctrl/Cmd/Alt (capture enforces the same for
|
||||
// rebinds), so plain typing exits before any registry work — this runs on
|
||||
// the document AND xterm keydown hot paths.
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) return false;
|
||||
|
||||
// Registry-aware chord check (COD-157): honors a rebound or disabled
|
||||
// palette shortcut. Falls back to the default Ctrl/Cmd/Alt+K chord when the
|
||||
// registry isn't available (isolated test harnesses).
|
||||
const registryAvailable =
|
||||
typeof this.getShortcutRegistry === 'function' && typeof this.matchesShortcutEvent === 'function';
|
||||
const palette = registryAvailable
|
||||
? this.getShortcutRegistry().find((s) => s.id === 'command-palette')
|
||||
: null;
|
||||
if (palette) {
|
||||
if (palette.disabled || !this.matchesShortcutEvent(e, palette)) return false;
|
||||
} else {
|
||||
const key = (e.key || '').toLowerCase();
|
||||
if (key !== 'k' && e.code !== 'KeyK') return false;
|
||||
// Don't hijack chords with extra modifiers (Ctrl+Shift+K is the Firefox
|
||||
// devtools console; matchesShortcutEvent applies the same rule above).
|
||||
if (e.shiftKey) return false;
|
||||
}
|
||||
|
||||
const target = e.target;
|
||||
if (!target) return true;
|
||||
const tagName = (target.tagName || '').toUpperCase();
|
||||
const className = typeof target.className === 'string' ? target.className : '';
|
||||
const isXtermHelper =
|
||||
target.classList?.contains?.('xterm-helper-textarea') || className.includes('xterm-helper-textarea');
|
||||
if (isXtermHelper) return true;
|
||||
if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') return false;
|
||||
if (target.isContentEditable) return false;
|
||||
if (typeof target.closest === 'function' && target.closest('[contenteditable="true"]')) return false;
|
||||
return true;
|
||||
},
|
||||
|
||||
openCommandPalette() {
|
||||
const modal = document.getElementById('commandPaletteModal');
|
||||
const search = document.getElementById('commandPaletteSearch');
|
||||
if (!modal || !search) return;
|
||||
|
||||
this.commandPaletteActiveIndex = 0;
|
||||
search.value = '';
|
||||
modal.classList.add('active');
|
||||
|
||||
this._wireCommandPalette();
|
||||
this.renderCommandPalette();
|
||||
|
||||
search.focus();
|
||||
search.select?.();
|
||||
},
|
||||
|
||||
closeCommandPalette() {
|
||||
const modal = document.getElementById('commandPaletteModal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
},
|
||||
|
||||
_wireCommandPalette() {
|
||||
if (this._commandPaletteWired) return;
|
||||
this._commandPaletteWired = true;
|
||||
|
||||
const modal = document.getElementById('commandPaletteModal');
|
||||
const search = document.getElementById('commandPaletteSearch');
|
||||
const list = document.getElementById('commandPaletteList');
|
||||
|
||||
search?.addEventListener('input', () => {
|
||||
this.commandPaletteActiveIndex = 0;
|
||||
this.renderCommandPalette();
|
||||
});
|
||||
|
||||
search?.addEventListener('keydown', async (e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this.moveCommandPaletteSelection(1);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.moveCommandPaletteSelection(-1);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await this.activateCommandPaletteItem();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.closeCommandPalette();
|
||||
}
|
||||
});
|
||||
|
||||
modal?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.closeCommandPalette();
|
||||
}
|
||||
});
|
||||
|
||||
list?.addEventListener?.('click', (e) => {
|
||||
const row = e.target?.closest?.('[data-command-index]');
|
||||
if (!row) return;
|
||||
this.commandPaletteActiveIndex = Number(row.dataset.commandIndex) || 0;
|
||||
void this.activateCommandPaletteItem();
|
||||
});
|
||||
},
|
||||
|
||||
buildCommandPaletteItems(query = '') {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const orderedIds = [
|
||||
...(Array.isArray(this.sessionOrder) ? this.sessionOrder : []),
|
||||
...Array.from(this.sessions?.keys?.() || []).filter((id) => !this.sessionOrder?.includes?.(id)),
|
||||
];
|
||||
const seen = new Set();
|
||||
const sessionItems = [];
|
||||
|
||||
for (const sessionId of orderedIds) {
|
||||
if (seen.has(sessionId)) continue;
|
||||
seen.add(sessionId);
|
||||
const session = this.sessions?.get?.(sessionId);
|
||||
if (!session) continue;
|
||||
const title = this.getSessionName?.(session) || session.name || session.title || sessionId.slice(0, 8);
|
||||
const subtitleParts = [session.workingDir, session.mode, session.status].filter(Boolean);
|
||||
const haystack = [title, session.workingDir, session.mode, session.status, sessionId].filter(Boolean).join(' ').toLowerCase();
|
||||
if (needle && !haystack.includes(needle)) continue;
|
||||
sessionItems.push({
|
||||
id: `session:${sessionId}`,
|
||||
type: 'session',
|
||||
sessionId,
|
||||
title,
|
||||
subtitle: subtitleParts.join(' · '),
|
||||
});
|
||||
}
|
||||
|
||||
sessionItems.push(this._buildCommandPaletteNewSessionItem(query));
|
||||
sessionItems.push({ id: 'browse-sessions', type: 'browse-sessions', title: 'Browse all sessions…', subtitle: 'Open Session Manager' });
|
||||
return sessionItems;
|
||||
},
|
||||
|
||||
_buildCommandPaletteNewSessionItem(query = '') {
|
||||
const mode = this.runMode || this._runMode || 'claude';
|
||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini' };
|
||||
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
|
||||
return {
|
||||
id: 'new-session',
|
||||
type: 'new-session',
|
||||
caseName,
|
||||
title: 'New session',
|
||||
subtitle: `Run ${labels[mode] || mode} in ${caseName}`,
|
||||
};
|
||||
},
|
||||
|
||||
_findCommandPaletteCaseMatch(query = '') {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle || !Array.isArray(this.cases)) return null;
|
||||
|
||||
const scoreCase = (caseItem) => {
|
||||
const name = String(caseItem?.name || '').trim();
|
||||
if (!name) return 0;
|
||||
const haystack = [
|
||||
name,
|
||||
caseItem?.path,
|
||||
caseItem?.casePath,
|
||||
caseItem?.workingDir,
|
||||
caseItem?.remote?.path,
|
||||
caseItem?.remote?.hostId,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
const lowerName = name.toLowerCase();
|
||||
if (lowerName === needle) return 100;
|
||||
if (lowerName.startsWith(needle)) return 90;
|
||||
if (lowerName.includes(needle)) return 80;
|
||||
if (haystack.includes(needle)) return 60;
|
||||
return 0;
|
||||
};
|
||||
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
for (const caseItem of this.cases) {
|
||||
const score = scoreCase(caseItem);
|
||||
if (score > bestScore) {
|
||||
best = caseItem;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best?.name || null;
|
||||
},
|
||||
|
||||
renderCommandPalette() {
|
||||
const search = document.getElementById('commandPaletteSearch');
|
||||
const list = document.getElementById('commandPaletteList');
|
||||
if (!list) return;
|
||||
|
||||
const query = search?.value || '';
|
||||
const items = this.buildCommandPaletteItems(query);
|
||||
this.commandPaletteItems = items;
|
||||
this.commandPaletteActiveIndex = Math.max(0, Math.min(this.commandPaletteActiveIndex || 0, items.length - 1));
|
||||
|
||||
list.innerHTML = items
|
||||
.map((item, index) => {
|
||||
const active = index === this.commandPaletteActiveIndex ? ' active' : '';
|
||||
const icon = item.type === 'new-session' ? '+' : item.type === 'browse-sessions' ? '≡' : '›';
|
||||
const browse = item.type === 'browse-sessions' ? ' command-palette-item--browse' : '';
|
||||
return `
|
||||
<button class="command-palette-item${active}${browse}" type="button" data-command-index="${index}">
|
||||
<span class="command-palette-icon" aria-hidden="true">${icon}</span>
|
||||
<span class="command-palette-text">
|
||||
<span class="command-palette-title">${escapeHtml(item.title)}</span>
|
||||
<span class="command-palette-subtitle">${escapeHtml(item.subtitle || '')}</span>
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
|
||||
moveCommandPaletteSelection(delta) {
|
||||
const items = this.commandPaletteItems || this.buildCommandPaletteItems(document.getElementById('commandPaletteSearch')?.value || '');
|
||||
if (!items.length) return;
|
||||
this.commandPaletteActiveIndex = (this.commandPaletteActiveIndex + delta + items.length) % items.length;
|
||||
this.renderCommandPalette();
|
||||
},
|
||||
|
||||
async activateCommandPaletteItem(index = this.commandPaletteActiveIndex || 0) {
|
||||
const item = (this.commandPaletteItems || [])[index];
|
||||
if (!item) return;
|
||||
|
||||
this.closeCommandPalette();
|
||||
if (item.type === 'session' && item.sessionId) {
|
||||
await this.selectSession(item.sessionId);
|
||||
return;
|
||||
}
|
||||
if (item.type === 'browse-sessions') {
|
||||
this.openSessionManager();
|
||||
return;
|
||||
}
|
||||
if (item.type === 'new-session') {
|
||||
const caseSelect = document.getElementById('quickStartCase');
|
||||
if (caseSelect && item.caseName) {
|
||||
if (
|
||||
caseSelect.tagName === 'SELECT' &&
|
||||
typeof caseSelect.appendChild === 'function' &&
|
||||
!Array.from(caseSelect.options || []).some((option) => option.value === item.caseName)
|
||||
) {
|
||||
const option = document.createElement('option');
|
||||
option.value = item.caseName;
|
||||
option.textContent = item.caseName;
|
||||
caseSelect.appendChild(option);
|
||||
}
|
||||
// selectQuickStartCase keeps the searchable combobox, dir display, and
|
||||
// persisted last-used case in sync with the palette's pick (COD-151);
|
||||
// fall back to a bare value set when the picker mixin isn't loaded.
|
||||
if (typeof this.selectQuickStartCase === 'function') {
|
||||
this.selectQuickStartCase(item.caseName);
|
||||
} else {
|
||||
caseSelect.value = item.caseName;
|
||||
}
|
||||
}
|
||||
await this.run();
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Manager Modal (COD-121)
|
||||
// Unified session list (GET /api/sessions/unified) reachable mid-session,
|
||||
// with a server-side search box. Reuses the history item renderer; clicking
|
||||
// a live row switches to it, a history row resumes the conversation.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async openSessionManager() {
|
||||
const modal = document.getElementById('sessionManagerModal');
|
||||
if (modal) {
|
||||
modal.classList.add('active');
|
||||
// Escape closes the modal even while focus is in the search input. A
|
||||
// modal-scoped listener is robust regardless of the global Escape chain
|
||||
// (which runs other close handlers first and can short-circuit). Wire once.
|
||||
if (!this._sessionManagerEscWired) {
|
||||
this._sessionManagerEscWired = true;
|
||||
modal.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.closeSessionManager();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure cases are loaded so item subtitles can show "#caseName" labels.
|
||||
// Mirror loadHistorySessions(): prefer already-loaded this.cases.
|
||||
if (!Array.isArray(this.cases) || this.cases.length === 0) {
|
||||
try {
|
||||
const r = await fetch('/api/cases');
|
||||
const d = r.ok ? await r.json() : null;
|
||||
this.cases = d?.data || [];
|
||||
} catch {
|
||||
this.cases = this.cases || [];
|
||||
}
|
||||
}
|
||||
|
||||
const search = document.getElementById('sessionManagerSearch');
|
||||
if (search) {
|
||||
// Wire the debounced search input once (lazy — the element exists by
|
||||
// the time the modal is first opened, and mixin methods are bound).
|
||||
if (!this._sessionManagerSearchWired) {
|
||||
this._sessionManagerSearchWired = true;
|
||||
search.addEventListener('input', () => {
|
||||
const value = search.value.trim();
|
||||
this._debouncedCall('sessionManagerSearch', () => this._loadSessionManagerList(value), 200);
|
||||
});
|
||||
}
|
||||
search.value = '';
|
||||
search.focus();
|
||||
}
|
||||
await this._loadSessionManagerList('');
|
||||
},
|
||||
|
||||
closeSessionManager() {
|
||||
const modal = document.getElementById('sessionManagerModal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
},
|
||||
|
||||
/** Replace the Session Manager list body with a single status line. */
|
||||
_setSessionManagerMessage(list, message) {
|
||||
list.replaceChildren();
|
||||
const line = document.createElement('p');
|
||||
line.className = 'empty-message';
|
||||
line.textContent = message;
|
||||
list.appendChild(line);
|
||||
},
|
||||
|
||||
async _loadSessionManagerList(q = '') {
|
||||
this._sessionManagerQuery = q;
|
||||
const list = document.getElementById('sessionManagerList');
|
||||
if (!list) return;
|
||||
try {
|
||||
const url = '/api/sessions/unified?limit=200' + (q ? '&q=' + encodeURIComponent(q) : '');
|
||||
const res = await fetch(url);
|
||||
const data = await res.json().catch(() => null);
|
||||
// ApiResponse envelope: { success: true, data: { sessions, total } }.
|
||||
// Surface failures instead of rendering them as an empty result set.
|
||||
if (!res.ok || !data || data.success === false || !data.data) {
|
||||
this._setSessionManagerMessage(list, data?.error || `Failed to load sessions (HTTP ${res.status})`);
|
||||
return;
|
||||
}
|
||||
const sessions = data.data.sessions || [];
|
||||
list.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
this._setSessionManagerMessage(list, q ? 'No sessions match your search' : 'No sessions found');
|
||||
return;
|
||||
}
|
||||
for (const s of sessions) {
|
||||
// Adapt UnifiedSessionItem (lastActivityAt epoch-ms, optional fields) to
|
||||
// the history-record shape _buildHistoryItem renders (lastModified date
|
||||
// string, sizeBytes, firstPrompt).
|
||||
const record = {
|
||||
sessionId: s.sessionId,
|
||||
workingDir: s.workingDir || '',
|
||||
sizeBytes: s.sizeBytes ?? 0,
|
||||
lastModified: new Date(s.lastActivityAt ?? s.createdAt ?? Date.now()).toISOString(),
|
||||
firstPrompt: s.firstPrompt || s.name || '',
|
||||
};
|
||||
const isLive = !!this.sessions?.has?.(s.sessionId);
|
||||
const item = this._buildHistoryItem(record, this.cases, {
|
||||
showViewAll: false,
|
||||
onActivate: () => {
|
||||
this.closeSessionManager();
|
||||
if (isLive) {
|
||||
void this.selectSession(s.sessionId);
|
||||
} else if (record.workingDir) {
|
||||
// History rows are keyed by the Claude conversation UUID; resumed
|
||||
// sessions carry theirs separately as claudeSessionId.
|
||||
void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir);
|
||||
}
|
||||
},
|
||||
});
|
||||
list.appendChild(item);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[_loadSessionManagerList]', err);
|
||||
this._setSessionManagerMessage(list, 'Failed to load sessions');
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Away Digest Modal
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async openAwayDigest(range = 'since-last-visit') {
|
||||
this.awayDigestRange = range;
|
||||
this._awayDigestLoadedSuccessfully = false;
|
||||
this._awayDigestSinceLastVisitGeneratedAt = undefined;
|
||||
const modal = document.getElementById('awayDigestModal');
|
||||
if (modal) modal.classList.add('active');
|
||||
this.updateAwayDigestRangeControls();
|
||||
await this.loadAwayDigest();
|
||||
},
|
||||
|
||||
closeAwayDigest() {
|
||||
const modal = document.getElementById('awayDigestModal');
|
||||
const generatedAt = this._awayDigestSinceLastVisitGeneratedAt;
|
||||
if (Number.isFinite(generatedAt)) {
|
||||
try {
|
||||
localStorage.setItem(AWAY_DIGEST_LAST_VIEWED_KEY, String(generatedAt));
|
||||
} catch (err) {
|
||||
console.warn('Failed to save away digest last-viewed marker:', err);
|
||||
}
|
||||
}
|
||||
if (modal) modal.classList.remove('active');
|
||||
},
|
||||
|
||||
/**
|
||||
* COD-121: live-refresh the unified session list when sessions change
|
||||
* (created/updated/deleted via SSE). Only touches surfaces that are currently
|
||||
* showing — the open Session Manager modal and/or the visible welcome list —
|
||||
* and is debounced so an event burst collapses into one re-fetch. The current
|
||||
* search query is preserved.
|
||||
*/
|
||||
_onSessionListMaybeChanged() {
|
||||
const modal = document.getElementById('sessionManagerModal');
|
||||
if (modal && modal.classList.contains('active')) {
|
||||
this._debouncedCall(
|
||||
'sessionManagerRefresh',
|
||||
() => this._loadSessionManagerList(this._sessionManagerQuery || ''),
|
||||
400
|
||||
);
|
||||
}
|
||||
const welcome = document.getElementById('welcomeOverlay');
|
||||
if (welcome && welcome.classList.contains('visible')) {
|
||||
this._debouncedCall('welcomeHistoryRefresh', () => this.loadHistorySessions(), 600);
|
||||
}
|
||||
},
|
||||
|
||||
setAwayDigestRange(range) {
|
||||
this.awayDigestRange = range;
|
||||
this._awayDigestLoadedSuccessfully = false;
|
||||
this.updateAwayDigestRangeControls();
|
||||
this.loadAwayDigest();
|
||||
},
|
||||
|
||||
updateAwayDigestRangeControls() {
|
||||
const range = this.awayDigestRange || 'since-last-visit';
|
||||
document.querySelectorAll('[data-away-range]').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.awayRange === range);
|
||||
});
|
||||
const customRange = document.getElementById('awayDigestCustomRange');
|
||||
if (customRange) customRange.classList.toggle('active', range === 'custom');
|
||||
if (range === 'custom') this.ensureAwayDigestCustomDefaults();
|
||||
},
|
||||
|
||||
ensureAwayDigestCustomDefaults() {
|
||||
const sinceInput = document.getElementById('awayDigestCustomSince');
|
||||
const untilInput = document.getElementById('awayDigestCustomUntil');
|
||||
if (!sinceInput || !untilInput) return;
|
||||
|
||||
const now = new Date();
|
||||
if (!untilInput.value) untilInput.value = this.formatAwayDigestDateTimeLocal(now);
|
||||
if (!sinceInput.value) {
|
||||
const since = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
sinceInput.value = this.formatAwayDigestDateTimeLocal(since);
|
||||
}
|
||||
},
|
||||
|
||||
formatAwayDigestDateTimeLocal(date) {
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
},
|
||||
|
||||
async loadAwayDigest() {
|
||||
const summaryEl = document.getElementById('awayDigestSummary');
|
||||
const freshnessEl = document.getElementById('awayDigestFreshness');
|
||||
const sectionsEl = document.getElementById('awayDigestSections');
|
||||
if (summaryEl) summaryEl.innerHTML = '<div class="away-digest-loading">Loading digest...</div>';
|
||||
if (freshnessEl) freshnessEl.textContent = '';
|
||||
if (sectionsEl) sectionsEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
const range = this.awayDigestRange || 'since-last-visit';
|
||||
const params = new URLSearchParams({ range });
|
||||
|
||||
if (range === 'since-last-visit') {
|
||||
const lastViewed = this.readAwayDigestLastViewed();
|
||||
if (Number.isFinite(lastViewed)) params.set('lastViewed', String(lastViewed));
|
||||
}
|
||||
|
||||
if (range === 'custom') {
|
||||
this.ensureAwayDigestCustomDefaults();
|
||||
const since = this.readAwayDigestDateTimeInput('awayDigestCustomSince');
|
||||
const until = this.readAwayDigestDateTimeInput('awayDigestCustomUntil');
|
||||
if (!Number.isFinite(since)) {
|
||||
throw new Error('Choose a custom start time');
|
||||
}
|
||||
params.set('since', String(since));
|
||||
if (Number.isFinite(until)) params.set('until', String(until));
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/away-digest?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Failed to load away digest');
|
||||
}
|
||||
|
||||
this._awayDigestLoadedSuccessfully = true;
|
||||
this._awayDigestGeneratedAt = data.digest.generatedAt;
|
||||
if (range === 'since-last-visit') {
|
||||
this._awayDigestSinceLastVisitGeneratedAt = data.digest.generatedAt;
|
||||
}
|
||||
this.renderAwayDigest(data.digest);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load away digest';
|
||||
console.error('Failed to fetch away digest:', err);
|
||||
if (summaryEl) summaryEl.innerHTML = '<div class="away-digest-load-error">Failed to load away digest</div>';
|
||||
if (sectionsEl) {
|
||||
sectionsEl.innerHTML = `<div class="empty-message">${escapeHtml(message)}</div>`;
|
||||
}
|
||||
this.showToast(message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
readAwayDigestLastViewed() {
|
||||
try {
|
||||
const value = localStorage.getItem(AWAY_DIGEST_LAST_VIEWED_KEY);
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
readAwayDigestDateTimeInput(id) {
|
||||
const input = document.getElementById(id);
|
||||
if (!input || !input.value) return undefined;
|
||||
const parsed = Date.parse(input.value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
},
|
||||
|
||||
renderAwayDigest(digest) {
|
||||
const summaryEl = document.getElementById('awayDigestSummary');
|
||||
const freshnessEl = document.getElementById('awayDigestFreshness');
|
||||
const sectionsEl = document.getElementById('awayDigestSections');
|
||||
if (!summaryEl || !freshnessEl || !sectionsEl) return;
|
||||
|
||||
const inputTokens = digest.totals.inputTokens || 0;
|
||||
const outputTokens = digest.totals.outputTokens || 0;
|
||||
const estimatedCost = digest.totals.estimatedCost || 0;
|
||||
summaryEl.innerHTML = `
|
||||
<div class="away-digest-card">
|
||||
<span class="away-digest-card-label">Needs Attention</span>
|
||||
<span class="away-digest-card-value">${digest.totals.needsAttention}</span>
|
||||
</div>
|
||||
<div class="away-digest-card">
|
||||
<span class="away-digest-card-label">Completed</span>
|
||||
<span class="away-digest-card-value">${digest.totals.completed}</span>
|
||||
</div>
|
||||
<div class="away-digest-card">
|
||||
<span class="away-digest-card-label">Active Sessions</span>
|
||||
<span class="away-digest-card-value">${digest.totals.activeSessions}</span>
|
||||
</div>
|
||||
<div class="away-digest-card">
|
||||
<span class="away-digest-card-label">Tokens</span>
|
||||
<span class="away-digest-card-value">${this.formatTokens(inputTokens + outputTokens)}</span>
|
||||
<span class="away-digest-card-cost">~$${estimatedCost.toFixed(2)}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const freshnessNotes = [];
|
||||
if (digest.dataFreshness.runSummariesLiveOnly || digest.dataFreshness.subagentsLiveOnly) {
|
||||
freshnessNotes.push('Run summaries and subagent completions use recent live state; lifecycle and token stats are persisted.');
|
||||
}
|
||||
if (digest.totals.tokenWindowPrecision === 'day') {
|
||||
freshnessNotes.push('Token totals are aggregated at day precision.');
|
||||
}
|
||||
freshnessEl.textContent = freshnessNotes.join(' ');
|
||||
|
||||
sectionsEl.innerHTML = AWAY_DIGEST_SECTIONS
|
||||
.map(([key, title]) => this.renderAwayDigestSection(title, digest.sections[key] || []))
|
||||
.join('');
|
||||
this.attachAwayDigestActions();
|
||||
},
|
||||
|
||||
renderAwayDigestSection(title, items) {
|
||||
const count = items.length;
|
||||
const body = count
|
||||
? items.map(item => this.renderAwayDigestItem(item)).join('')
|
||||
: '<div class="away-digest-empty">No items</div>';
|
||||
return `
|
||||
<section class="away-digest-section">
|
||||
<div class="away-digest-section-title">
|
||||
<h4>${escapeHtml(title)}</h4>
|
||||
<span>${count}</span>
|
||||
</div>
|
||||
${body}
|
||||
</section>
|
||||
`;
|
||||
},
|
||||
|
||||
renderAwayDigestItem(item) {
|
||||
const sourceLabel = this.formatAwayDigestSource(item.source);
|
||||
const sessionLabel = item.sessionName || item.sessionId || '';
|
||||
const detail = item.detail ? `<div class="away-digest-item-detail">${escapeHtml(item.detail)}</div>` : '';
|
||||
const action = item.link ? `
|
||||
<button class="away-digest-action"
|
||||
data-away-link-type="${escapeHtml(item.link.type)}"
|
||||
data-away-session-id="${escapeHtml(item.link.sessionId || '')}">
|
||||
Open
|
||||
</button>
|
||||
` : '';
|
||||
return `
|
||||
<article class="away-digest-item away-digest-${escapeHtml(item.severity)}">
|
||||
<div class="away-digest-item-main">
|
||||
<div class="away-digest-item-meta">
|
||||
<span>${escapeHtml(this.formatAwayDigestTimestamp(item.timestamp))}</span>
|
||||
<span>${escapeHtml(sourceLabel)}</span>
|
||||
${sessionLabel ? `<span>${escapeHtml(sessionLabel)}</span>` : ''}
|
||||
</div>
|
||||
<div class="away-digest-item-title">${escapeHtml(item.title)}</div>
|
||||
${detail}
|
||||
</div>
|
||||
${action}
|
||||
</article>
|
||||
`;
|
||||
},
|
||||
|
||||
attachAwayDigestActions() {
|
||||
const sectionsEl = document.getElementById('awayDigestSections');
|
||||
if (!sectionsEl) return;
|
||||
sectionsEl.querySelectorAll('[data-away-link-type]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
this.openAwayDigestItem(button.dataset.awayLinkType, button.dataset.awaySessionId || undefined);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async openAwayDigestItem(type, sessionId) {
|
||||
if (type === 'session' && sessionId) {
|
||||
await this.selectSession(sessionId);
|
||||
this.closeAwayDigest();
|
||||
return;
|
||||
}
|
||||
if (type === 'run_summary' && sessionId) {
|
||||
await this.openRunSummary(sessionId);
|
||||
this.closeAwayDigest();
|
||||
return;
|
||||
}
|
||||
if (type === 'lifecycle') {
|
||||
this.openLifecycleLog();
|
||||
this.closeAwayDigest();
|
||||
}
|
||||
},
|
||||
|
||||
formatAwayDigestTimestamp(timestamp) {
|
||||
if (!Number.isFinite(timestamp)) return '';
|
||||
return new Date(timestamp).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
},
|
||||
|
||||
formatAwayDigestSource(source) {
|
||||
const labels = {
|
||||
lifecycle: 'Lifecycle',
|
||||
run_summary: 'Run Summary',
|
||||
status: 'Status',
|
||||
token_stats: 'Token Stats',
|
||||
subagent: 'Subagent',
|
||||
};
|
||||
return labels[source] || source;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Token Statistics Modal
|
||||
@@ -753,8 +1469,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const agentIcon = teammateInfo ? `<span class="subagent-icon teammate-dot teammate-color-${teammateInfo.color}">●</span>` : '<span class="subagent-icon">🤖</span>';
|
||||
html.push(`
|
||||
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}${teammateInfo ? ' is-teammate' : ''}"
|
||||
onclick="app.selectSubagent('${escapeHtml(agent.agentId)}')"
|
||||
ondblclick="app.openSubagentWindow('${escapeHtml(agent.agentId)}')"
|
||||
onclick="app.selectSubagent(${escapeHtml(JSON.stringify(agent.agentId))})"
|
||||
ondblclick="app.openSubagentWindow(${escapeHtml(JSON.stringify(agent.agentId))})"
|
||||
title="Double-click to open tracking window">
|
||||
<div class="subagent-header">
|
||||
${agentIcon}
|
||||
@@ -762,8 +1478,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
${teammateBadge}
|
||||
${modelBadge}
|
||||
<span class="subagent-status ${statusClass}">${agent.status}</span>
|
||||
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent('${escapeHtml(agent.agentId)}')" title="Kill agent">✕</button>` : ''}
|
||||
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}('${escapeHtml(agent.agentId)}')" title="${hasWindow ? 'Close window' : 'Open in window'}">
|
||||
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent(${escapeHtml(JSON.stringify(agent.agentId))})" title="Kill agent">✕</button>` : ''}
|
||||
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}(${escapeHtml(JSON.stringify(agent.agentId))})" title="${hasWindow ? 'Close window' : 'Open in window'}">
|
||||
${hasWindow ? '✕' : '⧉'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -810,7 +1526,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="icon">${this.getToolIcon(a.tool)}</span>
|
||||
<span class="name">${escapeHtml(a.tool)}</span>
|
||||
<span class="detail">${escapeHtml(toolDetail.primary)}</span>
|
||||
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams('${escapeHtml(a.toolUseId)}')">▶</button>` : ''}
|
||||
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams(${escapeHtml(JSON.stringify(a.toolUseId))})">▶</button>` : ''}
|
||||
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${escapeHtml(a.toolUseId)}" style="display:none;"><pre>${escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
|
||||
</div>`;
|
||||
} else if (a.type === 'tool_result') {
|
||||
@@ -859,7 +1575,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="subagent-id" title="${escapeHtml(agent.description || agent.agentId)}">${escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)}</span>
|
||||
${modelBadge}
|
||||
<span class="subagent-status ${agent.status}">${agent.status}</span>
|
||||
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript('${escapeHtml(agent.agentId)}')">
|
||||
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript(${escapeHtml(JSON.stringify(agent.agentId))})">
|
||||
View Full Transcript
|
||||
</button>
|
||||
</div>
|
||||
@@ -1195,7 +1911,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
parentDiv.dataset.parentSession = parentSessionId;
|
||||
parentDiv.innerHTML = `
|
||||
<span class="parent-label">from</span>
|
||||
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentName)}</span>
|
||||
<span class="parent-name" onclick="app.selectSession(${escapeHtml(JSON.stringify(parentSessionId))})">${escapeHtml(parentName)}</span>
|
||||
`;
|
||||
header.insertAdjacentElement('afterend', parentDiv);
|
||||
}
|
||||
@@ -1687,7 +2403,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="status running">terminal</span>
|
||||
</div>
|
||||
<div class="subagent-window-actions">
|
||||
<button onclick="app.closeSubagentWindow('${escapeHtml(windowId)}')" title="Minimize to tab">─</button>
|
||||
<button onclick="app.closeSubagentWindow(${escapeHtml(JSON.stringify(windowId))})" title="Minimize to tab">─</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subagent-window-body teammate-terminal-body" id="subagent-window-body-${windowId}">
|
||||
@@ -2200,7 +2916,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const fileName = path.split('/').pop();
|
||||
html.push(`
|
||||
<span class="project-insight-filepath"
|
||||
onclick="app.openLogViewerWindow('${escapeHtml(path)}', '${escapeHtml(tool.sessionId)}')"
|
||||
onclick="app.openLogViewerWindow(${escapeHtml(JSON.stringify(path))}, ${escapeHtml(JSON.stringify(tool.sessionId))})"
|
||||
title="${escapeHtml(path)}">${escapeHtml(fileName)}</span>
|
||||
`);
|
||||
}
|
||||
@@ -2409,6 +3125,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Header "File Viewer" button (opt-in via App Settings → Header Displays →
|
||||
// File Viewer). Toggles the file browser panel open/closed without a trip
|
||||
// through settings. Persists via the same `showFileBrowser` flag the Panels
|
||||
// section + the panel's own close (X) use, so the three stay in sync.
|
||||
toggleFileBrowserButton() {
|
||||
const panel = this.$('fileBrowserPanel');
|
||||
const isOpen = panel?.classList.contains('visible');
|
||||
const btn = document.querySelector('.btn-file-viewer');
|
||||
if (isOpen) {
|
||||
this.closeFileBrowserPanel();
|
||||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||
return;
|
||||
}
|
||||
if (!this.activeSessionId) {
|
||||
this.showToast('Open a session to browse its files', 'info');
|
||||
return;
|
||||
}
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
settings.showFileBrowser = true;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
const checkbox = document.getElementById('appSettingsShowFileBrowser');
|
||||
if (checkbox) checkbox.checked = true;
|
||||
this.applyMonitorVisibility();
|
||||
if (btn) btn.setAttribute('aria-expanded', 'true');
|
||||
},
|
||||
|
||||
closeFileBrowserPanel() {
|
||||
const panel = this.$('fileBrowserPanel');
|
||||
if (panel) {
|
||||
@@ -2441,6 +3183,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
settings.showFileBrowser = false;
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
const checkbox = document.getElementById('appSettingsShowFileBrowser');
|
||||
if (checkbox) checkbox.checked = false;
|
||||
const headerBtn = document.querySelector('.btn-file-viewer');
|
||||
if (headerBtn) headerBtn.setAttribute('aria-expanded', 'false');
|
||||
},
|
||||
|
||||
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
|
||||
@@ -3099,7 +3845,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="status streaming">streaming</span>
|
||||
</div>
|
||||
<div class="log-viewer-window-actions">
|
||||
<button onclick="app.closeLogViewerWindow('${escapeHtml(windowId)}')" title="Close">×</button>
|
||||
<button onclick="app.closeLogViewerWindow(${escapeHtml(JSON.stringify(windowId))})" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-viewer-window-body" id="log-viewer-body-${windowId}">
|
||||
@@ -3275,14 +4021,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
<span class="size-badge">${sizeKB} KB</span>
|
||||
</div>
|
||||
<div class="image-popup-actions">
|
||||
<button onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" title="Open in new tab">↗</button>
|
||||
<button onclick="app.closeImagePopup('${escapeHtml(imageId)}')" title="Close">×</button>
|
||||
<button onclick="app.openImageInNewTab(${escapeHtml(JSON.stringify(imageUrl))})" title="Open in new tab">↗</button>
|
||||
<button onclick="app.closeImagePopup(${escapeHtml(JSON.stringify(imageId))})" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-popup-body">
|
||||
<img src="${imageUrl}" alt="${escapeHtml(fileName)}"
|
||||
onerror="this.parentElement.innerHTML='<div class=\\'image-error\\'>Failed to load image</div>'"
|
||||
onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" />
|
||||
onclick="app.openImageInNewTab(${escapeHtml(JSON.stringify(imageUrl))})" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -3505,9 +4251,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
modelHtml = `<span class="monitor-model-badge ${modelShort}">${modelShort}</span>`;
|
||||
}
|
||||
|
||||
const sid = escapeHtml(muxSession.sessionId);
|
||||
const sid = escapeHtml(JSON.stringify(muxSession.sessionId));
|
||||
html += `
|
||||
<div class="process-item process-item-clickable" onclick="app.selectSession('${sid}')" title="Switch to session">
|
||||
<div class="process-item process-item-clickable" onclick="app.selectSession(${sid})" title="Switch to session">
|
||||
<span class="monitor-status-badge ${statusClass}">${statusLabel}</span>
|
||||
<div class="process-info">
|
||||
<div class="process-name">${modelHtml} ${escapeHtml(muxSession.name || muxSession.muxName)}</div>
|
||||
@@ -3520,7 +4266,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
</div>
|
||||
</div>
|
||||
<div class="process-actions">
|
||||
<button class="btn-toolbar btn-sm btn-danger" onclick="event.stopPropagation(); app.killMuxSession('${sid}')" title="Kill session">Kill</button>
|
||||
<button class="btn-toolbar btn-sm btn-danger" onclick="event.stopPropagation(); app.killMuxSession(${sid})" title="Kill session">Kill</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -3563,7 +4309,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
</div>
|
||||
</div>
|
||||
<div class="process-actions">
|
||||
${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent('${escapeHtml(agent.agentId)}')" title="Kill agent">Kill</button>` : ''}
|
||||
${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent(${escapeHtml(JSON.stringify(agent.agentId))})" title="Kill agent">Kill</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user