Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 13:45:37 +02:00
Codeman maintainer 8d9dd70b51 chore: version packages
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 13:14:10 +02:00
Ark0N ed47a599be Merge pull request #161 from Ark0N/feat/multiuser-mode
feat: opt-in multi-user mode (per-user spaces + admin panel)
2026-07-20 13:07:58 +02:00
Codeman maintainer ccb3afc9ee fix(multiuser): close cross-user web-layer scoping holes found in review
The opt-in multi-user feature's only enforcement is web-layer scoping
(all sessions share one OS account). An adversarial review found 8 critical
+ 7 high cross-user holes that defeated it, plus mediums; all fixed here.
Single-user (flag-off) behavior stays byte-identical apart from documented
consistency deltas.

Ownership / confinement:
- DELETE /api/sessions (bulk) + /:id now owner-scope / findSessionOrFail
- quick-start, cron (create+fire), scheduled runs confine workingDir to the
  owner's space; case link/docker-link/docker-import confine the host path
- resolveCasePath no longer resolves linked cases for non-admins; foreign
  remote/docker cases are skipped (fall through to the caller's own local case)
- history, subagents/workflows, mux-sessions, orchestrator, cron run-history,
  away-digest, and remote/docker host reads are owner- or admin-scoped

Permission policy (section 6.3):
- non-granted users are downgraded at every spawn site incl. legacy
  /api/scheduled, PlanOrchestrator one-shots, remote launch, and the cron-fire
  gemini/codex bypass switches; resolveClaudeModeForUsername now fails closed

Auth / store:
- verify-first login throttle (a correct password is never locked out),
  /ws terminal subject to the change-password lockbox, cookie fast-path
  re-validates identity live, role/grant changes revoke sessions, admin delete
  runs the last-admin guard before any teardown
- users.json: distinguish missing (ENOENT) from corrupt/unreadable so a bad
  read can't overwrite all accounts; unique per-process temp write path

Event streams:
- debounced session:updated + batched task:updated, clipboard, and push
  notifications route by owner (fail closed); getLightState hides machine-wide
  globalStats from non-admins

Tests: two suites updated to assert the fixed (secure) behavior. tsc, eslint,
and test:ci all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 12:33:12 +02:00
Codeman maintainer c3b0dc345b fix(multiuser): wrap modal tabs so the injected Users tab is clickable
A Playwright browser pass found the injected 9th App Settings tab (Users)
overflowed the non-wrapping .modal-tabs flex row and landed under the modal
backdrop (elementFromPoint returned .modal-backdrop, not the button), so a real
mouse click was intercepted. flex-wrap:wrap lets the tabs wrap to a second row;
the built-in 8-tab modals still fit on one row (no visual change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:38:06 +02:00
Codeman maintainer 0ab2416460 docs(multiuser): plan status, CLAUDE.md, security-architecture, README + changeset
Stamp the plan doc with shipped-by-phase status; add the multi-user Key Patterns
entry + State Files + case-spaces note to CLAUDE.md; add a multi-user section to
the security architecture (threat model: workspace separation, not a security
boundary) and a README opt-in section; add a minor changeset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 04:34:44 +02:00
Codeman maintainer ac6fe6ef79 feat(multiuser): phase 5b, frontend (identity boot + admin panel)
- public/admin-ui.js (new, self-contained): on boot fetches GET /api/me and
  stores window.__codemanUser; installs a fetch interceptor that opens a
  change-password modal on any 403 PASSWORD_CHANGE_REQUIRED (and on boot when
  mustChangePassword is set); for a multi-user admin, injects a "Users" tab into
  the existing App Settings modal (create/reset/disable/enable/promote/demote/
  grant-bypass/delete with typed confirm + one-time-password reveal). No header
  button, so the mobile-header policy stays green; nothing renders in single-user
  mode.
- me-routes: GET /api/me returns a `multiUser` flag so the UI distinguishes a
  single-user admin (no admin UI) from a multi-user admin.
- index.html: load admin-ui.js after settings-ui.js, before session-ui.js.

Tests: test/admin-ui.test.ts (JSDOM: identity boot, Users-tab injection gating by
role/mode, forced change-password modal, script-order wiring). Backend verified
end-to-end by test/admin-routes.test.ts against a live server. A full Playwright
pass is recommended before merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 04:31:30 +02:00
Codeman maintainer dafe3de185 feat(multiuser): phase 5a, admin user-management API
- routes/admin-routes.ts: GET/POST /api/admin/users, PATCH/DELETE
  /api/admin/users/:username, reset-password, logout. Multi-user only (404
  otherwise), requireAdmin, last-admin invariants, one-time-password on create /
  reset (returned once + mustChangePassword), disable/reset/delete revoke cookie
  sessions, delete kills the user's live sessions first (normal teardown) and can
  delete their space (guarded). Per-user stats (live/active sessions, case count).
- web/admin-audit.ts: append-only ~/.codeman/admin-audit.jsonl (timestamp, acting
  admin, action, target, IP) for every user-management action.
- SSE admin:usersChanged + auth:passwordChangeRequired (sse-events.ts + constants.js).

fix(user-store): serialize users.json read-modify-write

touchLastLogin fires on every Basic auth (fire-and-forget) and was racing route
writes (create/update), clobbering records — a real corruption bug surfaced by
the admin tests. All mutators now run under a single write lock, and
touchLastLogin is throttled to once/minute per user to bound disk churn.

Tests: test/admin-routes.test.ts (8, live server) + user-store lock verified by
the existing user-store suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 04:26:44 +02:00
Codeman maintainer 2a06f7a5a8 feat(multiuser): phase 4, event fan-out + stream scoping
Scopes real-time streams and the init snapshot so a multi-user client only
receives what it owns. No-op in single-user mode (identity-less clients).

- WS terminal (ws-routes): owner gate after the session lookup. A non-admin may
  only attach to their own session (close 4003); the global auth hook already
  ran on the upgrade and decorated req.authUser, so an unauthenticated upgrade
  never reaches the handler.
- SSE (sse-stream-manager): per-client identity stored at addClient; broadcast()
  and the terminal-batch flush both enforce a routing hint via canDeliver().
  WebServer.broadcast auto-derives the hint (deriveSseHint): session-scoped event
  families resolve the owner from the payload's session id (fail closed when the
  owner can't be resolved), machine-level families (docker/tunnel/update/system/
  cron) + host-plan telemetry are admin-only, everything else stays global. Raw
  terminal bytes resolve the owner once and are withheld from non-owners.
- getLightState is filtered per connection AFTER the shared cache (sessions,
  respawnStatus, subagents, workflowRuns by owner; scheduledRuns + planUsage
  admin-only); applied to both the SSE init snapshot and GET /api/status.
- file-routes: getKnownSessionWorkingDir + getSessionAttachmentHistory (the
  preview/thumbnail/history helpers that bypass findSessionOrFail) now owner-check
  the session, closing a cross-user file-read path.
- GET /api/search: harvestSources is owner-scoped.

Deferred to a follow-up (documented in docs/multi-user-plan.md): away-digest +
subagent/workflow REST list scoping, push-subscription identity + routing,
per-user screenshot subdirs. The live-event versions of these are already routed
by the SSE hint; only the on-demand REST aggregates remain global for admins-only
follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 04:17:17 +02:00
Codeman maintainer 453605a58f feat(multiuser): phase 3, ownership threading + scoping
Threads per-user ownership through sessions, cases, cron, and the permission
policy. All scoping is a no-op in single-user mode (isMultiUserMode() guards).

Sessions
- Session.owner stamped at every create path from req.authUser / job.owner:
  POST /api/sessions, /api/run, /api/quick-start, ralph start, cron launch,
  plan generation. Round-trips through recovery (MuxSession.owner mirror, read
  muxSession.owner ?? savedState?.owner) and the mux layer.
- findSessionOrFail(ctx, id, req) now does a NOT_FOUND owner check (never 403, so
  other users' session existence is not leaked); wired at ~50 call sites.
- List endpoints filtered by owner: GET /api/sessions, /api/sessions/unified
  (live+persisted+lifecycle scoped, host-wide transcripts admin-only), cron jobs.

Permission policy (section 6.3)
- resolveClaudeModeForUsername wraps getClaudeModeConfig at every spawn site so a
  non-granted user is forced to --permission-mode auto (bypass -> auto), including
  recovery (or a reboot would un-downgrade). buildPromptArgs now respects the
  session's claudeMode, closing the one-shot (runPrompt) bypass hole.
- Shell mode and cron launchCommand require canBypassPermissions: 403 at
  POST /api/sessions, /api/quick-start create, cron job create, AND cron fire time
  (re-checked against the owner's current grant).

Cases
- resolveCasesDir(user): per-user ~/codeman-users/<name>/cases in multi-user, the
  shared ~/codeman-cases otherwise. All case CRUD + ralph + plan + quick-start
  resolve through it. resolveCasePath is owner-aware.
- GET /api/cases scoped per user (own folders; legacy linked cases admin-only;
  remote/docker cases owner-filtered). RemoteCase/DockerCase gain owner, stamped
  at link/quickcreate/import.
- Remote + Docker host CRUD is admin-only.
- Non-admin workingDir confinement (the linchpin): realpath must resolve inside the
  user's space, enforced at POST /api/sessions and /api/run BEFORE any disk write.

Limits
- sessionCapacityState / sessionCapacityMessage centralize the global + per-user
  cap (CODEMAN_MAX_SESSIONS_PER_USER, default global/2), replacing the 6 copy-pasted
  MAX_CONCURRENT_SESSIONS checks.

Tests: test/ownership-scoping.test.ts (case isolation, host-CRUD gate, workingDir +
shell gates, and the scoping helpers). Deferred to phase 4: WS owner gate, SSE
fan-out filtering, file-route preview/thumbnail helper scoping, push routing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 04:02:46 +02:00
Codeman maintainer 4d8857f72a feat(multiuser): phase 2, multi-user auth pipeline
Adds a parallel multi-user auth branch (the single-user Basic-auth path is
left byte-identical). Off unless CODEMAN_MULTIUSER/--multiuser.

- middleware/auth.ts: mode-selecting registerAuthMiddleware. New async
  multi-user hook verifies username:password against the user store (scrypt),
  mints identity-carrying cookies, decorates req.authUser, enforces a per-IP
  AND per-username failure bucket, and the mustChangePassword lockbox. The
  hook-secret loopback bypass is now a single shared helper used by both
  branches. FastifyRequest.authUser module augmentation.
- ports/auth-port.ts: AuthSessionRecord gains username/role/mustChangePassword.
- user-store.ts: verifyPassword (timing-equalized against user enumeration).
- route-helpers.ts: getAuthUser (synthetic admin fallback), canAccessOwned,
  requireAdmin, revokeUserSessions; findSessionOrFail gains an optional req for
  a NOT_FOUND owner check (dormant until phase 3 wires callers).
- routes/me-routes.ts: GET /api/me (synthetic admin in single-user) and
  POST /api/me/password (verify current, min 8, clear mustChangePassword,
  revoke other sessions).
- QR: QrTokenRecord + AuthSessionRecord carry a username; tunnel-manager
  mintUserToken / consumeTokenWithIdentity / getQrSvgForCode; /q/:code binds
  the cookie to the token's user (rejects identity-less tokens in multi-user);
  GET /api/tunnel/qr mints a per-user token. Single-user keeps the rotating token.
- server.ts: bootstrap the initial admin from CODEMAN_USERNAME/PASSWORD on first
  boot (refuse to start with no users); multi-user with >= 1 user satisfies the
  non-loopback auth requirement and the tunnel-enable guard; userFailures bucket
  disposal.
- types/api.ts: FORBIDDEN, PASSWORD_CHANGE_REQUIRED, USER_EXISTS, USER_NOT_FOUND,
  LAST_ADMIN error codes (message + status wired).
- Session.owner field + getter/setter, SessionState.owner, MuxSession.owner,
  CreateSessionOptions.owner (foundation for phase 3 ownership threading).

Tests: test/multiuser-auth.test.ts (10, live server on 3170/3171). Existing auth
suite (auth-security, qr-auth, cod54-hook-event, network-auth-policy) unchanged
and green; full test:ci sweep passes (3519 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 03:26:21 +02:00
Codeman maintainer f496e35d71 feat(multiuser): phase 1, user store, mode plumbing, CLI
Opt-in multi-user foundation (off by default; no behavior change without
CODEMAN_MULTIUSER/--multiuser):

- src/config/multiuser.ts: isMultiUserMode(), getUserSpacesDir()/userCasesDir(),
  maxUsers(), maxSessionsPerUser() (per-user fairness cap = global/2).
- src/types/user.ts: UserRecord/PasswordHash/AuthUser/PublicUser/UserRole.
- src/user-store.ts: ~/.codeman/users.json (atomic tmp+rename, mode 0600, short
  TTL cache). scrypt hashing with per-record params + timingSafeEqual verify plus
  rehash detection; createUser/setPassword/updateUser/deleteUser with last-admin
  invariants; guarded deleteUserSpace (symlink + realpath confinement, section 8);
  pure section-6.3 resolvers (resolveClaudeModeForUser downgrades bypass to auto
  for non-granted users; canRunPrivilegedCommands); bootstrapInitialAdmin.
- src/cli.ts: "codeman users add|passwd|list|rm" (hidden prompt or
  --password-stdin) operating directly on users.json; a --multiuser flag on the
  web command.

Tests: test/user-store.test.ts (29 tests: hashing/verify/rehash, username
validation, atomic 0600 write, last-admin invariants, 6.3 resolvers,
delete-space guards, bootstrap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:58:51 +02:00
Codeman maintainer 91070f5dda feat(claude): add 'auto' startup permission mode
Adds Anthropic's classifier-guarded low-prompt mode (--permission-mode
auto) as a fourth ClaudeMode alongside skip-permissions/normal/allowedTools.
Wired through both spawn paths (buildPermissionArgs for direct PTY,
buildClaudePermissionFlags for tmux), the getClaudeModeConfig validator,
and the App Settings Startup Mode picker. Exports buildSpawnCommand for
test coverage.

This is the prerequisite for multi-user mode section 6.3, which downgrades
non-granted users' sessions to 'auto'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:48:14 +02:00
Codeman maintainer fdce57ce5a docs: multi-user mode design plan
Design plan for opt-in multi-user support (per-user case spaces, admin
panel, ownership scoping). Ported onto master as the base for the
feat/multiuser-mode implementation branch.

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 01:46:59 +02:00
Ark0N d1928f300e Merge pull request #160 from Ark0N/feat/docker-session-mode
v1.4.1: Docker session mode hardening + File Viewer button
2026-07-20 01:41:58 +02:00
Codeman maintainer ca731c67b3 feat(docker): harden session mode + File Viewer button (v1.4.1)
Docker cases: seamless Claude auth (seed ~/.claude.json instead of the
corruption-prone single-file mount), full credential-store isolation for
claude + codex/gemini/gcloud/opencode (share only transcripts/rollouts,
seed the rest), auto-build the base image on first use, C.UTF-8 locale
(fixes box-drawing), collapsed/shortened Create-Case UI + short "(docker)"
case-menu tags, and w<n>-<case> tab naming for docker/remote sessions.
Also: opt-in File Viewer header button; fix a TZ-boundary flaky test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 01:36:21 +02:00
Ark0N a3fe0ae728 Merge pull request #159 from Ark0N/feat/docker-session-mode
docs: reflect shipped Docker session mode (1.4.0)
2026-07-19 22:28:41 +02:00
Codeman maintainer 82825cbfb3 docs: reflect shipped Docker session mode (1.4.0) across CLAUDE.md/README/security
- CLAUDE.md: rewrite the Docker cases Key Pattern to the shipped 1.4.0 state
  (removes the stale "not on master / Phases remaining" framing); add
  docker-quickcreate/templates/GPU/elastic-disk/export-import, the
  CODEMAN_DOCKER_BRIDGE_HOOKS listener, docker state files, env vars, route +
  SSE counts, and the build-agent-image command.
- README.md: new "Isolated Docker Sessions" section + a More Features bullet.
- docs/security-architecture.md: new §10 "Docker container isolation" (hardening,
  commit-safe creds, blast radius, untrusted-import safety, bridge-hooks) +
  Quick-reference env vars.

docs/docker-cases.md (user guide) and docs/docker-cases-plan.md (design) were
shipped with the feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:23:05 +02:00
Ark0N d1868516f7 Merge pull request #158 from Ark0N/feat/docker-session-mode
feat: Docker session mode (isolated per-case containers + export/import) — v1.4.0
2026-07-19 21:59:47 +02:00
Codeman maintainer 3e1272a675 feat(docker): resource templates, GPU, elastic disk, bridge-hooks listener
- One-click "Run in Docker" gains an expandable settings panel with a Template
  picker (Small 2G/1 · Medium 4G/2 default · Large 8G/4 · GPU 8G/4/all) plus
  memory/cpu/gpu/network/image/mount-creds overrides. Any tweak creates a dedicated
  per-case host; the plain checkbox keeps using the shared `default` host.
- GPU passthrough: `gpus` on DockerHost/SessionDocker -> `--gpus <value>` in create
  args (needs the NVIDIA container toolkit). Elastic disk: no `--storage-opt` cap,
  so container storage grows as data flows in.
- CODEMAN_DOCKER_BRIDGE_HOOKS=1: opt-in second listener on the docker bridge gateway
  (auto-detected 172.17.0.1, override CODEMAN_DOCKER_BRIDGE_HOST) that serves ONLY
  the hook endpoints and delegates into the secret-gated pipeline, so in-container
  hooks fire on a loopback-only server. Non-hook paths -> 403; host-internal, not LAN.

Verified live: Large template applies real 8GB/4CPU limits; a secret-authenticated
hook POST from inside a container now reaches the handler (was connection-refused);
non-hook paths return 403; template UI + GPU field verified via Playwright.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 21:35:38 +02:00
Codeman maintainer db6cd838b1 feat(docker): one-click "Run in Docker" case creation + Export button
- New POST /api/cases/docker-quickcreate: creates a normal case (folder in
  CASES_DIR, scaffolded CLAUDE.md + hooks) AND links it to a hardened container
  with default settings, auto-provisioning a shared `default` docker host — the
  user never touches host/image/network fields.
- Create New tab gains a "Run in isolated Docker container" checkbox; on submit it
  calls docker-quickcreate then auto-starts a claude session inside the container.
- Case Manage list gains an Export (full-image) button per docker case.
- SSE listeners for docker:exportComplete/exportFailed toast + refresh the exports
  list.

Verified end-to-end on the live instance: one-click create put the case in
~/codeman-cases/<name>, auto-created the default host, launched claude in the
container; export button produces a bundle; checkbox + button render (Playwright).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 19:28:13 +02:00
Codeman maintainer 66a41f5aa9 chore: version packages 2026-07-19 19:01:50 +02:00
Codeman maintainer a36c1f62db fix(docker): set CLAUDE_CODE_TMPDIR + document hook reachability limit
Found in live testing: claude refuses its default /tmp/claude-<uid> temp dir when
that path pre-exists root-owned (happens when the workspace bind-mount traverses
it, e.g. a workspace under /tmp/claude-<uid>). Set CLAUDE_CODE_TMPDIR to a
nonexistent HOME subpath the running uid creates+owns, so docker claude sessions
are robust to any workspace location.

Also document the hook-reachability constraint: in-container hooks POST to
host.docker.internal (the bridge gateway), so they only fire when Codeman is
reachable from the container (bind 0.0.0.0 + password); on a loopback-only bind
they don't fire and idle detection falls back to output-based (which works).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 18:19:50 +02:00
Codeman maintainer 8b2c857c3f feat(settings): wire session, away-digest, and cron button visibility toggles
Per-device App Settings > Header Displays toggles that show/hide the session
manager and away-digest header buttons (default OFF) and the cron footer
button (default ON). Adds the load/save/apply/default/displayKeys wiring in
settings-ui.js plus the marker CSS in styles.css. Client-only display keys,
stripped from the settings PUT so they never reach the strict server schema
(mirrors the showAttachmentsButton pattern); session/away stay hidden on
phones via the existing mobile.css rules. The button markup and checkbox
rows landed earlier in 5728b86.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:59:23 +02:00
Codeman maintainer 583678c950 docs(docker): add user-facing docs/docker-cases.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:55:21 +02:00
Codeman maintainer 5728b86a68 feat(docker): frontend Docker tab, run wiring, and export/import UI
- index.html: Create Case "Docker" tab (name/workspace/host/image/network +
  advanced memory/cpus/mountCredentials/resumeOnStart), and a Docker-exports
  section in the Manage tab
- session-ui.js: linkDockerCase (POST docker-host, PUT on conflict, then
  docker-link; omitted optionals as undefined not null), case-picker label
  "name @ container" + search fields, switchCaseModalTab/submitCaseModal docker
  branch, and export/import UI (refresh/export/import/delete). Docker cases route
  through /api/quick-start like remote (runClaude/runShell/runOpenCode/Codex/Gemini)
- verified in a real browser (Playwright): Docker tab renders, linking through the
  UI creates the case and it appears in the picker as "uitest @ codeman-case-uitest"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:53:14 +02:00
Codeman maintainer 39ef17b6af feat(docker): export/import (move a container to another machine) + boot reaper
- src/docker-export.ts: full-image export (pause-consistent commit + save|stream +
  workspace tar + manifest -> one .codeman-container.tgz) and workspace-only; import
  validates manifest + per-member sha256, traversal-guards the workspace tar, docker
  load + quarantine re-tag (never overwrites a local tag). Bounded by
  runWithConversionLimit; free-space precheck; docker rmi in finally; sealed
  containers refuse full-image export.
- routes: POST /api/docker-cases/:name/export (background + SSE), GET/DELETE
  /api/docker-exports, GET download, POST /api/docker-cases/import (-> new host+case)
- instance-scoped boot reaper (docker-hosts.reapOrphanedDockerContainers) wired after
  restoreMuxSessions; never touches another instance's containers
- SSE docker:exportComplete/exportFailed/importComplete (both registries)
- fix: stream pipeline in saveImageToTar so the bundle isn't truncated

VERIFIED end-to-end on real docker: full export -> 326MB valid bundle -> delete
case -> import -> new container runs from the quarantined image with the workspace
file AND the in-image change both restored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:38:01 +02:00
Codeman maintainer 814362b67b docs(docker): record implementation status (phases 0-5 done, e2e verified)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:38:53 +02:00
Codeman maintainer e9f9497259 feat(docker): allowlist container-to-host gateway aliases in host guard
An in-container hook curl carries Host: host.docker.internal:<port> (the derived
CODEMAN_API_URL), so the always-on host guard must allow host.docker.internal /
host.containers.internal or every in-container hook is blocked 403. Exact-match
only; not a browser DNS-rebinding surface (resolves to the host only from inside
a container netns). Verified end-to-end: quick-start launches claude/shell in a
real container with the workspace bind-mounted and hooks scaffolded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:32:45 +02:00
Codeman maintainer 8768ca4a5a feat(docker): docker-hosts CRUD, docker-link, and quick-start branch
- case-routes: GET/POST/PUT/DELETE /api/docker-hosts, POST /api/cases/docker-link
  (creates workspace, probes daemon + tmux-in-image), docker listing in
  GET /api/cases, docker-unlink (best-effort docker rm -f) in DELETE, single GET
- session-routes: /api/quick-start docker branch (rejects envOverrides/effort/
  per-CLI config, probes availability + tmux, casePath=hostWorkspacePath, seeds
  resume id, scaffolds hooks+CLAUDE.md if missing, threads docker into Session,
  Ralph auto-config skipped for docker)
- CaseInfo gains location:'docker' + docker{} block
- typecheck clean; 157 route+docker tests pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:28:44 +02:00
Codeman maintainer df9214ba9a feat(docker): thread SessionDocker through Session + recovery
- Session: _docker field, constructor config, toState, createSessionOptions/
  respawnPaneOptions (both interactive + shell paths), docker getter
- resolveMuxAttachCwd returns /tmp for docker sessions (local wrapper only execs)
- skip the LOCAL claude version probe for docker; probe the IN-CONTAINER version
  instead (deferred) so wheel-forwarding stays enabled (#154)
- server restoreMuxSessions round-trips MuxSession.docker / SessionState.docker
- full CI suite green (3444 passed)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:22:38 +02:00
Codeman maintainer 5f4c89b990 fix(docker): auto-assign agent uid (node:22 already occupies uid 1000)
node:22-bookworm-slim ships a `node` user at uid 1000, so `useradd -u 1000`
failed. Auto-assign the uid and rely on gid-0 + group-writable HOME so any
runtime `--user <hostUid>:0` can write $HOME. Verified: image builds; toolchain
(node/tmux/claude/codex/gemini/opencode) present; `--user 1000:0` writes
/home/agent and `claude --version` runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:14:44 +02:00
Codeman maintainer 54615e2371 feat(docker): agent base image + local build script
docker/agent.Dockerfile: node:22 + claude/codex/gemini/opencode CLIs + git/
tmux/ripgrep/curl, secret-free, OpenShift arbitrary-uid-writable HOME (gid 0).
scripts/build-agent-image.mjs: local build (decision "build locally on first
use"), docker/podman auto-detect, --engine/--image/--no-cache flags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:11:21 +02:00
Codeman maintainer 828b1664f7 feat(docker): Docker session mode foundation (types, storage, tmux builders)
Phase 0-2 of the Docker cases feature (docs/docker-cases-plan.md). Docker is a
LOCATION OVERLAY on cases (not a 6th SessionMode), mirroring the remote-SSH
feature: a local tmux pane runs `docker exec -it` into a durable in-container
tmux server. The container is per-CASE, so multiple sessions share it.

- types: DockerHost/DockerCase/SessionDocker + docker? on SessionState/MuxSession
- src/docker-hosts.ts: storage, toSessionDocker, pure buildDockerBaseArgs/
  buildDockerCreateArgs (cap-drop, no-new-privileges, --pull=never, mem==swap,
  never privileged/socket), containerApiUrl, hostGatewayAlias, config-hash,
  credential-mount resolution, daemon probes (VITEST no-op)
- schemas: DockerHostSchema + DockerCaseLinkSchema (NO_SHELL_META guards)
- 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
- 40 unit tests (docker-hosts + docker-exec-options), typecheck clean

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6ae2c0b8160090a1f0f6b32a3fe8496d402ac2c6)
2026-07-17 15:43:06 -04:00
Codeman maintainer 6f4b2b8a17 chore: version packages
Release 1.3.5. Consumes the changeset from PR #155: re-issue the
codeman_session cookie on every authenticated request so the browser cookie
lifetime tracks the server-side sliding TTL, fixing the recurring native Basic
Auth dialog during active use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:15:29 +02:00
Codeman maintainer a531f48e17 chore(gitignore): ignore local screenshot and design capture dirs
screenshots-readme/, screenshots-readme-real/, screenshots-real/ and
design-explorations/ are local capture scratch that was untracked but not
ignored, so an unqualified `git add -A` during a COM could sweep them into a
release (this has happened before).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:15:29 +02:00
Ark0N 7d5ea0bd50 Merge PR #155 from dennisentruencer/fix/sliding-auth-cookie: 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 (authSessions already used refreshOnGet: true). Fixes the recurring native Basic Auth dialog during active use.

Reviewed: no token rotation (same server-generated token re-issued, so no fixation vector), forged cookies are not blessed, logout still emits only the clearing cookie and server-side invalidation holds, cookie attributes identical to the Basic Auth path. Verified against the merge result: tsc --noEmit, lint, format:check, check:frontend-syntax, check:lockfile, and npm run test:ci (3404 passed) all green.
2026-07-16 23:49:10 +02:00
Codeman maintainer a9ae141eec chore: version packages 2026-07-16 23:34:03 +02:00
Codeman maintainer 7b79d4207c fix(terminal): restore Claude scroll-back on macOS trackpads (#154)
Deterministic claude --version probe seeds cliVersion so wheel-forwarding
to Claude's transcript engages (banner scrape was unreliable on 2.1.187+
and resumed sessions). Shift+wheel reads the dominant axis so a trackpad's
horizontal Shift-scroll reaches local scrollback. New per-device
"Wheel Scrolls Local History" opt-out. Wheel reports use a fire-and-forget
send path so they no longer flicker the pending-bytes indicator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:33:39 +02:00
Codeman maintainer 28744a2761 chore: version packages
Make the Cron Jobs modal skin-aware + consistent with App Settings:
skin-variable selects (appearance:none, --bg-input fill, custom chevron),
color-scheme:dark for native controls, themed date/time inputs, and
btn-toolbar-sized toolbar/footer buttons. Bumps aicodeman to 1.3.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:59:02 +02:00
Codeman maintainer cca07e2b11 chore: version packages
Redesign the Cron Jobs modal to match App Settings styling + fix the
create form never collapsing (scoped #cronModal .hidden rule). Bumps
aicodeman to 1.3.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:09:47 +02:00
Codeman maintainer 9806efdf0a chore: version packages 2026-07-13 00:49:49 +02:00
Codeman maintainer b00e7cf17c Merge PR #153 from aakhter/cod-161-session-manager-frontend: unified Session Manager — welcome list + searchable modal + live SSE refresh
Rebuilt on the merged #146 Session Manager: kept master's Command Palette + fixed
Session Manager implementation, dropped the PR's stale duplicate block (last-key-wins
regression), rebased _buildHistoryItem on master's onActivate contract, kept the new
projectKey plumbing + SSE live refresh + kebab menu/badges, phone-hid the header button.
2026-07-13 00:43:18 +02:00
Codeman maintainer efe2d8966a fix(review): rebuild Session Manager additions on the merged #146 implementation (PR #153)
- Hide the new btn-session-manager header button on phones: add it to the
  @media (max-width: 430px) display:none block in mobile.css (next to
  .btn-away-digest) and to KNOWN_PHONE_HIDDEN in the mobile-header policy
  test, closing the recurring phone-header-leak regression that was PR
  #153's red CI job.
- Put the session-manager header button on its own line in index.html
  (was crammed onto the away-digest line).
- app.js: drop session:updated from the unified-list SSE refresh trigger —
  it is batch-broadcast ~every 500ms per active session and would turn an
  open modal / visible welcome list into a sustained ~1 Hz full projects
  rescan loop; created/deleted (structural changes) are sufficient.
- terminal-ui.js _fetchUnifiedSessions: check the ApiResponse envelope and
  throw on failure so a 5xx surfaces via the caller's catch instead of
  rendering an empty history.
- terminal-ui.js _openSessionRowMenu: on re-entry, invoke the previous
  menu's close fn (stored as _openRowMenuClose) so its document/window
  listeners are detached rather than leaked; use claudeSessionId ||
  sessionId in the 'Resume session' menu item to match the main-row and
  Session Manager resume routing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 00:38:56 +02:00
Codeman maintainer f55f035690 Merge master into PR #153 (unified Session Manager)
Resolve the 4 conflicted files toward master's merged #146 work while
keeping PR #153's genuinely-new additions:

- app.js: keep the full Escape chain (closeSessionManager +
  closeCommandPalette + closeShortcutOverlay).
- index.html: keep master's Command Palette modal markup alongside the
  PR's Session Manager modal + header button.
- styles.css: keep master's Command Palette + COD-157 shortcut CSS AND
  the PR's COD-130 session-row kebab-menu CSS (both inserted at the same
  spot — reunited each with its own closing brace).
- terminal-ui.js: resolve _buildHistoryItem's main-row click handler to
  master's options.onActivate contract with a liveness + claudeSessionId
  -aware resume default, preserving the PR's two-shape/badges/kebab body.
- panels-ui.js: the PR's pre-#146 Session Manager block auto-merged as a
  duplicate AFTER master's fixed block (last-key-wins regression) — drop
  it, keep master's implementation plus the PR's new
  _onSessionListMaybeChanged.

Backend projectKey plumbing and the SSE live-refresh listeners in app.js
merge additively and are kept as-is.
2026-07-13 00:34:43 +02:00
Codeman maintainer 58fc5f874a docs(CLAUDE.md): accuracy audit fixes + document the 15 merged PRs
Audit (18 verified findings): COM step 6 watches BOTH CI+Release runs; hook-secret
is unconditional when auth is active (COD-91); env-prefix allowlist includes
GEMINI_*/GOOGLE_*; applySkin()/isWorkflowAgentTrackingEnabled() name fixes;
ultracode watcher completion-vs-live sources; harvestSources location; LRUMap
barrel exception; gemini-cli-resolver; config 15 files; state-files inventory;
terminal-history centralization; tunnel.sh named mode; test:watch row; shortcut
list corrections.

New feature docs: cron jobs, remote SSH cases, unified session list, command
palette + shortcut registry, PTY-exit breaker, full-scrollback replay, WS
resilience, Codex artifacts/response-viewer, HEIC conversion, WebGL toggle;
route/SSE/type counts refreshed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 20:16:07 +02:00
Codeman maintainer 1301b4b58c Merge PR #152 from pirronewantlux529-coder/codex-response-viewer: response-viewer (eye) support for Codex sessions
Includes review fixes: full route-test coverage for the rollout locator/parser (originator/uuid/pin resolution, dedup, injected-context filtering), LRU caches, multi-block text joins.

# Conflicts:
#	src/web/routes/session-routes.ts
2026-07-12 20:09:53 +02:00
Codeman maintainer 46493f374e Merge PR #151 from aakhter/cod-167-heic-jpeg-conversion: convert HEIC paste uploads to JPEG
Includes review fixes: worker-thread conversion with resourceLimits + timeout, global conversion-limiter cap, 64MP pre-decode bomb guard, magic-byte detection (covers mislabeled Android HEIF).
2026-07-12 20:08:43 +02:00
Codeman maintainer b20c00702a Merge PR #150 from aakhter/cod-166-codex-generated-artifact-attachments: Codex generated artifacts as attachment cards
Includes review fixes: source arg threaded through the deps lambda (was silently dropped), codex-mode gating, realpath-first trust decisions with homedir-anchored markers, image thumbnail passthrough, ANSI-stripped scanning.

# Conflicts:
#	src/session.ts
2026-07-12 20:08:32 +02:00
Codeman maintainer d55ebcb644 Merge PR #149 from aakhter/cod-165-ws-resilience: WebSocket durable-delivery resilience
Includes review fixes: real _wsState lifecycle (connecting/connected/disconnected), per-tab supersede identity (multi-tab coexistence), preserved reconnect backoff, connection-dot CSS for connected/fallback states.
2026-07-12 20:07:55 +02:00
Codeman maintainer e84a3834d0 Merge PR #148 from aakhter/cod-164-scrollback-replay-crlf: replay full tmux scrollback on terminal reload + CRLF normalization
Includes review fixes: explicit ?full=1 trigger wired from initial page load, capture maxBuffer sized from config with -S line bound, capture returned alone (no byte-buffer duplication), early byte-cap before normalization.
2026-07-12 20:07:35 +02:00
Codeman maintainer f89bc420ba Merge PR #147 from aakhter/cod-168-pty-exit-breaker: scrub TMUX vars + PTY-exit circuit breaker (COD-115/COD-118)
Includes review fixes: breaker reset only on explicit clearBreaker restarts (auto-reattach never clears), trip observability survives listener detach, push notification wired into PUSH_EVENT_MAP.

# Conflicts:
#	src/session.ts
2026-07-12 20:07:19 +02:00
Codeman maintainer 5a4e60dc8e fix(merge): reconcile cross-PR test seams after #141/#145/#146 merges
- help-modal extractor bounds at the next HTML comment (cron modal's 'Run At'
  text false-positived the stale-shortcut regex)
- remote-shell run test expects the wired /api/quick-start path (#145) — POST
  /api/sessions has no caseName in its schema

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 20:06:32 +02:00
Codeman maintainer 460972a50e Merge PR #146 from aakhter/cod-163-command-palette: searchable case picker + Command-K session palette
Includes review fixes: Session Manager aligned to the merged /api/sessions/unified contract with error states, Ctrl+K no longer leaks 0x0B into the PTY, shortcut registry finished (dispatch/persistence/rendering), shortcutOverrides preserved across settings saves, help modal kept reachable.

# Conflicts:
#	README.md
#	src/web/public/index.html
#	src/web/public/session-ui.js
2026-07-12 20:03:53 +02:00
Codeman maintainer 8a971c3935 Merge PR #145 from aakhter/cod-94-remote-host-ssh: remote host SSH cases
Includes review fixes: reachable Remote tab UI, remote metadata restore on recovery, quick-start routing for remote run flows, ssh-arg injection guards, dedicated remote socket/name (no cross-instance adoption), remote tmux kill on delete, wired tmux probe + ConnectTimeout, --dangerously-skip-permissions default.
2026-07-12 20:01:47 +02:00
Codeman maintainer 83779cab4d Merge PR #141 from chatgptkrylor/feat/scheduler: recurring cron-style scheduled jobs
Includes review fixes: multi-line prompt rejection, prompt-file confinement hardening (realpath + attachment-guard blocklist), per-job autoClosePreviousSession lifecycle, live-session-only concurrency counting, wired launchCommand.
2026-07-12 20:01:16 +02:00
Codeman maintainer a8e7669f5a fix(review): preserve shortcutOverrides across settings saves + keep help modal reachable (PR #146)
- saveAppSettings() rebuilds settings from the DOM; carry over shortcutOverrides
  like showTokenCount/showCost so rebinding survives unrelated saves
- shortcut overlay footer links to the full help modal (its only opener was the
  legacy Ctrl+? route this PR replaced)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:58:32 +02:00
Codeman maintainer 5deb0d4a4c fix(review): harden + wire remote-host SSH cases end-to-end (PR #145)
- UI: add the missing data-tab="case-remote" tab button; dispatch it through
  submitCaseModal()/switchCaseModalTab() to linkRemoteCase() (was dead code).
- Restore: restoreMuxSessions() now passes remote (muxSession.remote ??
  savedState.remote) into the Session constructor, so remote metadata round-trips
  on restart instead of reattaching from a local cwd / respawning LOCAL / being
  erased from state.json. Recovery tests added.
- Run flows: runClaude()/runShell() route remote cases through /api/quick-start
  (POST /api/sessions stat-validates workingDir locally); run*() skip the
  /api/*/status pre-check and omit inert config/env for remote cases.
- Quick-start: resolve the remote case BEFORE the local CLI availability gates and
  skip isCodex/Gemini/OpenCodeAvailable() when remote; REJECT
  envOverrides/effort/codex/gemini/openCode config for remote (they don't cross
  ssh) instead of silently dropping them.
- Injection: reject $, backtick, $( in remotePath + identityFile at the schema
  layer (they survive shellescape into the bash -c launch double-quote layer).
  Regression tests for $(...) and backtick payloads added.
- Remote socket/name: launch on a DEDICATED -L codeman-remote socket under a
  codeman-ssh-<id> name that fails a remote Codeman's SAFE_MUX_NAME_PATTERN, so a
  remote instance can't adopt the session; scope tmux set-options per-session
  (never -g) so they don't mutate other sessions.
- Kill: best-effort ssh 'tmux -L codeman-remote kill-session' on remote session
  kill (fire-and-forget, never blocks/throws the local kill) so the remote agent
  isn't orphaned forever.
- Probe: wire checkRemoteTmuxAvailable() into POST /api/quick-start (structured
  OPERATION_FAILED) and as courtesy validation in remote-link; add a default
  -o ConnectTimeout=10 to buildSshConnectionArgs (overridable via extraSshOptions).
- Command default: remote claude default is now
  'exec claude --dangerously-skip-permissions' (per-host override stays the escape
  hatch), mirroring local non-interactive semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:49:58 +02:00
Codeman maintainer 84ab4ff07b fix(review): harden cron security, session lifecycle, skip policy (PR #141)
- Reject multi-line prompts end-to-end: schema refines on promptText/
  launchCommand, runtime check in resolvePrompt (prompt-file content;
  trailing newlines tolerated), matching cron-ui form validation — delivery
  is single-line only, so multi-line was silently corrupted (typed mode
  fused lines, paste mode submitted partials)
- Close the workingDir confinement bypass (arbitrary server-side file read,
  e.g. workingDir=/proc + /proc/self/environ): realpath-resolve workingDir
  before the containment check, reject '/' and blocked/pseudo-fs trees
  (/proc, /sys, /dev + the attachment-guard blocklist) at fire time AND at
  job create/update (workingDir must exist and be a directory)
- Session lifecycle: new per-job autoClosePreviousSession (default true,
  recurring schedules only; ignored for 'once') — the previous run's
  still-open session is closed via the normal cleanupSession path when the
  next run fires; UI switch added; 50-session cap math documented in
  docs/cron-guide.md §8
- skip_if_same_agent_running: count only live sessions (exclude
  stopped/error dead tabs), exclude sessions created by this job's own runs
  (fixes the fire-once-then-skip-forever self-deadlock), and a skipped
  'once' job stays armed and retries next tick instead of being consumed;
  liveness filter mirrored in cron-ui _countActiveAgents
- Wire launchCommand (was accepted+documented but dead): shell mode sends
  it via writeViaMux as the first input line after startShell readiness
  (single-line, schema-enforced); form field shown for shell agent type
- Record delivery failures: a false writeViaMux result now fails the run
  instead of recording a false 'prompt_sent'
- Cap saved jobs at MAX_CRON_JOBS (100) to bound state.json growth
- Surface field-specific schema messages (drop parseBody custom
  errorMessage on cron create/update)
- Tests: workingDir create/update validation, /proc bypass regression,
  single-line enforcement (schema+runtime+trailing-newline tolerance),
  live/own-session skip filtering, once-skip re-arm, auto-close on/off/once,
  job-count cap

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:25:42 +02:00
Codeman maintainer 88f47754ad fix(review): wire Session Manager to /api/sessions/unified contract, stop Ctrl+K PTY leak, finish shortcut registry (PR #146)
- Session Manager (COD-121/192): align _loadSessionManagerList() with the
  merged #139 endpoint — map UnifiedSessionItem fields (lastActivityAt
  epoch-ms → lastModified, optional sizeBytes/firstPrompt/name) to the
  history-record shape _buildHistoryItem renders; surface non-2xx /
  error-envelope responses as a visible message instead of a silent
  "No sessions found"; route clicks by liveness (live row → selectSession,
  history row → resumeHistorySession by conversation UUID) via a new
  onActivate option so a live session is never duplicate-resumed
- Ctrl+K double-dispatch: gate the palette chord in
  attachCustomKeyEventHandler (return false on keydown) so xterm never
  writes 0x0b kill-line into the PTY while the palette opens; gate is
  registry-aware so a rebound/disabled palette shortcut restores normal
  terminal Ctrl+K
- Shortcut registry (COD-157) finished per maintainer decision: document
  keydown now dispatches through getShortcutRegistry() +
  matchesShortcutEvent() (legacy SHORTCUTS table removed), honoring
  per-shortcut disable and rebinds incl. the palette chord; overrides
  persist via saveAppSettingsToStorage() (correct device key + cache
  coherence, was orphaned 'codeman:settings'); Shortcuts tab renders on
  open via switchSettingsTab hook; capture uses a persistent listener that
  ignores bare modifier keydowns (combos now capturable) and requires a
  Ctrl/Cmd/Alt chord; settings rows use delegated listeners instead of
  inline onclick (JS-string injection sink) and overrides can no longer
  clobber id/label/action; added the missing row + overlay CSS
- matchesShortcutEvent: reject undeclared extra modifiers (Ctrl+Shift+K
  no longer hijacked from Firefox devtools) while keeping Ctrl/Cmd
  interchangeable; match physical code OR produced key for layout parity
- Registry/dispatch gaps: added restore-terminal-size entry, documented
  Ctrl+Shift+R again in the help modal (test flipped to assert presence),
  Ctrl+?/Alt+? now really open the registry-driven shortcut overlay, and
  Escape closes it
- Palette new-session pick routes through selectQuickStartCase() so the
  searchable combobox, dir display, and lastUsedCase stay in sync
- Removed fork cherry-pick debris: dead _onSessionListMaybeChanged(),
  orphaned .session-row-menu CSS, nonexistent closeMobileHeaderUtilities
  calls
- Tests: functional vm-harness coverage for the unified-list field
  mapping + error state + liveness routing, palette chord shift/disable/
  rebind handling, override persistence round-trip, capture flow, tab
  render hook, and source guards for the PTY gate + registry dispatch

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 19:06:12 +02:00
Codeman maintainer 6e417d69dc fix(review): WS state machine, per-tab supersede key, backoff, dot CSS (PR #149)
- _wsState now transitions through the full lifecycle: _connectWs() sets
  'connecting', ws.onopen (inside the this._ws === ws guard) sets 'connected',
  _disconnectWs() resets to 'disconnected' — the connection chip's "WS" state
  was previously unreachable (stuck on "WS…"/"HTTP" forever).
- WS registry supersede is now keyed per TAB: the upgrade URL sends
  cid = clientId + ':' + per-page nonce (reusing the constructor's page UUID),
  while input frames keep the bare browser clientId for seq dedup — two
  tabs/windows on one session coexist instead of 4010-evicting each other in a
  perpetual 5s ping-pong; a genuine same-tab reconnect still supersedes.
- Exponential backoff engages: _disconnectWs() no longer zeroes
  _wsReconnectAttempts (it's called at the top of _connectWs, so every retry
  replanned at attempt 0 → ~0ms tight reconnect loop during outages); onopen
  resets the counter on success.
- styles.css: add .connection-dot.connected (green) and .connection-dot.fallback
  (yellow) — both states rendered an invisible dot (no rule existed).
- Remove smuggled dead code: resolveMonitorRowLabels/CodemanMonitorLabels
  (COD-122, no consumer, referenced test doesn't exist) and the never-written
  _wsLastClose/_wsInputSendCount/_httpFallbackSendCount diagnostics.
- Tests: new test/ws-state-lifecycle.test.ts drives the REAL
  _connectWs/onopen/onclose/timer cycle (state transitions, escalating backoff
  delays, composite cid on the upgrade URL); registry two-tab coexistence test;
  static check that every emitted connection-dot class has a styles.css rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:42:36 +02:00
Codeman maintainer f98d29b323 fix(review): wire full-scrollback replay to an explicit ?full=1, dedup + bound the capture (PR #148)
- Replace the 'missing ?tail means reload' overload with an explicit ?full=1
  query param: the frontend's first buffer load after a page load (selectSession)
  now requests full=1, tab switches keep ?tail=, and the legacy no-param callers
  (response-viewer fallback, clearTerminal refresh) keep the cheap visible-frame
  path — the COD-47 feature was previously unreachable from a real reload.
- When the full-history capture succeeds, return it ALONE instead of prepending
  the byte buffer + \x1b[H\x1b[2J: the capture is the rendered superset of the
  byte history, and ED2 clears only the viewport so the concat replayed the whole
  conversation twice in xterm scrollback. The history+clear+frame concat stays
  for the visible-frame/tab-switch path.
- Pass an explicit execSync maxBuffer for the full-history capture (configured
  terminalBufferMaxBytes + slack) — the 1MB Node default ENOBUFS-killed exactly
  the multi-MB captures the feature exists for; log ENOBUFS concisely instead of
  dumping the truncated stdout.
- Bound the capture itself via -S -<N> derived from the configured tmux
  history limit (was unbounded -S -), and add -J so lines hard-wrapped at the
  capture-time pane width reflow in the browser xterm.
- Cap the concatenated buffer to terminalBufferMaxBytes EARLY (before the
  regex normalization passes) so multi-MB captures don't stall the event loop
  normalizing bytes that get sliced away.
- Tests: route tests updated for ?full=1 semantics (capture-alone response,
  config-forwarded capture bounds, byte-history fallback, no-param requests
  stay on the visible-frame path); source-scan tests cover the bounded -J -S -<N>
  flags and explicit maxBuffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:33:18 +02:00
Codeman maintainer 360d58ca4f fix(review): breaker reset semantics, trip observability, push template (PR #147)
- Breaker reset is now explicit-only: POST /api/sessions/:id/interactive no
  longer unconditionally resets the PTY-exit breaker (that endpoint IS the
  frontend's automatic re-attach path, so the breaker could never trip on the
  COD-115 crash loop and any tab click silently re-armed it). The route accepts
  a schema-validated optional body flag {clearBreaker:true}
  (InteractiveStartSchema) and resets only when it is sent.
- Frontend restart control: app.js selectSession keeps the bare auto-attach
  (no body, never clears); when the selected session has respawnBlocked it asks
  for explicit user confirmation and only then re-POSTs with clearBreaker:true.
  respawnBlocked is surfaced via SessionState/toState() (runtime-only, not
  restored on boot so recovery can re-attach).
- Trip observability: WebServer.setupSessionListeners() is now idempotent
  (skips while refs are attached) and the re-attach routes (/interactive,
  /interactive-respawn, /shell) re-run it, restoring the wiring that the exit
  handler detaches on every PTY exit — without this the 5th-exit trip had
  guaranteed zero listeners (no SSE, no push, no persist, no run-summary).
- Push notification: added SessionRespawnBreakerTripped to PUSH_EVENT_MAP
  ('Session crash loop stopped', urgency critical) with an exit-count body
  branch; previously sendPushNotifications silently no-oped.
- Minor: buildMuxAttachEnv() truecolor param is now actually passed
  (codex/gemini, mirrors buildEnvExports); buildClaudeEnv() uses delete for
  COLORTERM/CLAUDECODE (same node-pty "KEY=undefined" quirk as COD-115).
- Tests: route tests assert auto-reattach does NOT reset, clearBreaker resets,
  invalid flag rejected, and listener re-wiring on /interactive + /shell;
  real-wiring lifecycle tests (createSessionListeners/attach/detach) prove the
  exit-detach gap and that re-setup keeps the 5th-exit trip observable;
  PUSH_EVENT_MAP regression guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:21:42 +02:00
Aamer Akhter 6cac517fa6 COD-130 session-row ⋯ becomes a context menu
The per-row ⋯ in the session list was a details toggle that did nothing in
the Session Manager modal (swallowed by the modal's capture-phase
close-on-click). Replace it with a real kebab context menu.

- terminal-ui.js: ⋯ now opens _openSessionRowMenu() — a body-anchored popup
  (fixed-positioned, flips/clamps to viewport, z-index above the modal) with:
  Resume/Switch-to (live→select tab, closed→resume), Open folder in the file
  browser (live sessions only — the browser is session-scoped), Copy path
  (_copyText + toast, when workingDir present), and Show details (the old
  inline prompt/path panel). Closes on outside-click / Escape / scroll / resize.
- panels-ui.js: _loadSessionManagerList scopes its modal-close to the
  .history-item-main (resume) click, so the ⋯/menu no longer closes the modal.
- styles.css: .session-row-menu + .session-row-menu-item.

Verified in Chromium on an isolated beta: ⋯ opens the menu with the modal
still open; closed rows show Resume/Copy path/Show details, live rows add
Switch-to + Open folder; Show details expands inline (modal stays open),
Copy path copies the path, Resume closes the modal, Escape closes only the
menu. Gates: tsc 0, lint 0, frontend-syntax + public-asset format clean.
2026-07-12 12:19:10 -04:00
Aamer Akhter 2235f06ea5 COD-121 unified session list: live SSE refresh (slice A, unit 4)
The complete session list now updates live as sessions change, instead of
only on open/welcome-load.

- app.js: extra SSE listeners (session:created/updated/deleted) on the same
  EventSource (multiple listeners per event; existing handlers untouched;
  registered via addListener so they tear down on reconnect) call
  _onSessionListMaybeChanged().
- panels-ui.js: _onSessionListMaybeChanged() debounced-refreshes the Session
  Manager modal when it's open and the welcome list when its overlay is
  visible (no work when neither is showing). _loadSessionManagerList stores the
  active query so refreshes preserve the user's search.

Verified on an isolated beta instance (Playwright): dispatching a session
event refreshes the modal while open, does NOT while closed (gated), and
refreshes the welcome list while visible. Gates: tsc 0, frontend-syntax +
public-asset format clean, build clean.
2026-07-12 12:19:10 -04:00
Aamer Akhter 65b609b6db COD-121 unified session list: persistent Session Manager modal (slice A, unit 3)
Adds a header-reachable Session Manager so the complete session list is
available mid-session, not only on the welcome screen.

- index.html: always-on header button (.btn-session-manager) + #sessionManagerModal
  (mirrors the Away Digest modal) with a search box + results list.
- panels-ui.js: openSessionManager()/closeSessionManager()/_loadSessionManagerList()
  — loads GET /api/sessions/unified (limit 200), renders via the unit-2
  _buildHistoryItem (rich items, mode/LIVE badges, open->select / closed->resume),
  debounced search wired to the endpoint's q= param, empty/error states. A
  modal-scoped Escape listener closes it even when focus is in the search input;
  backdrop click and item click also close it.
- app.js: closeSessionManager() added to the global Escape chain.
- styles.css: modal + list styling (items reuse .history-item).

Verified on an isolated beta instance (Playwright): the header button opens the
modal, it lists 200 sessions from /api/sessions/unified, a no-match query issues
?q= to the server and yields 0 items, clearing restores the list, clicking an
item closes the modal and routes resume/select, and Escape closes it. Gates:
tsc 0, lint 0, frontend-syntax + public-asset format clean, 17 tests pass.
2026-07-12 12:19:10 -04:00
Aamer Akhter c9f37f2628 COD-121 unified session list: welcome list frontend (slice A, unit 2)
Backs the welcome-screen "Resume Conversation" list with the new
GET /api/sessions/unified endpoint instead of /api/history/sessions, so it
shows the COMPLETE set (live + persisted + non-Claude + closed history)
newest-first with richer context, rather than only Claude transcripts.

- terminal-ui.js: new _fetchUnifiedSessions(); loadHistorySessions() now uses
  it. _buildHistoryItem upgraded to the unified shape (kept backward-compatible
  with the folder-modal's old shape): title = name || firstPrompt || dir; a
  mode badge + a LIVE badge (sources includes 'live'); timestamp from
  lastActivityAt (falls back to lastModified); size only when present; detail
  panel + "View all in this folder" preserved (gated on projectKey). Resume
  branches: an open live session selects its tab, a closed one resumes.
- unified-session-service.ts + endpoint: pass projectKey through the history
  source so the folder drill-down survives.
- styles.css: .history-item-badges / -badge / -badge-live pills.

Verified: tsc 0, lint 0, frontend-syntax + public-asset format clean, service
tests 13/13 (+projectKey), route tests 4/4. Playwright on an isolated beta:
the welcome list renders real items from /api/sessions/unified, and the
renderer produces the tab-name title + codex mode badge + visible LIVE badge,
omits LIVE on closed items, keeps "View all in folder", and routes resume
correctly (open->select tab, closed->resume). Persistent panel + live SSE
status are later units.
2026-07-12 12:19:10 -04:00
Codeman maintainer 309959be27 fix(review): worker-thread HEIC conversion with bomb guard, concurrency cap, and magic-byte routing (PR #151)
- Event-loop blockage: HEIC decode/encode (CPU-synchronous libheif WASM +
  jpeg-js) now runs in a per-conversion worker_threads Worker
  (src/web/heic-jpeg-worker.ts, spawned by heic-jpeg-converter.ts) with
  resourceLimits and a 30s hard timeout that terminates the worker —
  verified end-to-end under tsx and against compiled dist/ output with a
  real iPhone HEIC (event-loop max stall 52ms during conversion).
- No server-side concurrency cap: conversions now acquire a slot from the
  existing global runWithConversionLimit() pool (document-conversion-limiter),
  bounding peak decode memory/CPU across simultaneous uploads.
- Decompression bomb: header-declared dimensions are read via heic-decode's
  allocation-free `.all` path and rejected above 64MP BEFORE decode() can
  allocate width*height*4 bytes (a <300-byte crafted file can declare
  30000x30000 = 3.6GB). Regression-tested with a crafted ISOBMFF fixture
  against the real heic-decode WASM (test/heic-jpeg-core.test.ts).
- Mislabeled HEIC (documented Android/MIUI case): conversion now routes on
  ftyp magic-byte sniff of the raw buffer regardless of declared
  ext/Content-Type, so a HEIF uploaded as image/jpeg converts instead of
  415ing; the magic-mismatch 415 only fires for genuinely unrecognized bytes.
- Brand allowlist narrowed to what heic-decode's isHeic() accepts
  (heim/heis/hevm/hevs dropped — they could only ever fail conversion).
- Converted-output size: the JPEG result is checked against
  MAX_PASTE_IMAGE_BYTES (jpeg-js can inflate a within-limit HEIC past the cap).
- Deps: heic-convert replaced with its underlying heic-decode + jpeg-js
  (the wrapper could not expose the pre-decode dimension check); lockfile
  synced, drops pngjs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:06:59 +02:00
Codeman maintainer 13c877f938 fix(review): harden Codex generated-artifact attachment pipeline (PR #150)
- Pass the attachment request `source` through the server deps lambda and make
  it a required param on SessionListenerDeps.registerAttachment + the wiring
  event type (the 2-arg lambda silently dropped `source`, force-confining every
  codex-generated artifact — the feature never worked outside the workspace);
  new test/session-listener-wiring.test.ts asserts the pass-through
- Gate the Codex `Saved to: file://` scanner on mode === 'codex' via a
  codexArtifacts option threaded from the session call site; magic links stay
  mode-agnostic; tests assert claude/shell sessions never emit codex-generated
  requests
- Decide the generated-artifact trust policy on the realpath-RESOLVED path
  (unresolvable → force-confined) and anchor the ~/.codex marker dirs to
  os.homedir() prefixes with startsWith instead of substring matching; symlink
  escape + unanchored-marker regression tests added
- Run the Codex scanner on stripAnsi'd data so trailing SGR sequences don't
  ride into the captured URL; styled 'Saved to:' test added
- Extend generateFirstPageThumbnail with jpg/jpeg/gif/webp passthrough and
  per-extension content types (mirrors the png passthrough) so the PR's new
  image formats render real thumbnails instead of 204 letter-tiles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 17:50:21 +02:00
Codeman maintainer 895edfedb0 fix(review): add Codex last-response test coverage + minor hardening (PR #152)
- Add test/routes/session-routes-codex-last-response.test.ts (app.inject +
  temp CODEX_HOME fixture rollouts): originator match beats cwd fallback when
  two panes share a dir, cwd fallback excludes sibling-claimed/foreign-cwd
  rollouts, resume-uuid filename match, history.jsonl pin outranks originator,
  event_msg/legacy user-turn dedup keeps old-codex turns, injected-context
  filtering, image placeholder, envelope shape ({success:true,data:{text,
  timestamp[,messages]}}), and a Claude-mode regression guard (codex reader
  never consulted for claude sessions)
- Replace clear-at-cap Map caches (codexHistoryPinCache, codexRolloutMetaCache)
  with the repo-standard LRUMap so a full cache wipe can't thrash hot entries
  on large rollout collections
- Join multi-block assistant/user text with a blank line instead of no
  separator (extractCodexBlockText)
- Re-enable the terminal-buffer eye fallback for shell sessions (they have no
  transcript source at all); TUI modes keep the clear placeholder

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 17:42:39 +02:00
Ark0N 3f23621f8d Merge pull request #144 from TeigenZhang/pr/mouse-restore-decset-strip
feat(web): restore tap/click/wheel mouse interaction when server strips mouse DECSETs
2026-07-12 13:23:22 +02:00
Ark0N 5cca965aa4 Merge pull request #143 from TeigenZhang/pr/cjk-input-loss
fix(mobile): CJK input loss — IME state machine, focus routing, and Android InputConnection recovery
2026-07-12 13:23:19 +02:00
Ark0N b74a904b41 Merge pull request #142 from TeigenZhang/fix/mobile-response-viewer-typography
fix(mobile): improve response-viewer readability on phones
2026-07-12 13:23:17 +02:00
Codeman maintainer 05d366e405 Merge PR #140 from crawlsys/feat/webgl-renderer-toggle: WebGL renderer toggle in settings
Includes review fixes (per-device setting + sticky-marker semantics); merged locally because the org-owned fork rejects maintainer pushes.
2026-07-12 13:22:53 +02:00
Ark0N 9204e42812 Merge pull request #139 from aakhter/cod-160-unified-session-service
Unified session list: backend service + endpoint
2026-07-12 13:22:39 +02:00
Ark0N a9749ead6a Merge pull request #138 from aakhter/cod-80-raise-terminal-defaults
Raise terminal history/scrollback/buffer defaults (50k→100k, 2MB→32MB)
2026-07-12 13:22:37 +02:00
Codeman maintainer e510ab74ca fix(review): use dvh fallback pair so the response-viewer header stays on-screen on iOS (PR #142)
- 92vh on iOS Safari measures the large viewport; with browser chrome visible the
  panel top (header + close button) clipped off-screen. 88vh fallback + 92dvh
  matches the repo's established dvh idiom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:20:16 +02:00
Codeman maintainer 7fa52cdcd6 fix(review): make WebGL toggle per-device and fix sticky-marker semantics (PR #140)
- saveAppSettings no longer sends webglRendererEnabled on the settings PUT:
  the key is absent from the .strict() SettingsUpdateSchema, so every save
  400'd with INVALID_INPUT, silently killing all server-side settings
  persistence. Stripped in the per-device destructure alongside
  localEchoEnabled/skin/etc.
- shouldSkipWebGL now treats a stored true like the untouched default w.r.t.
  the sticky marker: the checkbox defaults checked on desktop, so any
  unrelated save stored true and every page load then cleared the
  'codeman-webgl-disabled' marker, permanently defeating the GPU-stall
  auto-fallback. Only ?webgl=force clears the marker at init.
- The marker is instead retired on a real OFF->ON toggle flip detected at
  save time (mirrors the _prevGestureEnabled pattern in settings-ui.js).
- webglRendererEnabled added to the displayKeys per-device set in
  loadAppSettingsFromServer (renderer choice is device/GPU-specific; syncing
  would leak mobile's hidden-checkbox false onto desktop).
- Tests: stored true + sticky marker -> still skips WebGL; OFF->ON save
  clears the marker and keeps the key off the wire; default-checked save
  leaves the marker alone; ?webgl=force / ?nowebgl behavior unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:42:36 +02:00
Codeman maintainer 5ea424565d fix(review): content-free IME traces, guarded onData self-heal, Android-only retap recovery (PR #143)
- BLOCKER (privacy): the CJK diagnostic trace logged typed CONTENT — _esc(e.key)
  per keystroke, up to 24 chars of textarea value on focus/blur/compstart/
  compend/input, and the flushed text — mirrored into _crashDiag, which
  persists to localStorage and beacons to POST /api/crash-diag. Traces are now
  content-free: key CLASS via _kdesc (any single code point → 'printable',
  named keys pass through), value lengths + phantom presence via _vdesc
  (len=N[+ph]), and 'flush send len=N'. _esc removed.
- MAJOR: the onData self-heal refocused the CJK field whenever gated data
  arrived with focus elsewhere — but onData also fires for xterm's
  SELF-GENERATED query replies (DA/DSR/CPR/OSC during Ink redraws), so it
  stole focus from rename/search/settings inputs while output streamed. Now
  requires document.activeElement === this.terminal.textarea (genuine typed
  input) and bails when shouldSuppressTerminalQueryResponse(data) matches.
- MAJOR: the pointerdown blur→setTimeout(focus,0) wedged-IME recovery ran on
  ALL platforms; on iOS tapping the focused empty field is normal and the
  async refocus is outside the user-gesture stack. The listener is now only
  registered when /Android/i.test(navigator.userAgent).
- tests: trace-privacy test (no typed character or textarea value ever appears
  in the trace; lengths/key classes still recorded), iOS harness asserts the
  pointerdown recovery never cycles, self-heal source guard asserts both new
  conditions; vm harness gained a ua option (navigator injected, Android UA
  default so the existing wedged-IME test still exercises the recovery).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:42:14 +02:00
Codeman maintainer 0ad673794f fix(review): dedupe resumed-session rows + newest-wins lifecycle name/mode (PR #139)
- Duplicate rows: transcript-history rows are keyed by the Claude
  conversation UUID (.jsonl filename stem), which diverges from the Codeman
  session id for resumed (claudeSessionId = resumeSessionId != id) and
  /clear-respawned sessions, so one conversation surfaced as both a live row
  and a history-only row. mergeUnifiedSessions now builds an alias map
  (claudeSessionId -> Codeman id) from the live + persisted views and
  resolves history/lifecycle keys through it; the route feeds
  SessionState.resumeSessionId as the persisted alias.
- Inverted precedence: SessionLifecycleLog.query() returns entries
  NEWEST-first, but the merge loop unconditionally overwrote name/mode so
  the OLDEST entry in the window won (stale rename/mode). First-seen now
  wins, mirroring the existing lastActivityAt guard.
- Tests: resumed session yields ONE row (service unit + route end-to-end
  with a real transcript fixture); renamed-then-deleted session surfaces
  the NEWEST name/mode. All 4 new tests fail against the pre-fix code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:41:48 +02:00
Codeman maintainer 4d3080aacc fix(review): gate wheel forwarding by CLI mode/version, stop link click double-fire (PR #144)
- _shouldForwardWheelToApp: claude sessions forward wheel to the TUI only
  when the banner-parsed cliVersion is known AND >= 2.1.187 (older/unknown
  Claude Code captures wheel as select-menu navigation → keep local
  scrollLines); new dependency-free _cliVersionAtLeast semver-ish compare
- gemini excluded from wheel forwarding entirely (TUI wheel behavior
  unverified); codex keeps forwarding (verified); taps/clicks still
  forwarded for all strip modes
- link double-fire: registerFilePathLinkProvider links now track hover
  state via ILink hover/leave callbacks (_linkHovered) and
  _handleDesktopTerminalClick bails while a link is hovered, so a link
  click no longer also sends a synthetic SGR press/release to the TUI
- help modal: document Shift+Wheel (scroll local history when mouse
  passthrough is active)
- tests: version gate (2.1.186/unknown/garbage no forward, 2.1.187+
  forwards), codex/gemini split, link-hover click suppression

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:40:25 +02:00
Codeman maintainer 246f7b532d fix(review): clamp env-path trim below max; revert unwired scrollback raise (PR #138)
- UNBOUNDED-MEMORY: DEFAULT_TERMINAL_BUFFER_TRIM_BYTES from CODEMAN_TRIM_TERMINAL_TO
  had no relation to DEFAULT_TERMINAL_BUFFER_MAX_BYTES — setting only
  CODEMAN_MAX_TERMINAL_BUFFER=2097152 left the 24MB trim default in force, making
  BufferAccumulator.trim() (slice(-trimSize)) a no-op: unbounded growth past the cap
  plus a full string re-join on every append (O(n²)). Trim default is now clamped to
  75% of the resolved max (the 24MB/32MB default ratio, preserved as hysteresis);
  regression test re-evaluates the module under the env via vi.resetModules.
- OVERCLAIM: reverted DEFAULT_TERMINAL_SCROLLBACK_LINES 100k -> 50k — it has zero
  consumers; browser xterm scrollback is the separate hardcoded DEFAULT_SCROLLBACK
  (50k) in constants.js and deliberately stays 50k (mobile-memory hazard). The tmux
  history-limit raise (50k -> 100k) and PTY 32MB/24MB raise remain (those are wired).
  Module docstring now claims only what is wired; fixed the stale tmux-manager.ts
  comment saying the tmux limit "matches the xterm-side default in constants.js".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:39:46 +02:00
codeman-localandClaude Fable 5 116db81002 feat: response-viewer support for Codex sessions
The response-viewer (eye) currently reads only ~/.claude/projects — for
Codex panes it falls back to a raw terminal-buffer dump. This adds a
Codex-aware reader with exact per-pane rollout attribution.

Locating THIS pane's rollout (~/.codex/sessions/**), in confidence order:

1. history match — Session tracks the pane's last Enter
   (codexLastSubmitAt); correlating it against ~/.codex/history.jsonl
   {session_id, ts} entries identifies the thread the pane is ACTUALLY
   on, surviving /resume, /new and /fork typed inside the codex TUI.
   An entry is credited to the pane whose Enter is closest, so menu
   keystrokes in other panes can't steal attribution.
2. originator match — codex panes are spawned with
   CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_<sessionId>, which codex
   (verified on 0.144.1) writes into session_meta.originator of every
   rollout it creates.
3. resume-id match — resumed rollouts keep their original session_meta
   (codex appends without rewriting), but the uuid is in the filename.
4. cwd+mtime heuristic — case-blind compare (codex records launch-time
   path case) and rollouts claimed by other panes are excluded.

Reader details: user turns come from event_msg/user_message (real input
only — AGENTS.md / environment_context injections never appear there),
deduped against legacy response_item rows per-text so mixed-version
rollouts keep full history; image inputs render an [image xN]
placeholder; session_meta identity is cached per path (write-once).

Frontend: thread role label follows session mode (Codex/Gemini/
OpenCode); the terminal-buffer fallback is Claude-only — TUI modes show
a clear placeholder instead of a repaint dump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 10:39:42 +08:00
Aamer AkhterandSaqeb Akhter bb1d16e230 feat(image): convert HEIC paste uploads to JPEG
When a browser pastes an HEIC file without normalising it first, the
paste-image route now converts it to JPEG server-side via heic-convert
before writing to .claude-images/. Magic-byte validation confirms the
output is valid JPEG. Adds type declarations for the heic-convert package.

Co-authored-by: Saqeb Akhter <saqeb.akhter@gmail.com>
2026-07-11 16:32:49 -04:00
Saqeb Akhter 978ca57343 fix: COD-152 preserve generated artifact filenames 2026-07-10 20:58:47 -04:00
Saqeb Akhter f8aa93969b fix: COD-152 surface Codex generated artifacts 2026-07-10 20:54:41 -04:00
Aamer Akhter 584910f645 COD-144 flush queued SSE output on empty buffer-load so new shells paint immediately
A freshly created shell session rendered blank until a tab-switch. selectSession()
fetches the terminal buffer, but for a just-started shell that fetch resolves before
the PTY emits its prompt, so the buffer is empty; the prompt then arrives as a live
SSE event queued during the load and _finishBufferLoad() discarded it. The discard is
correct for an established session (its fetched buffer already contains that output),
but harmful when the load painted nothing.

_finishBufferLoad(owner, { flushQueued }) now REPLAYS the queued events through
batchTerminalWrite (after _isLoadingBuffer is cleared, so they write through, not
re-queue) instead of discarding. selectSession passes flushQueued only in the empty
branch (no fresh buffer + no cache), so the established-session de-dup path is
unchanged. TDD: test/terminal-buffer-flush.test.ts exercises the real begin/finish
mixin (vm-harness, no jsdom).
2026-07-10 14:39:19 -04:00
Aamer Akhter b86b132af5 COD-136 skip redundant connection-indicator DOM writes on hot input path
_updateConnectionIndicator() ran on every keystroke (_reliableSend) and
every ACK (_ackDelivery), unconditionally writing display/className/
textContent/title. During fast typing the rendered output is usually
identical between calls, so those were wasted main-thread DOM writes.

Extracted the branch logic into a pure DOM-free _computeConnectionDescriptor()
returning { display, dotClass, text, title } (every branch/string preserved
verbatim; hidden state normalizes the three non-display fields to '' so the
compare is well-defined). _updateConnectionIndicator() now computes the
descriptor, compares all four fields against a cached _lastIndicatorDescriptor,
and early-returns when unchanged — otherwise caches and writes the DOM exactly
as before (display always; dotClass/text/title only when shown). First call
renders (cache starts null). Perf only, no behavior change.

Tests: test/connection-indicator.test.ts — 9 descriptor cases pinning the
exact strings per state + 4 skip cases (first call writes; two identical calls
write DOM once via counting setters; state change and hidden->shown re-render).
31/31 with input-send-order regression; build, frontend-syntax, prettier clean.
2026-07-10 14:39:09 -04:00
Aamer Akhter 4ab89f9a4e COD-137 scope WS per-session limit by clientId (fix spurious 4008 on reconnect)
MAX_WS_PER_SESSION was gated by a bare Map<sessionId,number> counter,
incremented on upgrade and decremented only on the old socket's async
close. A client that dropped and immediately reconnected could land its
new upgrade before the old socket's close fired, briefly over-counting and
tripping a spurious 4008 (-> HTTP fallback). The limit also counted raw
sockets, so a reconnecting client consumed a new slot instead of its own.

Replace the counter with WsConnectionRegistry (new pure, unit-tested module)
that tracks live sockets per session keyed by clientId. A same-cid upgrade
SUPERSEDES its own socket (evicts the stale one with close 4010, reuses the
slot, no net count change) -> a reconnect can never be rejected by the cap.
The reliable-input protocol (shouldApplyInput(cid,seq)) already assumes one
logical client per cid per session, so same-cid eviction is principled, not
a regression of multi-tab (which already collides on seq). Slots are freed
EAGERLY on error/terminate, not just async close; close is identity-matched
so a superseded socket's late close is a no-op. cid-less upgrades are
admitted anonymously up to the cap and never evict (backward-compat).
Client sends cid on the WS upgrade URL (?cid=, encoded, omitted if absent).

Tests: ws-connection-registry.test.ts (reconnect-reclaim at cap, rejects
N+1th distinct, eager-terminate frees slot, cid-less up-to-limit + no-evict,
late-close-no-evict, per-session isolation) + route integration in
ws-routes.test.ts (real upgrade through the cap). 45/45 across registry +
ws-routes + input-send-order + ws-reconnect-plan; tsc 0, build, prettier,
frontend-syntax clean.
2026-07-10 14:39:01 -04:00
Aamer Akhter 20cb42d202 COD-135 re-drive lost input ACK on a live WebSocket (durable-delivery gap)
A reliable-input frame could be stranded forever if its server ACK
({t:'ia',seq}) was lost while the WebSocket kept delivering other output.
_drainSession's WS fast path skips records with sentAt!==0, and after
COD-134 the sweep only force-closes a *silent* socket -- so a lost ACK on
an otherwise-live socket (stale && !silent) was never re-sent.

_redeliverSweep now, for an active-WS session whose oldest unacked frame
is stale but the socket is NOT silent, resets sentAt=0 on every stale
unacked frame and lets the existing _drainSession re-drive them over the
live socket (server dedups by seq). The stale && silent force-close
remains the fallback for a genuinely half-open socket. Restores the
exactly-once recovery guarantee without reintroducing the flap.

Tests: new failing-first COD-135 cases in test/input-send-order.test.ts
(re-drive on live socket; leave not-yet-stale alone; keep stale+silent
force-close). 18/18 across input-send-order + reliable-input-dedup +
ws-reconnect-plan; tsc 0, frontend-syntax, build all clean.
2026-07-10 14:38:52 -04:00
Aamer Akhter 68fd6e8962 COD-134 fix WS flap loop (undefined onopen call) + reconnect resilience + logging
Root cause of the WS->HTTP->WS flapping: the v1.1.15 input-delivery merge left a
call to the now-undefined _flushHttpFallbackQueuesViaWs() in ws.onopen, so every
(re)connect threw a TypeError BEFORE _onWsReady() ran -- durable input was never
re-flushed over the fresh socket, the 2s redeliver sweep then saw stale unacked
frames and force-closed the socket, reconnect, throw again: a self-sustaining
flap loop. Remove the dead call (_onWsReady, 10 lines below, is its replacement).

Resilience + observability:
- Pure CodemanWsReconnect.plan(code, attempt) (constants.js, TDD, 6 tests):
  <4004 -> fast reconnect (immediate jittered first retry, faster backoff);
  4008/unknown->=4004 -> bounded retry-fallback (HTTP no longer sticks until a
  tab switch); 4004/4009 -> give up (session gone). Wired into onclose.
- Redeliver sweep force-closes only a SILENT socket (no recent recv), not one
  actively delivering output/ACKs -- stops self-inflicted flaps while typing.
- Client logs WS close code/reason to crash-diag; server logs [ws]
  open/close/terminate/4008 (console -> journald; Fastify runs logger:false).

Verified: 6/6 unit, tsc 0, frontend-syntax + prettier clean, build; beta WS
reaches connected with zero console errors (onopen TypeError gone),
_wsLastRecvAt tracked, server [ws] lines emit.
2026-07-10 14:38:48 -04:00
Aamer Akhter be4fecdad5 COD-133 fix header WS status indicator + typing lag from v1.1.15 merge
The upstream v1.1.15 merge spliced upstream's transport-object indicator
body onto local's _connectionStatus-based _updateConnectionIndicator()
without defining `transport`, so every transport.* reference threw
ReferenceError on any queued state. That hid the "WS" status and, because
_reliableSend() updates the indicator before _drainSession(), made every
keystroke skip immediate delivery (input flushed only on the 2s sweep =
typing lag).

- Rewrite _updateConnectionIndicator() to show the terminal WebSocket
  transport from _wsState (WS / HTTP / WS… / Offline), falling back to the
  SSE _connectionStatus only on the idle dashboard.
- Only annotate a backlog (· N queued) above 4 bytes so normal typing no
  longer flickers "sending 1B" on each key press.
- test/connection-indicator.test.ts (new): transport labels, the >4B
  threshold, an exhaustive never-throws guard for the ReferenceError, and
  the _reliableSend -> _drainSession invariant (typing-lag guard).
- test/input-send-order.test.ts: reconcile to local's durable input layer
  (the prior coalescing-fallback tests had been failing since 1255e28).
2026-07-10 14:36:21 -04:00
Aamer Akhter c7967d4b55 COD-138 normalize shell scrollback to CRLF so replay doesn't staircase
A shell terminal could render output diagonally (each line shifted one
column right) after a full page reload or a cursor-query-failure replay.

Root cause: capturePaneBuffer's full-history path (capture-pane -p -e -S -)
and its cursor-query-failure fallback returned raw scrollback, which tmux
joins with a BARE \n. The browser xterm uses convertEol:false (correct for
the live PTY stream, which carries real \r\n), so each bare \n dropped a
row without returning the cursor to column 0 -> staircase. The visible /
tab-switch path (formatPaneSnapshot) was immune because it repaints each
row with an absolute cursor CSI.

Fix: new pure helper normalizeScrollbackEol() (\r?\n -> \r\n, idempotent
on CRLF, leaves lone \r overwrites untouched, adds/removes no rows) applied
at both raw-return seams. The absolute-positioned snapshot path is unchanged.

Tests: test/tmux-scrollback-eol.test.ts pins the invariant (no LF without a
preceding CR) + CRLF idempotency + lone-CR preservation. 136/136 across
tmux-scrollback-eol + tmux-capture-full-history + tmux-manager +
routes/session-routes; build, tsc, prettier, frontend-syntax clean.
2026-07-10 11:14:26 -04:00
Aamer Akhter 8be83cd585 COD-47 replay full tmux scrollback on terminal reload
A full page reload (GET /api/sessions/:id/terminal with no ?tail=) now captures
the ENTIRE tmux scrollback via capture-pane -p -e -S -, so users get back history
that scrolled off Codeman's byte buffer. Tab switches (?tail=N) keep the fast
visible-frame capture.

- tmux-manager capturePaneBuffer/captureActivePaneBuffer take { fullHistory }:
  full-history returns raw linear scrollback (skips the single-screen
  formatPaneSnapshot repaint, which would clip multi-screen history).
- /terminal selects full-history on full reload, visible on tail; caps the
  payload at the configured terminalBufferMaxBytes (keeps most-recent bytes,
  line-aligned) and returns source/fullSize/truncated metadata.

Verified: tsc 0, tmux-capture-full-history 5/5, session-routes 68/68.
Caveat: lines tmux already evicted past its history-limit can't be recovered.
2026-07-10 11:08:57 -04:00
Aamer Akhter 09fd1e495f COD-118 fix(test): stub resetRespawnBreaker in MockSession
POST /api/sessions/:id/interactive calls session.resetRespawnBreaker()
before startInteractive(); mock missing the stub → route threw → 422.
2026-07-09 16:00:06 -04:00
Aamer Akhter 7efc6cd5a8 COD-94 fix(lint): remove useless escapes in jump-host regex character class
\[ inside [...] doesn't need backslash — ESLint no-useless-escape.
2026-07-09 12:18:21 -04:00
Aamer AkhterandClaude Opus 4.8 286cf0768d COD-118 feat: circuit breaker bounding repeated non-zero interactive-PTY exits
Defense-in-depth after COD-115. If the interactive PTY exits non-zero
repeatedly within a short window, recovery/reconnect paths recreate it
indefinitely (COD-115 saw 114 'exited with code: 1' events + orphans).

- New pure InteractivePtyExitBreaker (session-pty-exit-breaker.ts):
  injectable time, sliding window, clean-exit resets counter, stays
  tripped until reset(). Defaults: threshold 5, window 10s.
- Session records each interactive PTY exit in the breaker; on trip it
  flips _status to 'error', sets _respawnBlocked, emits
  respawnBreakerTripped. startInteractive() refuses to respawn while
  blocked, so all recovery/reconnect callers stop looping uniformly.
- Explicit user restart (POST /api/sessions/:id/interactive) calls
  resetRespawnBreaker() so intentional restarts are never blocked.
- New SSE event session:respawnBreakerTripped wired in sse-events.ts +
  constants.js (registries in sync) + session-listener-wiring.ts;
  minimal diagnostic toast in app.js.
- Tests: test/respawn-pty-breaker.test.ts (pure trip/reset/window +
  MockSession session-level trip/reset).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 11:49:23 -04:00
Aamer AkhterandClaude Opus 4.8 3c0e6286f6 COD-115 fix: scrub TMUX/TMUX_PANE so tmux-backed sessions don't crash-loop
When the web server is launched from inside a tmux pane it inherits TMUX/
TMUX_PANE. tmux's nesting guard then makes every new attach-bridge PTY
(`tmux attach-session`, used by codex/opencode/gemini and mux-wrapped claude)
exit code 1; the respawn controller recreates the dead bridge → infinite loop.

The existing guard in buildMuxAttachEnv() used `TMUX: undefined` on a
{...process.env} spread, which leaves the KEY present with value undefined —
node-pty serializes it as the literal string "TMUX=undefined", still tripping
the guard. (The working create path in tmux-manager.ts uses `delete`.)

Fix:
- Primary: delete process.env.TMUX / TMUX_PANE at web bootstrap (src/index.ts)
  so every downstream {...process.env} spread is clean regardless of launch
  context. `delete`, not `= undefined`.
- buildMuxAttachEnv(): build a copy and `delete` TMUX/TMUX_PANE/CLAUDECODE
  (and COLORTERM when not truecolor) instead of `: undefined` — same node-pty
  quirk affected all of them.
- Test: assert the keys are genuinely ABSENT (`'TMUX' in env === false`), not
  merely undefined — the prior test only checked `toBeUndefined()`, which is
  why the bug slipped through. Red→green confirmed.

Verified on isolated beta launched from inside tmux (inherited the poisonous
TMUX=codeman,980,7): created a codex session + triggered interactive attach —
the bridge `tmux -L codeman-beta attach-session` spawned with NO TMUX in its
env, attached successfully, zero "exited with code: 1", server healthy.

Circuit-breaker for repeated non-zero bridge exits (AC bullet 4, optional)
split to a follow-up. Deploy-pending (substrate): never auto-deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 11:47:38 -04:00
Aamer AkhterandClaude Sonnet 4.6 8a133d083b fix: COD-163 implementation gaps — shortcut overlay, settings tab, remote-case shell
- app.js: getShortcutRegistry()/matchesShortcutEvent()/showShortcutOverlay()/
  renderShortcutOverlay()/closeShortcutOverlay() (needed for DEFAULT_SHORTCUTS
  action dispatch + shortcut-registry-overlay tests)
- settings-ui.js: renderShortcutSettingsList()/startShortcutCapture()/
  onShortcutCaptureKeydown()/resetShortcutOverride()/toggleShortcutEnabled()
  (Settings → Shortcuts tab, needed for shortcut-registry-overlay tests)
- index.html: Shortcuts modal tab + shortcut overlay modal; remove Ctrl+Enter
  hint text (help-modal-shortcuts test asserts absence)
- session-ui.js: remote-case detection in runShell() (caseName vs workingDir);
  saveLastUsedCase after deleting selected case
- test/command-palette-ui.test.ts: expect browse-sessions item (COD-192 adds it)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 11:26:24 -04:00
Aamer AkhterandClaude Sonnet 4.6 a0e26db1dc feat: COD-192 add "Browse all sessions" escape hatch to command palette
Pins a "Browse all sessions…" item at the bottom of the command palette
list (after "New session"). Activating it closes the palette and opens
the Session Manager modal, bridging the gap between the fast in-memory
switcher and the full server-side history browser.

The item gets a distinct visual treatment (≡ icon, muted title/icon
color, 4px top gap) so it reads as a secondary action separate from the
primary session rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 11:11:48 -04:00
Saqeb Akhter 596899e19b fix: COD-157 refine shortcut palette labels 2026-07-09 11:11:33 -04:00
Saqeb Akhter e8f5ac94f3 fix: COD-153 preserve matched case selection 2026-07-09 11:09:00 -04:00
Saqeb Akhter 03192d9980 fix: COD-153 match new session case from palette query 2026-07-09 11:08:53 -04:00
Saqeb Akhter 3d4444ad78 fix: COD-153 guard command palette escape close 2026-07-09 11:08:48 -04:00
Saqeb Akhter c45e456b0e fix: COD-153 support terminal-focused command palette shortcuts 2026-07-09 11:08:23 -04:00
Saqeb Akhter ad25e234f4 feat: COD-153 add command-k session palette 2026-07-09 11:08:17 -04:00
Saqeb Akhter 48fd2da6ce fix: COD-151 launch case picker selection on enter 2026-07-09 11:01:45 -04:00
Saqeb Akhter e29721046c feat: COD-151 add searchable case picker 2026-07-09 11:01:31 -04:00
Aamer AkhterandClaude Sonnet 4.6 3a03792009 fix: resolve cherry-pick conflicts for COD-24/COD-107 remote host integration
- src/remote-hosts.ts: add missing execAsync = promisify(exec) that was
  implied by intermediate commits not in the cherry-pick set
- src/web/routes/session-routes.ts: add getDataDir import and
  readRemoteCases/readRemoteHosts/toSessionRemote for remote case support
  in quick-start; narrow casePath string|null via resolvedCasePath cast
- test/routes/session-routes.test.ts: add vi.hoisted remoteStore mock for
  remote-hosts.js; fix 'creates session from remote case' test to use
  /api/quick-start (remote cases are not supported on /api/sessions)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 09:35:14 -04:00
Aamer AkhterandClaude Opus 4.8 e83ff72b61 COD-107 fix: shellescape -J jumpHost + structural validator (close command-injection)
buildSshConnectionArgs interpolated jumpHost raw while its siblings
(identityFile/socksProxy/extraSshOptions) were shellescaped. The token array is
joined and run via execAsync (/bin/sh -c), so a jumpHost like "x; touch /tmp/pwned"
executed. The Zod denylist only blocked backtick/newline/$( and let ;|& and spaces
through.

- shellescape jumpHost in buildSshConnectionArgs (primary fix)
- replace jumpHost denylist with a structural allowlist: [user@]host[:port],
  comma-separated multi-hop, bracketed IPv6; no shell metachar can appear
- update/extend tests: escaped -J assertion + injection-safety case

Verified: remote-ssh-options (11) + case-routes (33) pass, tsc --noEmit clean,
regex accepts valid forms / rejects 8 injection payloads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 09:17:02 -04:00
Aamer Akhter 268a0bbdbd COD-107 remote SSH: custom port + advanced connection options (escape hatch)
The Remote case form could only reach port-22, default-identity, directly
SSH-able hosts. Add an escape-hatch set of SSH connection options so Codeman
can reach a host like aa-desktop (custom port 2222, ed25519 identity, cloudflared
SOCKS5 ProxyCommand) the way ssh-aa-desktop does — without shelling out to that
wrapper.

- Model (types/session.ts): new optional RemoteSshOptions (identityFile,
  socksProxy, jumpHost, extraSshOptions) on RemoteHost AND SessionRemote; all
  absent = today's behavior. toSessionRemote() carries them case->session.
- Shared buildSshConnectionArgs(remote) in remote-hosts.ts: pure, exported,
  ordered ssh connection tokens (-o BatchMode=yes, -p, -i <abs identity with
  ~/$HOME expanded + shellescaped>, -J, -o ProxyCommand=nc -X 5 -x <socks>
  %h %p emitted as ONE shellescaped token so %h %p reach ssh literally, then
  each extraSshOptions -o). Both buildRemoteLaunchCommand (tmux-manager.ts) and
  buildRemoteTmuxCheckCommand now use it, so the prereq probe and the real
  launch connect identically. checkRemoteTmuxAvailable widened to accept the
  options (callers already pass the full host).
- Validation (schemas.ts): identityFile (no newline/NUL), socksProxy
  (host:port), jumpHost (no shell metachars), extraSshOptions (KEY=VALUE,
  reject newline/NUL/backtick/$() — defense-in-depth on operator-entered config.
- UI (index.html + session-ui.js): SSH Port field + collapsible "Advanced SSH"
  section (identity, SOCKS proxy, jump host, extra -o options one per line);
  wired into the remote-host create payload.

Empty-options remotes emit byte-identical ssh to before (pinned by test).

Tests: test/remote-ssh-options.test.ts (buildSshConnectionArgs +
buildRemoteLaunchCommand + buildRemoteTmuxCheckCommand for the aa-desktop set,
escaping/%h %p/identity-~ expansion, byte-identical back-compat); case-routes
schema tests (advanced options round-trip; malformed extraSshOptions/socksProxy
rejected). tsc/eslint/frontend-syntax/prettier/build clean.

Acceptance (real remote, no wrapper): the emitted command connected to
aa-desktop through the cloudflared SOCKS proxy and created a durable remote
tmux session (verified independently via ssh-aa-desktop: CONNECTED_NO_WRAPPER,
STILL_ALIVE_AFTER_DETACH); checkRemoteTmuxAvailable over the proxy returned
{ok:true, tmuxPath:/usr/local/bin/tmux}; test session cleaned up.
2026-07-09 09:16:57 -04:00
Saqeb Akhter 26e78daf58 fix: COD-24 stabilize remote host sessions 2026-07-09 09:06:06 -04:00
Saqeb Akhter 568d93efb0 feat: add remote host case routes 2026-07-09 08:51:12 -04:00
Saqeb Akhter 3bf991d730 feat: add remote host case domain 2026-07-09 08:51:08 -04:00
Teigen 7fb58648ba feat(web): forward wheel + guard clicks for desktop stripped-mouse sessions
Desktop click-to-position-cursor died under the server's mouse-DECSET strip
(same root cause as the mobile touchend tap regression): xterm's native mouse
encoder only emits SGR while mouseTrackingMode is ON, but the server strips the
enabling DECSETs from claude/codex/gemini output. Hand-encode the report for
plain left-clicks (_handleDesktopTerminalClick), skipping every click that
already means something else (synthetic/compat, modified, double/triple,
drag-selection, off-grid, xterm encoder live).

Also widen forwarding to the wheel: Claude Code 2.1.187+ scrolls its own
transcript on SGR wheel reports and no longer captures wheel as select-menu
navigation (verified against 2.1.202), so forward the wheel to the TUI for
strip-mode sessions at the buffer bottom (40ms-coalesced to avoid a tmux
send-keys storm). Shift+wheel and any scrolled-up viewport stay on xterm's
local scrollback. Guard synthetic taps/clicks on viewport-at-bottom so a
scrolled-up report can't hit-test the wrong row.

Tests: 12 cases in test/terminal-touch-tap.test.ts. Verified E2E via Playwright
against the live instance (wheel up/down forward, Shift+wheel local, click).
2026-07-07 17:52:32 +08:00
Teigen 9535edc367 fix(mobile): restore tap-to-position cursor after master merge — hand-encode SGR when server strips mouse DECSETs
v1.1.7 (3172bef, arrived via the master merge) strips mouse-tracking DECSET
sequences from claude/codex/gemini output so the wheel keeps scrolling
scrollback. Side effect: the browser xterm's mouseTrackingMode is permanently
'none' for those sessions, and the mobile touchend tap branch gates its
synthetic click on exactly that mode — so tap-to-position-cursor silently died.

Fix: when tracking reads 'none' but the session mode is one the server strips
(claude/codex/gemini — the PTY-side TUI still has tracking ON), encode the SGR
press+release report directly from the touch point and send it to the PTY,
bypassing xterm's mouse encoder. No DOM click is dispatched, so xterm's local
selection cannot trigger either.

Tests: 3 new cases in test/terminal-touch-tap.test.ts (SGR encoding, grid
clamping, shell-mode exclusion); verified E2E via Playwright iPhone emulation
against both a stripped-stream instance and the production bundle.
2026-07-07 17:52:32 +08:00
Teigen 443b85c18e fix(mobile): CJK input loss — IME state machine, focus routing, and Android InputConnection recovery
Three independent root causes of intermittent Chinese character loss
(English was unaffected because it bypasses the composition path):

1. input-cjk.js state machine: stuck _composing when compositionend never
   fires (WeChat/Sogou IMEs) silently swallowed all input; the deferred
   compositionend flush could reset the textarea mid-next-composition
   (cancels the live IME composition on iOS); the 100ms keydown-echo
   window discarded ANY input regardless of content.

2. Focus stealing: session-select / SSE-reconnect paths call
   terminal.focus() (15+ call sites), landing focus on xterm's hidden
   textarea; with the CJK onData gate active, everything typed there was
   swallowed. Fix: focus router in initTerminal routes ALL
   terminal.focus() calls to the CJK field while it is visible, plus a
   self-healing onData gate that reclaims focus when it swallows input.

3. Android InputConnection wedge (9-key IMEs + Chromium): the keyboard
   composes in its own UI but delivers zero DOM events. Fix: skip
   redundant textarea value/selection writes (they race IME session
   setup), and re-tapping the focused empty field forces a blur→focus
   cycle that restarts the input session.

Diagnostics: input-cjk.js now traces every IME event/flush decision into
the crash-diag breadcrumbs; /api/crash-diag stores beacons per page-load
id (iOS PWA reloads no longer wipe the trail, concurrent clients no
longer clobber each other) and flushes on visibilitychange.

Tests: test/input-cjk.test.ts (vm-sandbox, 9 cases incl. regression
guards for all three root causes).
2026-07-07 17:52:10 +08:00
Teigen 66eaaf0da3 fix(mobile): improve response-viewer readability on phones
The mobile media query only overrode .response-viewer-body with a flat
font-size: 12px / padding: 12px, leaving the desktop response-viewer
typography system (--rv-content-max, .rv-text pre, heading scale) with no
mobile tuning. Bump body text to 14.5px/1.65, give code blocks phone-sized
padding and 11.5px code, scale headings (h1 1.35em / h2 1.2em / h3 1.08em),
let content span full width, and cap the panel at 92vh.

Layers cleanly on top of the existing response-viewer selectors in
styles.css; desktop rendering is unchanged.
2026-07-06 10:36:57 +08:00
Aamer Akhter ce4c5dd584 test(types): stop asserting Date.now() timestamps are deep-equal
createInitialRalphTrackerState() stamps lastActivity: Date.now(). The
'should create fresh instances each time' test deep-equaled two factory
results, so two calls straddling a millisecond boundary differed by 1ms
and failed intermittently (e.g. PR #139 CI: 1782927694581 vs ...580).

Exclude the dynamic lastActivity from the equality check and assert it
is a number separately, preserving the test's intent (distinct instances
with identical initial field values) without the timing race.
2026-07-05 21:49:31 -04:00
KrisandClaude Opus 4.8 e77af21107 docs(cron): add cron user guide + Claude speedrun protocol
- docs/cron-guide.md: comprehensive user/operator guide for the Cron
  feature (fields, schedule types, prompt security, execution flow, API,
  SSE, limits, troubleshooting), sourced from the implementation.
- SPEEDRUN.md: fast-execution protocol for Claude grounded in this repo's
  real commands and CLAUDE.md guardrails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SSVnYek4nq4Ztmbb3SrCA5
2026-07-04 16:52:05 +05:30
DennisandClaude Opus 4.8 a842f2db4d fix(auth): re-issue session cookie on each request (sliding expiry)
The codeman_session cookie was only set on the Basic Auth path with a fixed
lifetime from login and never refreshed, while the server-side session store
slides its TTL (refreshOnGet). So the browser cookie expired mid-use, the next
request arrived cookie-less and fell through to Basic Auth, popping the native
username/password dialog — perceived as a random logout while actively working.

Re-issue the cookie on every authenticated (valid-cookie) request so the browser
lifetime tracks the server-side sliding TTL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:24:24 +00:00
Kevin Crawley bf36eb0db4 feat(terminal): add WebGL renderer toggle in settings
Adds a 'WebGL Renderer' toggle to Settings > Appearance (desktop). WebGL
stays on by default; users can turn it off to force the DOM renderer when
they hit GPU glitches, without needing the ?nowebgl URL param. Explicit
opt-in (or ?webgl=force) clears a stale auto-fallback marker. Mobile skip
and the long-task auto-fallback safety net are unchanged.

The device/param/sticky/pref interaction is factored into a pure,
unit-tested shouldSkipWebGL() helper in constants.js.
2026-07-01 20:41:38 -05:00
Aamer Akhter 4dfdbcd100 COD-160 unified session list: backend service + endpoint
First increment of the read-only "complete + searchable session list".

- New src/services/unified-session-service.ts: mergeUnifiedSessions() combines
  live + persisted (state.json) + lifecycle + ~/.claude transcript history + mux
  stats into one list de-duped by sessionId, with precedence
  history < lifecycle < persisted < live, a meaningfulness floor that drops bare
  lifecycle/mux-only noise, and a stable newest-first sort. Plus
  filterAndPaginate() (case-insensitive q over name/firstPrompt/workingDir/
  sessionId; total before paging; limit clamped [1,500]). No IO — unit-testable.
- New GET /api/sessions/unified in session-routes.ts: gathers the five sources
  from ctx (sessions/store/lifecycle/scanProjectDir/mux, each try/caught), feeds
  the pure service, returns { sessions, total } (ApiResponse envelope). testMode
  short-circuits to empty.

Tests: unified-session-service.test.ts (12, pure) + unified-sessions-routes.test.ts
(4, app.inject).
2026-07-01 13:25:16 -04:00
Aamer Akhter ad71a92f29 COD-80 raise terminal history/scrollback/buffer defaults
Bump the centralized terminal-history defaults: tmux scrollback 50k->100k and
PTY buffer cap 2MB->32MB (trim 1.5MB->24MB). Both remain env/settings overridable
and bounds-clamped. Worst-case 20-session buffer budget rises 40MB->640MB.
Stacked on the terminal-history config commit.
2026-07-01 13:00:08 -04:00
Codeman maintainer 1fa88cd187 chore: version packages
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 09:08:33 +02:00
Ark0N 613eb25302 Merge PR #137: Centralize terminal history/scrollback/buffer limits into config (COD-80)
Introduces src/config/terminal-history.ts as the single source of truth for terminal scrollback lines, tmux history-limit, and PTY buffer byte caps. Behavior-neutral: defaults match prior hardcoded values; env overrides preserved. tmuxHistoryLimit is wired live (setHistoryLimit + respawn re-apply); the other three keys are scaffolding for a stacked follow-up. Reviewed: CI green (typecheck/lint + full test suite).
2026-07-01 09:06:15 +02:00
Aamer Akhter 8c0c94540c COD-80 centralize terminal history/scrollback/buffer limits into config
Introduce src/config/terminal-history.ts: one place for terminal scrollback,
tmux history-limit, and PTY buffer byte caps, each overridable via env var or
the settings object and bounds-clamped via resolveTerminalHistoryConfig().
Defaults match the prior hardcoded values, so this is behavior-neutral. Wires
the resolver through buffer-limits, tmux-manager (incl. a setHistoryLimit so a
settings change applies live), session, server, system-routes, session-routes,
schemas, and the config port. Adds 4 optional settings keys (terminalScrollback
Lines, tmuxHistoryLimit, terminalBufferMaxBytes, terminalBufferTrimBytes) with
bounds + a trim<=max cross-check.
2026-06-30 19:52:49 -04:00
KrisandClaude Opus 4.8 d9c2c6420d fix(cron): harden cron fixes against adversarial-review findings
Two blind adversarial reviewers found real holes in the prior cron commits:

SECURITY (was CRITICAL): the prompt-file guard was blocklist-only by default,
so promptFilePath:/proc/self/environ leaked the SERVER PROCESS's entire
environment (every secret) into the agent session, and /dev/zero or a FIFO
caused an unbounded readFile → OOM/hang DoS. A denylist is the wrong posture
for an exfil-into-LLM sink. resolveSafePromptPath now:
  - confines the realpath-resolved file to the job's working dir (ALLOWLIST) —
    closes /proc, /dev, other homes, modern cloud-cred paths, and symlink escapes
  - requires a regular file (rejects dirs/FIFOs/char devices)
  - caps the read at MAX_PROMPT_FILE_BYTES (1 MiB)
  - keeps the /etc,/root,secrets blocklist as defense-in-depth

LOGIC:
  - once-rearm (was MED, defeated in prod): the edit UI round-trips the full
    job, so the field-PRESENCE re-arm check always fired → a renamed fired
    once-job could be resurrected via edit→re-enable. Now compares schedule
    VALUES; an unchanged schedule never re-arms.
  - skipped-run history (was HIGH): recording a skip every tick was unbounded
    state.json growth. Now coalesces consecutive skips (one record per streak)
    and prunes global run history to MAX_CRON_RUN_HISTORY (500), covering the
    launch path too.
  - skip bookkeeping (was MED): a skip no longer advances lastRunAt (nothing
    ran); lastStatus still reflects 'skipped'.

Regression tests added/updated (43 pass): /proc/self/environ + outside-workspace
+ symlink-escape + non-regular + oversized all blocked, in-workspace file
passes; UI-path once resurrection blocked; consecutive skips coalesce to one
record; skip leaves lastRunAt null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU
2026-06-29 12:05:04 +05:30
KrisandClaude Opus 4.8 6082bceee6 fix(cron): close three MED cron-job defects
1. once-rearm on edit: editing any field of a finished one-time job reset
   completedOnce, silently resurrecting it. Now only a SCHEDULE edit
   (scheduleType/runAt/interval/daily/weekly) re-arms a completed once job;
   cosmetic edits (rename/notes) leave completedOnce intact.

2. update-validation gap: CronJobUpdateSchema = .partial() drops the cross-field
   superRefine, so a PUT switching scheduleType without its dependent field
   produced a dead enabled job (nextRunAt:null). updateJob now re-validates the
   MERGED job against the full CronJobSchema and throws 400 on inconsistency,
   leaving the stored job untouched.

3. concurrency-skip silent starvation: skip_if_same_agent_running advanced the
   schedule but wrote no run record, so a perpetually-skipped job had empty
   history. Now records a 'skipped' run (new CronJobRunStatus) + lastStatus.

Tests updated/added in cron-service.test.ts (37 pass): once non-schedule edit
preserves completedOnce, schedule edit re-arms, inconsistent partial update is
rejected with the stored job untouched, and the skip path records a skipped run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU
2026-06-29 11:50:42 +05:30
KrisandClaude Opus 4.8 40e26c5422 fix(cron): confine cron prompt-file reads to block sensitive paths
A cron job's promptFilePath is user-supplied via the API and was read with an
unconfined readFile of any absolute path, so a hostile job config could exfil
arbitrary host files (e.g. /etc/passwd, SSH keys) into a Claude session.

Guard the read in resolvePrompt by mirroring the attachment-serving guard
(resolveServableAttachmentPath in file-routes): realpath-resolve the path, then
reject via the shared blocklist (/etc, /root, secret locations) plus the
optional workspace-confinement toggle before reading.

Regression tests in cron-service.test.ts: blocks /etc/passwd (the live repro)
and /root/*, fails cleanly on a missing file, and still allows an ordinary
prompt file outside the blocklist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU
2026-06-29 11:38:51 +05:30
KrisandClaude Opus 4.8 9feaa0d6e5 refactor(cron): rename scheduler feature to cron
Rename the recurring-jobs feature scheduler->cron to disambiguate from the
legacy ScheduledRun system (/api/scheduled), which is left untouched:

- ScheduledJob->CronJob, SchedulerService->CronService
- /api/scheduler/jobs -> /api/cron/jobs; SSE scheduler:* -> cron:*
- state keys cronJobs/cronJobRuns
- files moved to src/cron/, cron-routes.ts, cron-port.ts, types/cron.ts
- frontend cron-ui.js, #cronModal, menu "Cron"
- docs moved to docs/cron-discovery.md + docs/cron-build-brief.md, README guides
- new tests: cron-service.test.ts, cron-time.test.ts

Green: tsc, lint, frontend-syntax, format, 30 cron + 9 legacy scheduled-runs tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU
2026-06-29 11:35:56 +05:30
KrisandClaude Opus 4.8 2d2f4e592b feat(scheduler): add Scheduled Jobs UI
- scheduler-ui.js: job list + create/edit form + Run Now/Enable/Disable/Delete,
  reacting to scheduler:* SSE events; same-agent Run Now warning
- index.html: "⏰ Schedules" toolbar button + #schedulerModal + script include
- constants.js / app.js: frontend SSE event constants + handler map entries
- styles.css: scheduler row/badge/form styles

Follows Codeman's vanilla-JS mixin + .modal/.form-row conventions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rp7JhmQXcYJhmxFMdZuuah
2026-06-27 08:15:36 +05:30
KrisandClaude Opus 4.8 6ae86b53f6 feat(scheduler): add cron-style scheduled jobs (backend)
Adds a saved/named scheduling layer on top of Codeman's existing session
primitives. Distinct from the legacy run-now ScheduledRun concept.

- types/scheduler.ts: ScheduledJob + ScheduledJobRun
- state-store: persist scheduledJobs/scheduledJobRuns in ~/.codeman/state.json
- scheduler/scheduler-time.ts: pure once/interval/daily/weekly next-run math
- scheduler/scheduler-service.ts: CRUD, Run Now, due-checker tick, run history;
  reuses SessionPort (create -> start -> writeViaMux) for launches
- web/routes/scheduler-routes.ts: /api/scheduler/jobs CRUD + run + history
- web/schemas.ts: zod validation with schedule-type-aware refinements
- web/sse-events.ts: scheduler:* events
- server.ts: wire service into route context + 30s background tick loop
- test/scheduler-time.test.ts: 14 unit tests for next-run calculations

Phase 1 discovery recorded in SCHEDULER_DISCOVERY.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rp7JhmQXcYJhmxFMdZuuah
2026-06-27 08:10:40 +05:30
Codeman maintainer abb6447f66 chore: version packages
Release 1.2.1: fix iOS Safari local echo on keyboard-up tab switches
(selectSession now runs the keyboard-show heal so typed input paints at
the prompt instead of staying invisible/mispositioned until a manual
keyboard toggle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 01:52:32 +02:00
Codeman maintainer cc7c0e5dcb chore: version packages
Release 1.2.0: Gemini run mode, cross-session search, away digest, and
Ralph todo-config (PRs #133–#136), plus review fixes. Also refreshes CLAUDE.md
with the new-feature docs and several audit-verified drift corrections
(MockSession path, ultracode floating-window toggle, route counts, durable
input-delivery layer, mobile image-upload limits).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:27:24 +02:00
Codeman maintainer 368fc20fc2 fix: address PR review findings for Gemini run mode + Ralph todo-config
Gemini (PR #134) blockers:
- runGemini() now unwraps the {success,data} envelope: status check reads
  .data.available, quick-start reads data.data.sessionId (was reading the raw
  shape, so the Run-Gemini button could never start a session).
- setGeminiEnvVars() now uses the socket-scoped ${this.tmux()} setenv instead of
  bare tmux — Gemini/Google auth env vars were targeting the wrong tmux server
  and silently failing on every install.

Gemini parity polish:
- gemini tab-mode badge ('gm') + .tab-mode.gemini CSS; kill-dialog label
  'Kill Tmux & Gemini'; codeman doctor dependency-registry entry; export
  isGeminiAvailable from utils barrel; COLORTERM=truecolor + unset NO_COLOR;
  add gemini to isAltScreenStripMode (Ink TUI, repaints inline like Codex/Claude).
- Revert 4 system-routes.test.ts envelope assertions weakened to
  (body.message ?? body.error) back to (body.success === false).
- Add a runGemini() vm-sandbox test that drives the envelope path end-to-end.

Ralph todo-config (PR #135): maxTodos/todoExpirationMinutes are now persisted
and read back — surfaced via the loopState getter (RalphTrackerState) into
toState()/SSE broadcast and restored in restoreState(), mirroring maxIterations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:26:06 +02:00
Codeman maintainer 9cc310e843 Merge PR #134: Gemini run mode (third external-CLI mode alongside Codex/OpenCode) (COD-36) 2026-06-25 00:10:52 +02:00
Codeman maintainer aa991ece8f Merge PR #133: cross-session search (federated GET /api/search + history-panel search box) (COD-113) 2026-06-25 00:10:38 +02:00
Codeman maintainer 3b4106c349 Merge PR #136: Away digest feature (COD-41) 2026-06-25 00:10:38 +02:00
Codeman maintainer c2867be77f Merge PR #135: Ralph todo-config (maxTodos / todoExpirationMinutes) (COD-79) 2026-06-25 00:10:33 +02:00
Codeman maintainer a1b66f3510 chore: version packages 2026-06-23 23:25:18 +02:00
Codeman maintainer 98ba1fd49c fix(input): stop the connection indicator flashing "Sending 1B…" while typing
The reliable-delivery layer marks every keystroke as briefly pending until its
ACK lands a few ms later, which made the connection indicator flash
"Sending 1B…" on every character during normal typing. Hide the indicator
entirely while the connection is healthy (connected/connecting) — it now only
appears for an actual problem (reconnecting/offline), where the queued-byte
count reassures the user their input is safely buffered.

Verified in a real browser: hidden throughout connected typing, shows
"Offline (NB queued)" when offline, hides again after reconnect+delivery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:24:49 +02:00
Codeman maintainer 9df310c30a chore: version packages 2026-06-23 23:13:55 +02:00
Codeman maintainer 50b8f1d9a0 feat(mobile): large + multi-image uploads from the camera-roll picker
The mobile copy/paste overlay's "🖼 Image" button (and drag-drop / paste)
now handles real-world photo batches:

- Up to 20 images per batch, uploaded with bounded concurrency (3) and a
  live "Uploading N/M…" progress toast; a final summary reports successes,
  any failures, and whether the 20-cap trimmed the selection (no silent
  truncation).
- Per-file upload limit raised 10MB → 50MB (MAX_PASTE_IMAGE_BYTES in
  buffer-limits.ts, env-overridable) so full-resolution phone photos and
  large screenshots aren't rejected.
- Very large images are downscaled to <=4096px longest edge before upload:
  fixes iOS Safari's ~16.7M-px <canvas> limit (which made huge photos fail
  to re-encode and fall back to an original that tripped the magic-byte
  check), and keeps batch uploads fast and small.
- Fix a latent concurrency bug the batch path exposed: the first parallel
  uploads to a session raced on `mkdir(.claude-images)` and the EEXIST
  losers 500'd. mkdir now treats an existing real directory as success
  (re-verifying it isn't a planted symlink), so concurrent uploads succeed.

Verified end-to-end in a real browser (Playwright): downscale, >10MB
server acceptance, 20-cap, 20/20 concurrent uploads landing on disk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:12:41 +02:00
Aamer Akhter 11bacf67a0 fix(mobile): COD-8 hide away-digest header button on phones
The mobile-header-buttons-policy static guard requires every default-visible
header button to make an explicit phone-visibility decision. The new
.btn-away-digest button had none, failing CI. Hide it on phones alongside
.btn-settings / .btn-lifecycle-log — it's a secondary informational control
that doesn't belong on the cramped phone header.
2026-06-20 10:48:52 -04:00
Aamer Akhter 509595b837 COD-52 fix: wire maxTodos + todoExpirationMinutes through ralph-config to the tracker
The Ralph settings modal sent maxTodos/todoExpirationMinutes but RalphConfigSchema
(zod) stripped them and the ralph-config route never applied them, so the inputs
were silent no-ops.

Fix: add both as optional positive-int fields to RalphConfigSchema; destructure
and apply them in the ralph-config route (matching the maxIterations pattern).
RalphTracker had no setters (the values were module constants) — added per-instance
_maxTodos/_todoExpiryMs (defaulting to the same constants, behavior unchanged),
switched the eviction + expiry sites to read them, and added
setMaxTodos/setTodoExpirationMinutes (minutes→ms) + getters.

Test: route test POSTs the two fields and asserts the route applies them to the
tracker. Verified RED (setters not called — fields stripped) → GREEN. 34/34
ralph-routes tests pass; tsc + eslint(src) + prettier + build clean. Frontend
already sent the fields (no change).
2026-06-19 18:00:29 -04:00
Aamer Akhter c95e94e4cb COD-8 add away digest 2026-06-19 17:58:06 -04:00
Aamer Akhter 19139837e4 feat: add Gemini run mode 2026-06-19 13:10:17 -04:00
Aamer Akhter 9afaccc85d COD-9 add cross-session search frontend (history-panel search box) v1
Search box + grouped result cards + filters folded into the welcome/history
panel, wired to GET /api/search. Debounced query (250ms), type-filter chips
(session/event/file), client-side case/status/date filters, grouped cards
(badge, name, timestamp, snippet) with jump-to (session->selectSession,
run-summary->openRunSummary, file-preview->openFilePreview), empty-state +
truncated notice. All result text via textContent (no XSS surface).

Files: index.html (panel markup), terminal-ui.js (search mixin + initSearchPanel),
styles.css (.search-* styles).
2026-06-19 12:35:16 -04:00
Aamer Akhter 95df96e06a COD-9 add cross-session search backend (GET /api/search) v1
Bounded federated search over in-memory stores (sessions/cases, run-summary
events, file paths). Zod-validated query (q 1-200 chars, types csv, limit 1-60),
grouped session->event->file with exact-match-first + recency tiebreak, total
cap 60 + per-group cap 25, snippet cap 200, path-safety (relativePath only).
Frontend search box (history panel) deferred to next cycle; resume/history-prompt
text matching deferred to v1.1 (lives in large on-disk files, out of v1 bounded scope).

New: src/search-service.ts (pure core), src/types/search.ts, src/web/routes/search-routes.ts.
Tests: test/search-service.test.ts (14), test/routes/search-routes.test.ts (10).
2026-06-19 12:35:16 -04:00
Codeman maintainer 1255e28f6f fix(input): durable exactly-once input delivery so a dropped link can't lose a prompt
A "sent" prompt could vanish with no trace on a flaky connection (e.g. a train):
with local echo on, Enter cleared the overlay then sent over the WebSocket
fire-and-forget. On a half-open socket (readyState===OPEN, dead TCP) ws.send()
doesn't throw, so the frame was silently discarded, nothing was enqueued, and
navigator.onLine stayed true — the prompt was lost and never resent.

Replace the best-effort offline queue with a durable, acknowledged delivery layer:

- Client (app.js): every input frame is recorded with a stable clientId +
  monotonic per-session seq and persisted to localStorage BEFORE delivery, and
  only dropped on a server ACK. Delivered over WS (acked via {t:'ia',seq}) or,
  when the socket is down, POST in seq order (HTTP 2xx = ACK). A 2s sweep
  force-reconnects a WS whose oldest frame is unacked past 4s (half-open sockets
  never recover on their own); on reconnect/reload all pending frames re-deliver.
  Survives reconnects AND page reloads. Connection indicator shows pending count.
- Server: Session.shouldApplyInput(clientId, seq) applies each frame exactly once
  (bounded MRU map); ws-routes + POST /input dedup a redelivered seq but still ACK
  it (200 / {t:'ia'}), so an at-least-once resend can never type the prompt twice.
  Untagged input (curl/legacy) applies unconditionally — no behavior change.
- terminal-ui.js sendInput() (voice / keyboard-accessory / paste) now routes
  through the same durable layer.

Tests: test/reliable-input-dedup.test.ts (exactly-once semantics on the real
Session) + POST /input dedup route tests. Design: docs/reliable-input-delivery.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:58:40 +02:00
Codeman maintainer 9d12fc7f94 feat(gesture): hand-drag subagent & ultracode windows in the gesture beta
Pinch any floating subagent or ultracode run/transcript window with the
camera hand-tracking overlay and move it anywhere. Adds a 'window' grab
kind to entry.ts, slotted into the pinch priority chain
(cg-float panel → agent window → session tab → toolbar button). It moves
the window via its own style.left/top (matching app.js's mouse drag,
incl. bottom:'auto') and calls window.app.updateConnectionLines() so the
glowing connector line to the session tab tracks live — app.js redraws
from fresh rects, so no reach into its internals.

Hardening: el.isConnected guard (ultracode windows tear down mid-grab on
SSE reconnect / auto-close), all window.app calls optional-chained +
try/caught so the standalone playground still works, bring-to-front via
app.js's own z-counters, rAF-coalesced redraws cleared on drop so the
final placement always redraws.

Rebuilt the committed gesture-codeman.js bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:01:04 +02:00
Codeman maintainer 5d406c9705 chore: version packages 2026-06-19 15:31:57 +02:00
Codeman maintainer a8782b364f fix(security): harden remaining inline onclick handlers against XSS double-context
Extends PR #132 (ultracode handlers) to the rest of the frontend. The same
JS-string-in-HTML-attribute pattern — '${escapeHtml(value)}' — remained in 32
more inline handlers across app.js, panels-ui.js, session-ui.js,
subagent-windows.js, and notification-manager.js. The browser HTML-decodes the
attribute value before parsing the handler source, so escapeHtml's &#39; reverts
to ' and a quote-bearing id/path/name breaks out of the JS string literal into
executable code.

Switch all to escapeHtml(JSON.stringify(value)): JSON.stringify JS-encodes and
quote-wraps first, then escapeHtml handles the HTML-attribute layer, so the
value round-trips as one inert string argument.

Also fixes two non-escapeHtml variants of the same class:
- panels-ui.js: mux-session `sid` was pre-escaped with escapeHtml() then dropped
  into a single-quoted JS string (selectSession / killMuxSession). Now
  JSON.stringify'd at the source.
- orchestrator-panel.js: phase.id was interpolated raw (no escaping at all) into
  orchestratorSkipPhase / orchestratorRetryPhase. Now escapeHtml(JSON.stringify()).

The most realistic vector here is file paths (panels-ui openLogViewerWindow) —
filenames can legally contain a single quote.

Numeric interpolations (${i+1}, ${index}, ${item.version}) and the
developer-literal ${onclick} in orchestrator-panel are not user data and are
left as-is. Verified: 0 vulnerable patterns remain, all 22 frontend files parse
(check:frontend-syntax + node --check), and a runtime round-trip confirms the
injection that fired under the old pattern is now an inert string argument.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:26:42 +02:00
Ark0N d8da1bd3ff Merge pull request #132 from aakhter/cod-127-xss-ultracode-handlers
Harden ultracode inline onclick handlers against XSS
2026-06-19 15:10:28 +02:00
Aamer Akhter 06871eb7e3 Harden ultracode inline onclick handlers against XSS
The ultracode run/agent cards and minimized-tab badges built inline onclick
handlers by interpolating escapeHtml(value) inside single-quoted JavaScript
strings within an HTML attribute:

    onclick="app.openUltracodeAgentWindow('${escapeHtml(agentId)}', ...)"

escapeHtml maps ' -> &#39;, but the browser HTML-decodes the attribute value
before the handler source is parsed, so &#39; becomes a literal ' again and a
quote in a run/agent/session id breaks out of the string literal into
executable JS. escapeHtml alone is insufficient for the JS-string-within-HTML-
attribute double context.

Switch each handler to 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 an inert string argument. This
matches the encoding already used by other handlers in these files.

Affected:
- ultracode-panel.js: selectWorkflowRun, openUltracodeAgentWindow
- ultracode-windows.js: restore/dismiss for minimized run and agent tabs
2026-06-19 08:55:24 -04:00
Codeman maintainer 5d59c1764d feat(ultracode): in-page agent transcript windows + minimize-to-tab (1.1.14)
Clicking an agent card opens its live transcript as an in-page connected
floating window instead of a detached browser popup. The "−" button on both
run and agent windows now minimizes into the originating session tab as a
restorable ULTRA badge (🧬 runs, 📄 transcripts). Removes the old
collapse-to-header behavior.

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

+282
View File
@@ -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.
+72
View File
@@ -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.
+246
View File
@@ -0,0 +1,246 @@
# Remote Sessions (SSH)
Codeman can run a session's agent on a **remote host over SSH** instead of the
local machine. The agent (Claude, OpenCode, Codex, Gemini, or a plain shell)
runs inside a `tmux` server **on the remote host**, so it survives the SSH
connection dropping; Codeman attaches to it the same way it attaches to a local
managed session.
This document covers the data model, the shell-safe SSH command construction
(COD-107), the durable-launch design (COD-104), and the operational caveats.
For the local session/mux machinery this builds on, see the **Mux** and
**Session** entries in `CLAUDE.md` → Architecture.
## Why it exists
A developer box (`AA-DESKTOP`) often needs to drive an agent on another machine —
a NAS, a build server, a host reachable only through a jump box or a
cloudflared SOCKS5 proxy. Rather than wrap `ssh` by hand per host, Codeman
stores reusable **remote hosts** + **remote cases** and reproduces the exact
connection the operator already uses (`ssh-aa-desktop`-style configs:
custom port, identity file, `-J` jump host, `-o ProxyCommand`).
## Data model
Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
| Type | Role |
|------|------|
| `RemoteSshOptions` | The **HOW-to-reach** fields, shared by host + session: `identityFile`, `socksProxy` (`host:port`), `jumpHost` (`[user@]host[:port]`), `extraSshOptions` (`KEY=VALUE[]`). Every field optional — all-absent reproduces port-22, default-identity, directly-SSH-able behavior. |
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
| `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini'>` — the modes that can run remotely. |
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
Persistence is two flat JSON arrays in the instance data dir:
- `~/.codeman/remote-hosts.json` — `readRemoteHosts()` / `writeRemoteHosts()`
- `~/.codeman/remote-cases.json` — `readRemoteCases()` / `writeRemoteCases()`
(Paths via `remoteHostsPath()` / `remoteCasesPath()`; both honor `CODEMAN_INSTANCE`
because the config dir is the instance data dir.)
On the live `Session`, the remote rides as `_remote?: SessionRemote`. When
attaching, `resolveMuxAttachCwd()` forces the cwd to `/tmp` for remote sessions —
the local working directory is meaningless on the remote box.
## SSH command construction (COD-107 — the injection surface)
**All** SSH command lines flow through one function so user-controlled fields are
escaped once and the launch + prereq probe can never drift apart:
```ts
// src/remote-hosts.ts
buildSshConnectionArgs(remote: RemoteSshOptions & Pick<RemoteHost, 'port'>): string[]
```
It returns the **ordered leading tokens** of an ssh command line (no `-t`, no
target, no remote command):
```
ssh -o BatchMode=yes
[-p <port>]
[-i <abs-identity>] # ~ / $HOME expanded, then shellescaped
[-J <jumpHost>] # shellescaped, single token
[-o ProxyCommand=nc -X 5 -x <socks> %h %p] # ONE shellescaped -o token
[-o <KEY=VALUE>] … # each extra option, shellescaped
```
Rules that keep this safe — **do not bypass them by hand-building an ssh line elsewhere:**
- **Every** user-controlled value (`-i`, `-J`, `-o`, ProxyCommand) is POSIX
single-quote `shellescape`d (`'…'` with embedded `'\''`). The helper mirrors
the one in `tmux-manager.ts`.
- **`~`/`$HOME` in `identityFile` is expanded at build time** (`expandIdentityPath`),
*before* escaping — ssh does not expand `~` inside `-i`, and the escaped value
never reaches a shell that would.
- **The ProxyCommand is one shellescaped `-o KEY=VALUE` token**, so its spaces and
the `%h`/`%p` placeholders reach ssh as a single argument. `%h %p` survive
verbatim — **ssh** expands them to the real host/port, not the shell.
- **Empty options ⇒ `['ssh', '-o BatchMode=yes']`** (+ `-p` only when set) —
byte-identical to the historical behavior.
Token construction is unit-tested independently of any live connection (see
`test/` for `buildSshConnectionArgs` / `buildRemoteTmuxCheckCommand` cases).
## Durable launch (COD-104)
`buildRemoteLaunchCommand({ mode, remote, sessionId })` in `tmux-manager.ts`
builds the command that launches (or **reattaches** to) the remote session:
```
ssh -o BatchMode=yes -t <connection-args> user@host \
'tmux -L codeman-remote new-session -A -s codeman-ssh-<id8> -c <remotePath> "cd <remotePath> && exec <cli>" \; \
set -t codeman-ssh-<id8> status off \; set -t codeman-ssh-<id8> mouse off \; \
set -t codeman-ssh-<id8> prefix C-q \; set -s escape-time 0 \; \
set -t codeman-ssh-<id8> window-size latest'
```
Key points:
- **`new-session -A -s codeman-ssh-<id8>`** = attach-if-exists-else-create, so a
reconnect (same deterministic `remoteTmuxSessionName(sessionId)` — `codeman-ssh-` +
the first 8 chars of the session id) lands back in
the **same** remote session rather than spawning a duplicate. This is what makes
the remote agent survive an SSH drop. The name deliberately fails
`SAFE_MUX_NAME_PATTERN` so a Codeman running ON the remote host never adopts it.
- **`-L codeman-remote`** = a DEDICATED socket for sessions launched by remote
Codemans, NOT the canonical `-L codeman` socket the remote host's own Codeman
uses. Options are set per-session (`set -t`), never `-g`, so a shared remote
tmux server's other sessions are untouched (#145 hardening). Note the
asymmetry: **discovery/attach (COD-105) target the canonical `-L codeman`
socket** — they join sessions the remote's own Codeman manages, while owned
durable launches live on `-L codeman-remote`.
- **`exec <cli>`** replaces the pane shell with the agent, so the pane PID *is*
the agent. The per-mode command comes from `remote.commands?.[mode]` or
`defaultRemoteCommandForMode(mode)` (`exec claude` / `exec opencode` /
`exec codex` / `exec gemini` / `exec bash -l`).
- The **whole tmux invocation is a single shell-quoted ssh argument**, and the
pane command is independently quoted, so a `remotePath` with spaces is safe.
- Connection options come from the **same `buildSshConnectionArgs(remote)`** as
the prereq probe; `-t` is inserted right after `ssh -o BatchMode=yes`,
preserving historical token order.
### tmux prerequisite probe
Because durable remote sessions require tmux on the remote host,
`checkRemoteTmuxAvailable(host)` runs `command -v tmux` over SSH **before**
creating a remote case/session and returns a structured, never-throwing result:
- empty stdout / non-zero exit → *"remote host `<host>` needs tmux installed for
durable remote sessions"*
- stderr present → *"could not verify tmux on remote host `<host>`: `<stderr>`"*
(a real connection failure, surfaced to the operator)
- success → `{ ok: true, tmuxPath }`
It connects with the **identical** options as the launch
(`buildRemoteTmuxCheckCommand` reuses `buildSshConnectionArgs` and inserts
`-o ConnectTimeout=10`), so a proxied/custom-port/identity host that the launch
can reach also passes the probe (and vice-versa).
**Test-mode short-circuit:** under `VITEST` the probe returns
`{ ok: true, tmuxPath: '(test-mode)' }` without opening a socket — mirroring
`TmuxManager`'s no-op-shell-under-VITEST (`IS_TEST_MODE`). Without it, remote-case
create-path tests would hit a real ~10s ssh timeout. Only the live probe is
skipped; command construction is still asserted by unit tests.
## Ownership: launched vs. discovered-and-attached (COD-105)
COD-104 (above) was Phase 1 — Codeman *launches* a remote session and owns it.
COD-105 is Phase 2 — Codeman can also **discover** `codeman-*` tmux sessions
already running on a remote host (created by the remote's own Codeman or another
instance) and **attach** to one it didn't launch. Ownership decides what happens
when the tab closes.
`SessionRemote.owned` carries this:
- **`owned: true`** (or absent — legacy/COD-104 sessions persisted before this
field) — we launched it via `buildRemoteLaunchCommand` and may explicitly kill it.
- **`owned: false`** — discovered + attached; another Codeman owns the remote
session. `remoteSessionName` holds its existing tmux name. Closing the tab
**detaches**, never kills.
### Discovery
`listRemoteCodemanSessions(host)` lists the remote's `codeman-*` sessions:
- `buildRemoteListSessionsCommand()` runs `tmux -L codeman list-sessions -F "…"`
over SSH (connection args from the shared `buildSshConnectionArgs`, so discovery
connects identically to launch/probe). `2>/dev/null` swallows tmux's "no server
running" stderr.
- `parseRemoteSessionList()` is a **pure, unit-tested** parser. ⚠️ Quirk: the
remote tmux's `-F "…\t…"` format emits the **literal two-character `\t`**, not a
real tab (verified on tmux next-3.7), so the parser splits on `/\\t|\t/` (literal
backslash-t **or** a real tab, for builds that do expand it). It keeps only
`codeman-*` names, coerces types, and skips malformed lines.
- `listRemoteCodemanSessions()` **never throws** — unreachable host / no tmux / no
sessions all map to `[]`. Like the prereq probe, it **no-ops to `[]` under
`VITEST`** so a request path never opens a real ssh connection.
Discovery is **explicit** — the UI has a "Discover existing sessions" button per
host; Codeman never auto-discovers on host select.
### Attach vs. launch selection
`buildRemoteSessionCommand(mode, remote, sessionId)` in `tmux-manager.ts` picks the
remote command line by ownership:
- **`owned === false`** → `buildRemoteAttachCommand(remote, name)` — emits
`ssh … -t … 'tmux -L codeman attach -t <remoteSessionName>'`. It uses **`attach`,
NOT `new-session -A`**, so it only *joins* an existing session and never creates
one.
- **owned (default)** → `buildRemoteLaunchCommand` (the COD-104 path above).
### Detach-not-kill
`TmuxManager.killSession()` has an **early return for non-owned remote sessions**:
it tears down **only the LOCAL pane** holding the ssh client (`tmux -L codeman
kill-session` on *this* host's socket). Killing the local ssh sends SIGHUP to the
remote `tmux attach`, which **detaches** — the durable remote session survives.
The early return is a structural guarantee that **no code path can ever issue a
remote `kill-session` for a session we don't own** — the only `kill-session` run is
on the local socket, which never reaches the remote socket.
## API
Routes are registered in `src/web/routes/case-routes.ts`:
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/remote-hosts` | List saved hosts |
| `POST` | `/api/remote-hosts` | Create a host |
| `PUT` | `/api/remote-hosts/:id` | Update a host |
| `DELETE` | `/api/remote-hosts/:id` | Delete a host |
| `GET` | `/api/remote-hosts/:hostId/sessions` | Discover `codeman-*` sessions on the host (COD-105; `listRemoteCodemanSessions`, never errors) |
| `POST` | `/api/cases/remote-link` | Link a case to a remote host (creates the `RemoteCase`) |
Attaching to a discovered session is a **session-create** path, not a host route:
`POST /api/sessions` accepts `attachRemoteSession: { hostId, remoteSessionName }`
(schema in `schemas.ts`; `remoteSessionName` must match `^codeman-[a-zA-Z0-9._-]+$`),
which `session-routes.ts` turns into a non-owned (`owned: false`) session.
Frontend touchpoints: the remote-host management UI is in `session-ui.js` /
`panels-ui.js`; a remote session is created by picking a remote host/case in the
session-create flow, or via the per-host **"Discover existing sessions"** button →
**Attach** action (creates an `owned: false` session).
## Security notes
- **`identityFile` is a path only — never key bytes.** Codeman stores the path and
passes it to `ssh -i`; the key never enters Codeman's state or the wire.
- The injection surface is the SSH option fields. The single-source
`buildSshConnectionArgs` + `shellescape` discipline (COD-107) is the control —
audit any new code path that constructs an ssh command to route through it
rather than concatenating options inline.
- `BatchMode=yes` means **no interactive password/passphrase prompts** — remote
hosts must be reachable with key-based or agent auth (or an unencrypted key the
agent has loaded). A host needing a passphrase will fail the probe with an ssh
diagnostic rather than hang.
## Related
- `CLAUDE.md` → Architecture → **Remote** row, and the **Remote sessions (SSH)**
Key Pattern.
- `docs/security-architecture.md` — overall network/auth model.
- COD-104 (tmux prereq + durable launch), COD-105 (discover + attach, detach-not-kill ownership), COD-107 (shell-safe connection args).
+33 -2
View File
@@ -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`.
+479 -55
View File
@@ -5,12 +5,22 @@
# Usage: curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
#
# Environment variables:
# CODEMAN_NONINTERACTIVE=1 - Skip all prompts (for CI/automation)
# CODEMAN_NONINTERACTIVE=1 - Skip all prompts and accept their defaults
# (CI/automation). Required for headless runs
# that need system changes (sudo package
# installs, AI CLI download); without it those
# steps abort instead of running silently.
# CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app)
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd/launchd service setup prompt
# CODEMAN_NODE_VERSION - Node.js major version to install (default: 22)
# CODEMAN_REPO_URL - Custom git repository URL (default: upstream Codeman)
# CODEMAN_BRANCH - Git branch to install (default: master)
# CODEMAN_HOST - Preset the network binding and skip the prompt
# (e.g. 0.0.0.0 for LAN access, 127.0.0.1 for
# local-only; interactive default is 0.0.0.0,
# non-interactive default is 127.0.0.1)
# CODEMAN_PASSWORD - Preset the dashboard password (skips the
# password prompt when binding to the network)
set -euo pipefail
@@ -26,6 +36,13 @@ TARGET_NODE_VERSION="${CODEMAN_NODE_VERSION:-22}"
NONINTERACTIVE="${CODEMAN_NONINTERACTIVE:-0}"
SKIP_SYSTEMD="${CODEMAN_SKIP_SYSTEMD:-0}"
# Network binding chosen during install (choose_network_binding). Empty
# BIND_HOST means "not chosen" (e.g. the update path) and falls back to the
# server's own loopback default.
BIND_HOST=""
BIND_PASSWORD=""
BIND_ACK="0"
# puppeteer is a devDependency used only by scripts/browser-comparison.mjs — its
# ~150MB chrome-headless-shell download is never needed to build or run Codeman.
# Skipping it avoids a slow download and a fatal install failure when a prior
@@ -53,6 +70,26 @@ OPENCODE_SEARCH_PATHS=(
"$HOME/bin/opencode"
)
# Codex CLI search paths (from src/utils/codex-cli-resolver.ts)
CODEX_SEARCH_PATHS=(
"$HOME/.codex/bin/codex"
"$HOME/.local/bin/codex"
"/usr/local/bin/codex"
"$HOME/.bun/bin/codex"
"$HOME/.npm-global/bin/codex"
"$HOME/bin/codex"
)
# Gemini CLI search paths (from src/utils/gemini-cli-resolver.ts)
GEMINI_SEARCH_PATHS=(
"$HOME/.gemini/bin/gemini"
"$HOME/.local/bin/gemini"
"/usr/local/bin/gemini"
"$HOME/.bun/bin/gemini"
"$HOME/.npm-global/bin/gemini"
"$HOME/bin/gemini"
)
# ============================================================================
# Color Output
# ============================================================================
@@ -107,16 +144,39 @@ die() {
}
# Security notice — printed at the very end of install/update so it is the last
# thing the user sees (the default loopback bind + how to expose it safely).
# thing the user sees. Adapts to the binding chosen during install; the update
# path (BIND_HOST empty) gets the generic text.
print_security_notice() {
echo ""
echo -e " ${YELLOW}${BOLD}Security:${NC}"
echo -e " Codeman binds ${BOLD}127.0.0.1${NC} (this machine only) — no password needed by default."
echo -e " To reach it from another device, do ONE of:"
echo -e " ${CYAN}•${NC} tailscale serve / cloudflared tunnel ${DIM}(recommended)${NC}, or"
echo -e " ${CYAN}•${NC} ${CYAN}codeman web --host 0.0.0.0${NC} AND set ${CYAN}CODEMAN_PASSWORD${NC}"
echo -e " A non-loopback bind without a password still starts, but warns loudly."
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
if [[ "$BIND_HOST" == "0.0.0.0" && -z "$BIND_PASSWORD" ]]; then
echo -e " ${RED}${BOLD}============================================================${NC}"
echo -e " ${RED}${BOLD} WARNING: NETWORK ACCESS WITHOUT A PASSWORD${NC}"
echo -e " ${RED}${BOLD}============================================================${NC}"
echo -e " ${RED}The dashboard is reachable by EVERY device on your network,${NC}"
echo -e " ${RED}and whoever opens it can run commands as ${BOLD}$USER${NC}${RED} through${NC}"
echo -e " ${RED}your AI agents. Anyone on your Wi-Fi owns this machine.${NC}"
echo ""
echo -e " Fix it by setting a password (takes 30 seconds):"
echo -e " ${CYAN}•${NC} re-run the installer and choose a password, or"
echo -e " ${CYAN}•${NC} add ${CYAN}Environment=CODEMAN_PASSWORD=<yours>${NC} to the service"
echo -e " Or switch back to local-only: ${CYAN}CODEMAN_HOST=127.0.0.1${NC}"
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
elif [[ "$BIND_HOST" == "0.0.0.0" ]]; then
echo -e " ${YELLOW}${BOLD}Security:${NC}"
echo -e " The dashboard is reachable from your network at port 3000 and is"
echo -e " password-protected (user ${BOLD}admin${NC}). Keep that password strong:"
echo -e " whoever logs in can run commands through your agents."
echo -e " For access from OUTSIDE your network, prefer Tailscale or a tunnel."
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
else
echo -e " ${YELLOW}${BOLD}Security:${NC}"
echo -e " Codeman binds ${BOLD}127.0.0.1${NC} (this machine only) — no password needed by default."
echo -e " To reach it from another device, do ONE of:"
echo -e " ${CYAN}•${NC} tailscale serve / cloudflared tunnel ${DIM}(recommended)${NC}, or"
echo -e " ${CYAN}•${NC} ${CYAN}codeman web --host 0.0.0.0${NC} AND set ${CYAN}CODEMAN_PASSWORD${NC}"
echo -e " A non-loopback bind without a password still starts, but warns loudly."
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
fi
echo ""
}
@@ -336,6 +396,62 @@ get_opencode_path() {
done
}
check_codex() {
if command -v codex &>/dev/null; then
return 0
fi
for path in "${CODEX_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
return 0
fi
done
return 1
}
get_codex_path() {
if command -v codex &>/dev/null; then
command -v codex
return
fi
for path in "${CODEX_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
echo "$path"
return
fi
done
}
check_gemini() {
if command -v gemini &>/dev/null; then
return 0
fi
for path in "${GEMINI_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
return 0
fi
done
return 1
}
get_gemini_path() {
if command -v gemini &>/dev/null; then
command -v gemini
return
fi
for path in "${GEMINI_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
echo "$path"
return
fi
done
}
check_cloudflared() {
# Check ~/.local/bin first (matches tunnel-manager.ts resolution order)
if [[ -x "$HOME/.local/bin/cloudflared" ]]; then
@@ -683,17 +799,54 @@ install_cloudflared_suse() {
# Interactive Prompts
# ============================================================================
# `curl | bash` leaves stdin attached to the pipe, so a plain `read` never sees
# the keyboard even though the user is sitting at a terminal. These helpers
# prompt via /dev/tty whenever a real terminal is available, and only fall back
# to defaults when there is genuinely none (CI, truly headless pipes).
has_tty() {
[[ -t 0 ]] && return 0
{ : < /dev/tty; } 2>/dev/null
}
read_reply() {
# read_reply <varname>: read one line from the user's real terminal
if [[ -t 0 ]]; then
read -r "$1"
else
read -r "$1" < /dev/tty
fi
}
read_secret() {
# read_secret <varname>: like read_reply but without echoing (passwords)
if [[ -t 0 ]]; then
read -rs "$1"
else
read -rs "$1" < /dev/tty
fi
echo "" >&2
}
# headless_guard <action>: refuse consequential system changes (sudo package
# installs, third-party curl | bash installers) when nobody can consent, i.e.
# no terminal AND no explicit CODEMAN_NONINTERACTIVE=1 opt-in. Interactive
# runs fall through to their normal prompt; opted-in automation proceeds with
# the prompt defaults as before.
headless_guard() {
local action="$1"
if [[ "$NONINTERACTIVE" == "1" ]] || has_tty; then
return 0
fi
error "No interactive terminal, but the installer would need to: $action."
error "Re-run from a terminal to be prompted, or set CODEMAN_NONINTERACTIVE=1 to approve such steps in automation."
exit 1
}
prompt_yes_no() {
local prompt="$1"
local default="${2:-y}"
if [[ "$NONINTERACTIVE" == "1" ]]; then
[[ "$default" == "y" ]]
return
fi
# Check if stdin is a terminal
if [[ ! -t 0 ]]; then
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
# Non-interactive, use default
[[ "$default" == "y" ]]
return
@@ -708,7 +861,7 @@ prompt_yes_no() {
while true; do
echo -en "${CYAN}$prompt${NC} $yn_hint " >&2
read -r answer
read_reply answer || answer="$default"
answer="${answer:-$default}"
case "$answer" in
[Yy]|[Yy][Ee][Ss]) return 0 ;;
@@ -819,10 +972,135 @@ setup_sc_alias() {
info "Added 'sc' alias for tmux-chooser"
}
# ============================================================================
# Network Binding
# ============================================================================
# Best-effort LAN IP for "open this URL from your phone" hints.
detect_lan_ip() {
local ip=""
if [[ "$(uname -s)" == "Darwin" ]]; then
ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true)
else
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
fi
echo "${ip:-<your-ip>}"
}
# Escape a value for a quoted systemd Environment="KEY=value" assignment.
systemd_env_escape() {
printf '%s' "$1" | sed 's/[\\"]/\\&/g'
}
# Escape a value for embedding in a launchd plist <string>.
xml_escape() {
printf '%s' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g'
}
# Ask how the dashboard should be reachable and set BIND_HOST/BIND_PASSWORD/
# BIND_ACK. Interactive default is network access (0.0.0.0) because that is
# what most installs need; loopback is offered as the safer alternative.
# Non-interactive runs keep the safe loopback default unless CODEMAN_HOST is
# preset. The server binary itself still defaults to 127.0.0.1 either way.
choose_network_binding() {
# Preset via environment: honor it and skip the prompt entirely.
if [[ -n "${CODEMAN_HOST:-}" ]]; then
BIND_HOST="$CODEMAN_HOST"
BIND_PASSWORD="${CODEMAN_PASSWORD:-}"
if [[ "$BIND_HOST" != "127.0.0.1" && -z "$BIND_PASSWORD" ]]; then
BIND_ACK="1"
fi
info "Network binding preset via CODEMAN_HOST: $BIND_HOST"
return 0
fi
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
BIND_HOST="127.0.0.1"
info "Non-interactive install: binding 127.0.0.1 (preset CODEMAN_HOST=0.0.0.0 to override)"
return 0
fi
echo -e " ${BOLD}Network access${NC}"
echo ""
echo -e " How should the Codeman dashboard be reachable?"
echo ""
echo -e " ${CYAN}1)${NC} ${BOLD}Any device on your network${NC} ${DIM}(0.0.0.0)${NC} [default]"
echo -e " Open it straight from your phone or laptop."
echo -e " ${YELLOW}Less safe: set a password so only you control your agents.${NC}"
echo -e " ${CYAN}2)${NC} ${BOLD}This machine only${NC} ${DIM}(127.0.0.1)${NC}"
echo -e " Safest. Reach it remotely via Tailscale or a tunnel."
echo ""
local bind_choice=""
while true; do
echo -en "${CYAN}Choose [1/2] (default 1):${NC} " >&2
read_reply bind_choice || bind_choice="1"
bind_choice="${bind_choice:-1}"
case "$bind_choice" in
1|2) break ;;
*) echo "Please enter 1 or 2." >&2 ;;
esac
done
if [[ "$bind_choice" == "2" ]]; then
BIND_HOST="127.0.0.1"
success "Binding 127.0.0.1 (this machine only)"
return 0
fi
BIND_HOST="0.0.0.0"
if [[ -n "${CODEMAN_PASSWORD:-}" ]]; then
BIND_PASSWORD="$CODEMAN_PASSWORD"
info "Using CODEMAN_PASSWORD from the environment"
return 0
fi
echo ""
local pw="" pw2=""
while true; do
echo -en "${CYAN}Set a dashboard password (recommended; Enter to skip):${NC} " >&2
read_secret pw || pw=""
if [[ -z "$pw" ]]; then
echo ""
warn "Without a password, EVERY device on your network gets full access"
warn "to your agents (they run commands as $USER)."
if prompt_yes_no "Continue WITHOUT a password?" "n"; then
BIND_ACK="1"
break
fi
continue
fi
echo -en "${CYAN}Confirm password:${NC} " >&2
read_secret pw2 || pw2=""
if [[ "$pw" == "$pw2" ]]; then
BIND_PASSWORD="$pw"
success "Password set (login user: admin)"
break
fi
echo "Passwords do not match, try again." >&2
done
return 0
}
# ============================================================================
# Service Setup (Linux systemd / macOS launchd)
# ============================================================================
# Wait briefly for codeman-web.service to report active. A bad node path or a
# busy port makes the unit crash within the first seconds (then sit in
# activating/auto-restart), so a blind "started!" message would be a lie.
verify_systemd_active() {
local attempt
for attempt in 1 2 3; do
sleep 2
if systemctl --user is-active --quiet codeman-web.service 2>/dev/null; then
return 0
fi
done
return 1
}
setup_launchd_service() {
local plist_label="com.codeman.web"
local agent_dir="$HOME/Library/LaunchAgents"
@@ -855,6 +1133,21 @@ setup_launchd_service() {
local node_path
node_path=$(command -v node)
# Binding chosen during install (empty on paths that never asked)
local bind_plist=""
if [[ -n "$BIND_HOST" ]]; then
bind_plist=" <key>CODEMAN_HOST</key>
<string>$BIND_HOST</string>"
if [[ -n "$BIND_PASSWORD" ]]; then
bind_plist+=$'\n'" <key>CODEMAN_PASSWORD</key>
<string>$(xml_escape "$BIND_PASSWORD")</string>"
fi
if [[ "$BIND_ACK" == "1" ]]; then
bind_plist+=$'\n'" <key>CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK</key>
<string>1</string>"
fi
fi
cat > "$agent_plist" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -876,6 +1169,7 @@ setup_launchd_service() {
<string>$HOME</string>
<key>LANG</key>
<string>en_US.UTF-8</string>
$bind_plist
</dict>
<key>WorkingDirectory</key>
<string>$HOME</string>
@@ -895,7 +1189,15 @@ EOF
launchctl load "$agent_plist" 2>/dev/null || true
success "LaunchAgent installed and started"
# launchctl load is silent about many failures: confirm the agent is loaded
sleep 2
if launchctl list "$plist_label" &>/dev/null; then
success "LaunchAgent installed and started"
return 0
fi
warn "LaunchAgent did not load."
warn "Inspect: launchctl list | grep codeman ; tail -20 /tmp/codeman.log"
return 1
}
setup_systemd_service() {
@@ -910,6 +1212,18 @@ setup_systemd_service() {
local node_path
node_path=$(command -v node)
# Binding chosen during install (empty on paths that never asked)
local bind_env=""
if [[ -n "$BIND_HOST" ]]; then
bind_env="Environment=CODEMAN_HOST=$BIND_HOST"
if [[ -n "$BIND_PASSWORD" ]]; then
bind_env+=$'\n'"Environment=\"CODEMAN_PASSWORD=$(systemd_env_escape "$BIND_PASSWORD")\""
fi
if [[ "$BIND_ACK" == "1" ]]; then
bind_env+=$'\n'"Environment=CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1"
fi
fi
# Create service file
cat > "$service_file" << EOF
[Unit]
@@ -924,13 +1238,21 @@ Restart=always
RestartSec=10
Environment=NODE_ENV=production
Environment=PATH=$PATH
$bind_env
[Install]
WantedBy=default.target
EOF
# Reload systemd
systemctl --user daemon-reload
# Reload systemd. A user D-Bus session is required for systemctl --user
# (missing under bare `ssh host 'curl | bash'` provisioning), so detect
# that up front instead of dying mid-setup with a cryptic trap message.
if ! systemctl --user daemon-reload 2>/dev/null; then
warn "systemctl --user is unavailable (no user D-Bus session?); cannot manage user services here."
warn "Unit written to $service_file. From a normal login shell, enable it with:"
warn " systemctl --user daemon-reload && systemctl --user enable --now codeman-web"
return 1
fi
# Enable service
systemctl --user enable codeman-web.service 2>/dev/null || true
@@ -940,10 +1262,17 @@ EOF
loginctl enable-linger "$USER" 2>/dev/null || true
fi
# Start the service immediately
systemctl --user start codeman-web.service 2>/dev/null || true
# (Re)start the service. restart, not start: on a re-run over an existing
# running service, start would be a no-op and leave the OLD build running.
systemctl --user restart codeman-web.service 2>/dev/null || true
success "Systemd service installed and started"
if verify_systemd_active; then
success "Systemd service installed and started"
return 0
fi
warn "codeman-web.service did not become active."
warn "Inspect: systemctl --user status codeman-web ; journalctl --user -u codeman-web -e"
return 1
}
setup_tunnel_service() {
@@ -1027,6 +1356,7 @@ main() {
# Git
info "Checking Git..."
if ! check_git; then
headless_guard "install Git (system package via sudo)"
if prompt_yes_no "Git is not installed. Install it now?"; then
install_dependency "git" "$os" "$distro"
else
@@ -1045,6 +1375,7 @@ main() {
warn "Node.js $node_version is installed but version $MIN_NODE_VERSION+ is required."
fi
headless_guard "install Node.js v$TARGET_NODE_VERSION (system package via sudo)"
if prompt_yes_no "Install Node.js v$TARGET_NODE_VERSION?"; then
install_dependency "node" "$os" "$distro"
@@ -1069,6 +1400,7 @@ main() {
if check_tmux; then
success "tmux is installed"
else
headless_guard "install tmux (system package via sudo)"
if prompt_yes_no "tmux is not installed. Install it now?"; then
install_dependency "tmux" "$os" "$distro"
else
@@ -1076,9 +1408,11 @@ main() {
fi
fi
# AI CLI (at least one required: Claude Code or OpenCode)
# AI CLI (Codeman drives one of: Claude Code, OpenCode, Codex, Gemini)
local has_claude=false
local has_opencode=false
local has_codex=false
local has_gemini=false
info "Checking AI CLI tools..."
if check_claude; then
@@ -1089,28 +1423,39 @@ main() {
has_opencode=true
success "OpenCode found at $(get_opencode_path)"
fi
if check_codex; then
has_codex=true
success "Codex found at $(get_codex_path)"
fi
if check_gemini; then
has_gemini=true
success "Gemini CLI found at $(get_gemini_path)"
fi
if [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then
if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" ]]; then
echo ""
warn "No AI CLI found. Codeman requires at least one: Claude Code or OpenCode."
warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, or Gemini."
headless_guard "install an AI CLI (curl | bash from its vendor)"
echo ""
echo -e " ${BOLD}Which AI CLI would you like to install?${NC}"
echo -e " ${CYAN}1)${NC} Claude Code (Anthropic)"
echo -e " ${CYAN}2)${NC} OpenCode (open-source)"
echo -e " ${CYAN}3)${NC} Both"
echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex or Gemini)"
echo ""
local cli_choice=""
if [[ "$NONINTERACTIVE" == "1" ]] || [[ ! -t 0 ]]; then
# Non-interactive: default to Claude Code
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
# Explicit automation opt-in: default to Claude Code
cli_choice="1"
info "CODEMAN_NONINTERACTIVE=1: defaulting to Claude Code"
else
while true; do
echo -en "${CYAN}Choose [1/2/3]:${NC} " >&2
read -r cli_choice
echo -en "${CYAN}Choose [1/2/3/4]:${NC} " >&2
read_reply cli_choice || { cli_choice="1"; break; }
case "$cli_choice" in
1|2|3) break ;;
*) echo "Please enter 1, 2, or 3." >&2 ;;
1|2|3|4) break ;;
*) echo "Please enter 1, 2, 3, or 4." >&2 ;;
esac
done
fi
@@ -1139,8 +1484,12 @@ main() {
fi
fi
if [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then
die "At least one AI CLI is required. Install manually and re-run the installer."
if [[ "$cli_choice" == "4" ]]; then
warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive."
info "Install one later, e.g.: npm install -g @openai/codex (Codex)"
info " or: npm install -g @google/gemini-cli (Gemini)"
elif [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then
die "The selected AI CLI failed to install. Install one manually and re-run the installer."
fi
fi
@@ -1236,6 +1585,16 @@ main() {
fi
fi
# ========================================================================
# Mark install complete
# ========================================================================
# The dispatcher at the bottom only routes a bare re-run to the quiet
# update path when this marker exists, so an aborted first install
# (failed npm install/build, Ctrl+C) re-runs the full setup flow
# (symlinks, PATH, launch menu) instead of silently "updating".
date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete"
# ========================================================================
# Launch Options
# ========================================================================
@@ -1246,6 +1605,11 @@ main() {
echo -e "${GREEN}${BOLD}============================================================${NC}"
echo ""
# Ask how the dashboard should be reachable BEFORE the launch menu, so the
# service files and the run-now path all inherit the choice.
choose_network_binding
echo ""
local launch_choice=""
local has_service=false
local service_type=""
@@ -1269,12 +1633,13 @@ main() {
echo -e " ${CYAN}3)${NC} Don't start — I'll run it later"
echo ""
if [[ "$NONINTERACTIVE" == "1" ]] || [[ ! -t 0 ]]; then
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
launch_choice="3"
info "No interactive terminal detected: not starting (run 'codeman web' when ready)"
else
while true; do
echo -en "${CYAN}Choose [1/2/3]:${NC} " >&2
read -r launch_choice
read_reply launch_choice || { launch_choice="3"; break; }
case "$launch_choice" in
1|2|3) break ;;
*) echo "Please enter 1, 2, or 3." >&2 ;;
@@ -1289,12 +1654,13 @@ main() {
echo -e " ${CYAN}2)${NC} Don't start — I'll run it later"
echo ""
if [[ "$NONINTERACTIVE" == "1" ]] || [[ ! -t 0 ]]; then
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
launch_choice="2"
info "No interactive terminal detected: not starting (run 'codeman web' when ready)"
else
while true; do
echo -en "${CYAN}Choose [1/2]:${NC} " >&2
read -r launch_choice
read_reply launch_choice || { launch_choice="2"; break; }
case "$launch_choice" in
1) break ;;
2) break ;;
@@ -1310,14 +1676,16 @@ main() {
# Handle service setup
if [[ "$launch_choice" == "2" ]]; then
local service_ok=true
if [[ "$service_type" == "launchd" ]]; then
setup_launchd_service
setup_launchd_service || service_ok=false
else
setup_systemd_service
setup_systemd_service || service_ok=false
fi
# Offer tunnel service if cloudflared is available (Linux only — systemd tunnel service)
if [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
# Offer tunnel service if cloudflared is available (Linux only: systemd tunnel service).
# Skipped when service setup failed: it needs the same systemctl --user access.
if [[ "$service_ok" == "true" ]] && [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
echo ""
if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then
setup_tunnel_service
@@ -1325,10 +1693,20 @@ main() {
fi
echo ""
echo -e " ${GREEN}${BOLD}Codeman is running now!${NC}"
echo ""
echo -e " ${CYAN}# Open in browser${NC}"
echo -e " http://localhost:3000"
if [[ "$service_ok" == "true" ]]; then
echo -e " ${GREEN}${BOLD}Codeman is running now!${NC}"
echo ""
echo -e " ${CYAN}# Open in browser${NC}"
if [[ "$BIND_HOST" == "0.0.0.0" ]]; then
echo -e " http://$(detect_lan_ip):3000 ${DIM}(any device on your network)${NC}"
echo -e " http://localhost:3000 ${DIM}(this machine)${NC}"
else
echo -e " http://localhost:3000"
fi
else
echo -e " ${YELLOW}${BOLD}The service was set up but is not running yet${NC} (see warnings above)."
echo -e " ${DIM}You can always run it directly:${NC} ${CYAN}codeman web${NC}"
fi
echo ""
echo -e " ${BOLD}Manage the service:${NC}"
echo ""
@@ -1349,11 +1727,23 @@ main() {
if [[ "$launch_choice" != "2" ]]; then
echo -e " ${BOLD}Quick Start:${NC}"
echo ""
echo -e " ${CYAN}codeman web${NC} # Start the web server"
echo -e " ${CYAN}codeman web --https${NC} # With HTTPS (for remote access)"
echo ""
echo -e " ${CYAN}# Open in browser${NC}"
echo -e " http://localhost:3000"
if [[ "$BIND_HOST" == "0.0.0.0" ]]; then
if [[ -n "$BIND_PASSWORD" ]]; then
echo -e " ${CYAN}CODEMAN_HOST=0.0.0.0 CODEMAN_PASSWORD='<your-password>' codeman web${NC}"
else
echo -e " ${CYAN}CODEMAN_HOST=0.0.0.0 codeman web${NC}"
fi
echo -e " ${DIM}(a bare 'codeman web' binds 127.0.0.1, this machine only)${NC}"
echo ""
echo -e " ${CYAN}# Open in browser${NC}"
echo -e " http://$(detect_lan_ip):3000 ${DIM}(any device on your network)${NC}"
else
echo -e " ${CYAN}codeman web${NC} # Start the web server"
echo -e " ${CYAN}codeman web --https${NC} # With HTTPS (for remote access)"
echo ""
echo -e " ${CYAN}# Open in browser${NC}"
echo -e " http://localhost:3000"
fi
echo ""
fi
@@ -1377,10 +1767,12 @@ main() {
echo -e " https://github.com/Ark0N/Codeman"
echo ""
if ! check_claude && ! check_opencode; then
if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini; then
echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:"
echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code"
echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode"
echo -e " ${CYAN}npm install -g @openai/codex${NC} # Codex"
echo -e " ${CYAN}npm install -g @google/gemini-cli${NC} # Gemini"
echo ""
fi
@@ -1400,6 +1792,11 @@ main() {
# Source profile to pick up PATH changes, then exec codeman
# shellcheck disable=SC1090
source "$profile" 2>/dev/null || true
if [[ -n "$BIND_HOST" ]]; then
export CODEMAN_HOST="$BIND_HOST"
[[ -n "$BIND_PASSWORD" ]] && export CODEMAN_PASSWORD="$BIND_PASSWORD"
[[ "$BIND_ACK" == "1" ]] && export CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1
fi
exec node "$INSTALL_DIR/dist/index.js" web
fi
}
@@ -1412,10 +1809,26 @@ update() {
info "Updating Codeman..."
cd "$INSTALL_DIR"
git remote set-url origin "$REPO_URL" 2>/dev/null || true
# Never blow away local changes silently (this used to be an unconditional
# reset --hard). Interactive users get a choice; headless runs auto-stash
# so the changes stay recoverable, the same policy as scripts/self-update.sh.
if ! git diff --quiet 2>/dev/null || ! git diff --staged --quiet 2>/dev/null; then
warn "Local changes detected in $INSTALL_DIR"
if prompt_yes_no "Stash local changes and update? (recover with: git stash pop)"; then
git stash push --quiet -m "codeman-installer auto-stash $(date -u +%Y-%m-%dT%H:%M:%SZ)"
info "Local changes stashed (see 'git stash list' in $INSTALL_DIR)"
else
info "Keeping local changes; update skipped."
return 0
fi
fi
git fetch --quiet origin
git reset --hard "origin/$BRANCH" --quiet
npm install --quiet --no-fund --no-audit 2>/dev/null || npm install --no-fund --no-audit
npm run build --quiet 2>/dev/null || npm run build
date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete"
success "Updated to $(node -e "console.log(require('./package.json').version)")"
echo ""
@@ -1423,8 +1836,13 @@ update() {
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then
info "Restarting codeman-web service..."
systemctl --user restart codeman-web.service
success "codeman-web service restarted"
systemctl --user restart codeman-web.service 2>/dev/null || true
if verify_systemd_active; then
success "codeman-web service restarted"
else
warn "codeman-web.service did not come back up."
warn "Inspect: systemctl --user status codeman-web ; journalctl --user -u codeman-web -e"
fi
elif [[ -f "$agent_plist" ]]; then
info "Restarting LaunchAgent..."
launchctl unload "$agent_plist" 2>/dev/null || true
@@ -1493,6 +1911,9 @@ uninstall() {
rm -rf "$INSTALL_DIR"
success "Removed $INSTALL_DIR"
else
# Clear the marker so a future installer run does full setup again
# (the symlinks and services being removed here need recreating).
rm -f "$INSTALL_DIR/.install-complete"
info "Kept $INSTALL_DIR"
fi
fi
@@ -1522,7 +1943,10 @@ case "${1:-}" in
update) update ;;
uninstall) uninstall ;;
*)
if [[ -z "${1:-}" && -d "$INSTALL_DIR/.git" ]]; then
# Only a COMPLETED install re-runs as a quiet update. A partial one
# (clone succeeded but build/menu never finished) lacks the marker and
# re-runs the full flow, so a failed first attempt can actually finish.
if [[ -z "${1:-}" && -d "$INSTALL_DIR/.git" && -f "$INSTALL_DIR/.install-complete" ]]; then
print_banner
update
else
+31 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.1.13",
"version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.1.13",
"version": "1.8.0",
"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
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.1.13",
"version": "1.8.0",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
@@ -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",
+138 -5
View File
@@ -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;
+72
View File
@@ -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);
});
+2
View File
@@ -67,6 +67,7 @@ appendFileSync(
// 4. Minify frontend assets
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
@@ -86,6 +87,7 @@ console.log('\n[build] content-hash cache busting');
'styles.css',
'mobile.css',
'constants.js',
'i18n.js',
'mobile-handlers.js',
'voice-input.js',
'notification-manager.js',
+482
View File
@@ -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();
+64 -12
View File
@@ -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
+66
View File
@@ -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;
}
+13 -2
View File
@@ -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
View File
@@ -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')
+21 -5
View File
@@ -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
+8
View File
@@ -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',
+13
View File
@@ -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
// ============================================================================
+63
View File
@@ -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));
}
+13
View File
@@ -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;
+77
View File
@@ -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,
};
}
+32
View File
@@ -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;
}
+668
View File
@@ -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() });
}
}
+82
View File
@@ -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}`;
}
+465
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -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') {
+70
View File
@@ -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);
}
+12
View File
@@ -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
View File
@@ -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;
}
+22 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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, {
+379
View File
@@ -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,
};
}
+184
View File
@@ -0,0 +1,184 @@
/**
* @fileoverview Pure logic for the remote-session auto-reconnect watcher (COD-108).
*
* COD-104 made remote tmux sessions durable + idempotently reattachable, but a
* reconnect only fired at explicit trigger points. COD-108 adds a continuous
* watcher (in `TmuxManager`) that detects a dead remote pane and emits
* `remoteSessionDropped`; `SessionManager`/server then reassembles the respawn
* options and reattaches (re-running the idempotent remote command).
*
* This module holds the SIDE-EFFECT-FREE pieces so they can be unit-tested
* without real tmux:
* - the bounded exponential **backoff schedule** (attempt → delay, capped),
* - the per-session **reconnect state** shape,
* - the **eligibility decision** (`decideReconnect`) given a session + its
* reconnect state + the current time + the guard set.
*
* The watcher in `tmux-manager.ts` owns the live `isPaneDead` probe and the
* timers; everything here is pure and deterministic (time is injected).
*
* @module remote-reconnect
*/
/**
* Bounded exponential backoff delays (ms) between reconnect attempts.
* Attempt N (1-based) waits `BACKOFF_SCHEDULE_MS[N-1]` from the previous emit
* before the next emit is eligible. After the last entry the session is
* considered `reconnect-exhausted` and the watcher stops emitting for it.
*
* 5s, 15s, 45s, 2m, 5m, 5m → ~6 attempts spanning ~13 minutes.
*/
export const BACKOFF_SCHEDULE_MS: readonly number[] = [5_000, 15_000, 45_000, 120_000, 300_000, 300_000];
/** Maximum number of reconnect attempts before exhaustion. */
export const MAX_RECONNECT_ATTEMPTS = BACKOFF_SCHEDULE_MS.length;
/**
* Delay (ms) to wait AFTER emitting attempt `attempt` (1-based) before the next
* attempt is eligible. `attempt <= 0` returns the first delay; an attempt at or
* beyond the cap returns the last delay (callers should check exhaustion via
* {@link isExhausted} rather than relying on this for the stop decision).
*
* Pure — no clock, no I/O.
*/
export function reconnectDelayForAttempt(attempt: number): number {
if (!Number.isFinite(attempt) || attempt <= 1) return BACKOFF_SCHEDULE_MS[0];
const idx = Math.min(Math.floor(attempt) - 1, BACKOFF_SCHEDULE_MS.length - 1);
return BACKOFF_SCHEDULE_MS[idx];
}
/** Whether `attempts` reconnect emits have reached/exceeded the cap. Pure. */
export function isExhausted(attempts: number): boolean {
return attempts >= MAX_RECONNECT_ATTEMPTS;
}
/**
* Per-session reconnect bookkeeping held by the watcher. All time values are
* epoch ms. `inFlight` guards against stacking respawns when a tick fires while
* a previous reattach is still running. `exhaustedEmitted` ensures the
* `remoteReconnectExhausted` event fires at most once per session.
*/
export interface RemoteReconnectState {
/** Number of `remoteSessionDropped` emits so far (advances per emit). */
attempts: number;
/** Earliest time (epoch ms) the next emit is eligible. 0 = eligible now. */
nextEligibleAt: number;
/** A reattach triggered by a prior emit is currently running. */
inFlight: boolean;
/** Cap reached — stop auto-retrying for this session. */
exhausted: boolean;
/** The `remoteReconnectExhausted` SSE event has already been emitted. */
exhaustedEmitted: boolean;
}
/** A fresh reconnect state (no attempts, immediately eligible). Pure. */
export function freshReconnectState(): RemoteReconnectState {
return { attempts: 0, nextEligibleAt: 0, inFlight: false, exhausted: false, exhaustedEmitted: false };
}
/**
* Advance the backoff after an emit at time `now`. Increments `attempts` and
* schedules `nextEligibleAt = now + delay`. Returns a NEW state object (does
* not mutate the input). Pure.
*
* NOTE: this does NOT set `exhausted`. Exhaustion is a decision the watcher
* makes on the FOLLOWING tick (via {@link decideReconnect} → `exhaust`), so the
* `remoteReconnectExhausted` event fires exactly once after the final attempt's
* backoff window elapses — not pre-emptively on the last emit.
*/
export function advanceBackoff(state: RemoteReconnectState, now: number): RemoteReconnectState {
const attempts = state.attempts + 1;
const delay = reconnectDelayForAttempt(attempts);
return {
...state,
attempts,
nextEligibleAt: now + delay,
};
}
/** Reset after a successful reattach — back to a fresh, eligible state. Pure. */
export function resetReconnectState(): RemoteReconnectState {
return freshReconnectState();
}
/** Minimal session view the decision needs (avoids importing MuxSession here). */
export interface ReconnectSessionView {
sessionId: string;
/** Truthy when this is a remote (SSH-wrapped) session. */
isRemote: boolean;
/** Result of `isPaneDead(muxName)` for this session. */
paneDead: boolean;
}
/**
* Decision outcomes for a single watcher tick on one session.
* - `emit` → emit `remoteSessionDropped { sessionId, attempt }`, then
* advance backoff (attempt = the returned `attempt`).
* - `exhaust` → cap reached this tick; emit `remoteReconnectExhausted` once.
* - `skip` → do nothing (not remote / pane alive / guarded / in-flight /
* not yet due / already exhausted).
*/
export type ReconnectAction =
| { kind: 'emit'; attempt: number }
| { kind: 'exhaust' }
| { kind: 'skip'; reason: ReconnectSkipReason };
export type ReconnectSkipReason =
| 'not-remote'
| 'pane-alive'
| 'guarded'
| 'in-flight'
| 'not-due'
| 'exhausted'
| 'disabled';
export interface DecideReconnectInput {
session: ReconnectSessionView;
state: RemoteReconnectState | undefined;
/** Session is in the intentional-teardown guard set (killed/detached/stopping). */
guarded: boolean;
/** Kill-switch: `remoteAutoReconnect` setting. When false, never reconnect. */
enabled: boolean;
now: number;
}
/**
* PURE eligibility decision for one session on one tick. No clock, no I/O — all
* inputs are passed in. The watcher translates the result into emits + state
* transitions.
*
* Order of guards (most-decisive first):
* 1. kill-switch off → skip:disabled
* 2. not a remote session → skip:not-remote
* 3. pane is alive → skip:pane-alive
* 4. intentional teardown guard → skip:guarded (NEVER revive a killed tab)
* 5. a reattach already running → skip:in-flight (no stacked respawns)
* 6. already exhausted → skip:exhausted (one exhaust emit, then quiet)
* 7. cap reached this tick → exhaust
* 8. not yet due (backoff) → skip:not-due
* 9. otherwise → emit (attempt = attempts + 1)
*/
export function decideReconnect(input: DecideReconnectInput): ReconnectAction {
const { session, state, guarded, enabled, now } = input;
if (!enabled) return { kind: 'skip', reason: 'disabled' };
if (!session.isRemote) return { kind: 'skip', reason: 'not-remote' };
if (!session.paneDead) return { kind: 'skip', reason: 'pane-alive' };
// Intentional kill / detach must NEVER be auto-revived.
if (guarded) return { kind: 'skip', reason: 'guarded' };
const s = state ?? freshReconnectState();
// Only one reconnect in flight per session — don't stack respawns.
if (s.inFlight) return { kind: 'skip', reason: 'in-flight' };
if (s.exhausted) return { kind: 'skip', reason: 'exhausted' };
// Cap reached: surface exhaustion once, then go quiet.
if (isExhausted(s.attempts)) return { kind: 'exhaust' };
// Backoff gate — only emit when due.
if (now < s.nextEligibleAt) return { kind: 'skip', reason: 'not-due' };
return { kind: 'emit', attempt: s.attempts + 1 };
}
+207
View File
@@ -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 };
}
+351
View File
@@ -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 };
}
+37 -9
View File
@@ -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;
}
/**
+68
View File
@@ -0,0 +1,68 @@
/**
* @fileoverview Pure helpers for the global session tab-order (COD-131).
*
* Tab order (drag-and-drop reorder + Ctrl+Shift+{/}) is persisted server-side
* so it follows the user across devices. The server is authoritative; the
* browser's localStorage (`codeman-session-order`) is the offline fallback.
*
* These helpers are pure (no IO) so they can be unit-tested in isolation and
* reused by both the PUT /api/session-order route and the StateStore accessor.
*
* - `normalizeSessionOrder` coerces arbitrary input into a clean string[]
* (non-empty strings only, deduped with first occurrence winning).
* - `mergeSessionOrder` lets the pushing device's order win, while preserving
* any server-only ids the pushing device didn't know about — they fall to the
* END in their existing relative order, never dropped.
*/
/**
* Coerce arbitrary input into a clean ordered list of session ids:
* keep only non-empty strings and dedup (first occurrence wins).
*
* @param order - unknown input (expected to be a string[], but defensive)
* @returns a normalized string[] (empty array for non-array / all-junk input)
*/
export function normalizeSessionOrder(order: unknown): string[] {
if (!Array.isArray(order)) {
return [];
}
const seen = new Set<string>();
const result: string[] = [];
for (const entry of order) {
if (typeof entry !== 'string' || entry.length === 0) {
continue;
}
if (seen.has(entry)) {
continue;
}
seen.add(entry);
result.push(entry);
}
return result;
}
/**
* Merge an incoming order from a pushing device with the existing server order.
*
* The incoming order wins; any ids present in `existing` but NOT in `incoming`
* are appended at the END, preserving their relative order. This is the
* "server-only ids the pushing device didn't know about fall to the end, never
* dropped" rule.
*
* Both arguments are normalized first, so callers may pass raw input safely.
*
* @param incoming - the order the pushing device wants
* @param existing - the current server-side order
* @returns the merged, normalized order
*/
export function mergeSessionOrder(incoming: string[], existing: string[]): string[] {
const normalizedIncoming = normalizeSessionOrder(incoming);
const incomingSet = new Set(normalizedIncoming);
const merged = [...normalizedIncoming];
for (const id of normalizeSessionOrder(existing)) {
if (!incomingSet.has(id)) {
merged.push(id);
}
}
return merged;
}
+102
View File
@@ -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;
}
}
+380 -35
View File
@@ -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,22 @@ 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';
}
}
/**
@@ -141,14 +163,15 @@ export function isExternalCliMode(mode: SessionMode): boolean {
* 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 and Claude Code are known, controlled 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.
* 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';
return mode === 'codex' || mode === 'claude' || mode === 'gemini';
}
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
@@ -157,6 +180,8 @@ export function isAltScreenStripMode(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
@@ -191,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.
@@ -270,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);
@@ -304,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;
@@ -351,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
@@ -362,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';
@@ -421,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();
@@ -481,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)
@@ -495,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);
}
@@ -596,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
@@ -904,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;
}
@@ -954,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,
@@ -967,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,
@@ -985,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
@@ -1137,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);
@@ -1149,6 +1275,70 @@ export class Session extends EventEmitter {
return { isRestored };
}
/**
* COD-108 — re-establish a dropped REMOTE session. Triggered by the
* `TmuxManager` remote-reconnect watcher (via `remoteSessionDropped`): the
* watcher detects a dead remote pane, the session owner reassembles the SAME
* `RespawnPaneOptions` used for Claude-idle respawns and calls
* `respawnPane()` directly. For a remote session that re-runs
* `buildRemoteSessionCommand` (owned → `new-session -A`, non-owned →
* `attach`), which idempotently REATTACHES the still-running durable remote
* tmux session — scrollback + agent intact (proven COD-104/105).
*
* Deliberately does NOT route through the Claude-idle respawn-controller —
* this is a transport re-establish, not a `/clear`/`/compact` cycle.
*
* @returns true if the pane was respawned (reattach issued), false otherwise.
*/
async reattachRemote(): Promise<boolean> {
if (!this._remote) return false; // not a remote session
if (!this._useMux || !this._mux || !this._muxSession) return false;
const mux = this._mux;
// If tmux lost the whole session (not just a dead pane), there is nothing to
// respawn into — a genuine death, leave it for normal recovery/reconcile.
if (!mux.muxSessionExists(this._muxSession.muxName)) {
console.log('[Session] reattachRemote: mux session gone, skipping:', this._muxSession.muxName);
return false;
}
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
if (!newPid) {
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
return false;
}
console.log('[Session] reattachRemote: reattached remote session', this._muxSession.muxName, 'pid', newPid);
return true;
}
/**
* Assemble the {@link RespawnPaneOptions} for this session. Single source of
* truth shared by interactive start, shell start (via their inline copies),
* and {@link reattachRemote} so the remote reattach path can never drift from
* the spawn path.
*/
private _buildRespawnPaneOptions(): import('./mux-interface.js').RespawnPaneOptions {
return {
sessionId: this.id,
workingDir: this.workingDir,
mode: this.mode,
niceConfig: this._niceConfig,
model: this._model,
claudeMode: this._claudeMode,
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
geminiConfig: this._geminiConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
owner: this._owner,
};
}
private _handleTerminalOutput(data: string): void {
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
@@ -1196,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
@@ -1222,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,
@@ -1258,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',
});
@@ -1331,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
@@ -1366,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}`);
@@ -1454,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';
@@ -1481,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
@@ -1594,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,
@@ -1602,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',
});
@@ -1729,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, {
@@ -2208,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.
*
@@ -2230,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);
}
+85
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+51 -8
View File
@@ -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) */
+39
View File
@@ -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 ==========
+7
View File
@@ -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 ==========
+103
View File
@@ -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;
}
+24
View File
@@ -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;
}
+2
View File
@@ -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';
+4
View File
@@ -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 */
+77
View File
@@ -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
View File
@@ -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;
}
/**
+64
View File
@@ -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;
}
+488
View File
@@ -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' });
}
+41 -1
View File
@@ -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;
}
+67
View File
@@ -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
View File
@@ -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';
+1 -1
View File
@@ -60,7 +60,7 @@ export function stripAnsi(text: string): string {
*/
export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/;
export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_/\-. ~]+$/;
export const SAFE_PATH_PATTERN = /^[\p{L}\p{N}_/\-. ~]+$/u;
/**
* Execute a global regex pattern against data, calling the callback for each match.
+28
View File
@@ -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 */
}
}
+379
View File
@@ -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,
};
}
+82
View File
@@ -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})`)));
});
})
);
}
+93
View File
@@ -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
View File
@@ -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']);
+12
View File
@@ -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;
}
+13
View File
@@ -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 {
+3 -1
View File
@@ -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;
+10
View File
@@ -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;
}
+1
View File
@@ -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';
+4 -1
View File
@@ -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>;
}
+557
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 };
})();
+1057 -163
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -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',
};
// ═══════════════════════════════════════════════════════════════
+328
View File
@@ -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');
}
},
});
+87 -4
View File
@@ -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;
+957
View File
@@ -0,0 +1,957 @@
/**
* @fileoverview Dependency-free browser localization and user-facing branding.
*
* English remains the canonical source language. The translator covers the static
* application shell plus DOM content inserted later by the plain-JS UI modules.
* It deliberately skips terminal/file/response/user-name surfaces so user content
* is never mistaken for application copy. Missing entries fall back to English.
*
* @dependency none (loads after constants.js, before all UI modules)
* @loadorder 1.5 of 16
*/
(function initCodemanI18n(global) {
'use strict';
const DEFAULT_NAME = 'Codeman';
const SUPPORTED_LANGUAGES = new Set(['en', 'zh-CN']);
const TRANSLATABLE_ATTRIBUTES = ['title', 'aria-label', 'placeholder'];
const SKIP_SELECTOR = [
'[data-i18n-skip]',
'.xterm',
'.terminal-container',
'.terminal-output',
'.response-content',
'.response-viewer-content',
'.file-preview-content',
'.session-tab-name',
'.session-name',
'.case-name',
'.notif-item-message',
'pre',
'code',
'script',
'style',
'textarea',
].join(',');
const USER_TEXT_SELECTOR = [
'.history-item-title',
'.history-item-subtitle',
'.history-detail-prompt',
'.history-detail-path',
'.folder-history-subtitle',
].join(',');
// Exact English-source translations. Technical names, command examples, model
// names, keyboard chords, and user-authored content intentionally stay unchanged.
const ZH_CN = Object.freeze({
'Skip to terminal': '跳转到终端',
'Go to main page': '返回主页',
'Session tabs': '会话标签页',
'Admin Panel': '管理面板',
'Open admin panel': '打开管理面板',
'Re-dock to dashboard (close window)': '重新停靠到主界面(关闭窗口)',
'Tunnel status': '隧道状态',
'Decrease font size': '减小字体',
'Increase font size': '增大字体',
'Current font size': '当前字体大小',
'System resource usage': '系统资源使用情况',
'Redraw terminal': '重绘终端',
'Redraw terminal to fit current screen (Ctrl+Shift+R)': '重绘终端以适应当前屏幕(Ctrl+Shift+R)',
'View last response': '查看最近一次回复',
'Away Digest': '离开期间摘要',
'Open away digest': '打开离开期间摘要',
'Session Manager': '会话管理器',
'Session actions': '会话操作',
'Open session manager': '打开会话管理器',
Attachments: '附件',
'Open attachment history': '打开附件历史',
'File Viewer': '文件查看器',
'Open file viewer': '打开文件查看器',
'Open Codeman across all displays': '在所有显示器上打开 {name}',
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
Notifications: '通知',
'Toggle notifications': '切换通知面板',
'Session Lifecycle Log': '会话生命周期日志',
'Open session lifecycle log': '打开会话生命周期日志',
'App Settings': '应用设置',
'Open app settings': '打开应用设置',
'Total tokens across all sessions': '所有会话的 Token 总数',
'Token usage across active sessions': '活动会话的 Token 使用量',
'Instance count': '实例数量',
'No response yet': '暂无回复',
'No response yet — send a message in this session first.': '暂无回复,请先在此会话中发送一条消息。',
'Last Response': '最近一次回复',
More: '更多',
'Codeman version': '{name}版本',
Stop: '停止',
Watching: '监视中',
Orchestrator: '编排器',
Close: '关闭',
'Close window': '关闭窗口',
'Session unavailable': '会话不可用',
'This session has ended or is no longer available.': '此会话已结束或不再可用。',
// Welcome / quick start / common actions
'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。',
'Select case': '选择案例',
'Select Case': '选择案例',
'All cases': '全部案例',
'No directory': '未选择目录',
Run: '运行',
'Run Claude Code': '运行 Claude Code',
'Run OpenCode': '运行 OpenCode',
'Run Gemini': '运行 Gemini',
'Run Shell': '运行 Shell',
'Select AI backend': '选择 AI 后端',
'Create New Case': '新建案例',
'Create new case': '新建案例',
'Link Existing': '关联现有目录',
'Add Case': '添加案例',
'Open sessions': '打开会话',
'Recent Sessions': '最近会话',
'Search sessions by name, prompt, or path…': '按名称、提示词或路径搜索会话…',
'Search open sessions or start a new one': '搜索已打开会话或启动新会话',
'Find Open Session': '查找已打开会话',
'No background agents': '没有后台智能体',
'No background agents detected': '未检测到后台智能体',
'No notifications': '没有通知',
'No mux sessions': '没有 mux 会话',
'No lifecycle entries found': '未找到生命周期记录',
'No ultracode runs detected': '未检测到 Ultracode 运行',
// Global/common controls
Display: '显示',
'Claude CLI': 'Claude CLI',
'Codex CLI': 'Codex CLI',
Models: '模型',
Shortcuts: '快捷键',
Voice: '语音',
Save: '保存',
Cancel: '取消',
Apply: '应用',
Create: '创建',
Add: '添加',
Delete: '删除',
Remove: '移除',
Edit: '编辑',
Refresh: '刷新',
Back: '返回',
Next: '下一步',
Previous: '上一步',
Clear: '清除',
'Clear all': '全部清除',
'Clear All': '全部清除',
Search: '搜索',
Filter: '筛选',
Enable: '启用',
Enabled: '已启用',
Disabled: '已禁用',
Active: '活动',
'Not active': '未活动',
On: '开',
Off: '关',
Yes: '是',
No: '否',
Optional: '可选',
Default: '默认',
Custom: '自定义',
Name: '名称',
Description: '描述',
Status: '状态',
Reason: '原因',
Time: '时间',
Event: '事件',
Events: '事件',
Session: '会话',
Sessions: '会话',
Files: '文件',
History: '历史',
Summary: '摘要',
Details: '详情',
Options: '选项',
Settings: '设置',
Help: '帮助',
Loading: '正在加载',
Error: '错误',
Errors: '错误',
Warning: '警告',
Warnings: '警告',
Info: '信息',
Complete: '完成',
Completed: '已完成',
Stopped: '已停止',
Running: '运行中',
Idle: '空闲',
Working: '工作中',
Today: '今天',
Home: '主页',
Local: '本地',
Remote: '远程',
Docker: 'Docker',
Terminal: '终端',
Prompt: '提示词',
Source: '来源',
Type: '类型',
Language: '语言',
// Display settings
'Branding & Language': '品牌与语言',
'Display Name': '显示名称',
'Interface Language': '界面语言',
'Name shown in the browser UI and window title. Supports Unicode, including Chinese.':
'显示在浏览器界面和窗口标题中的名称。支持 Unicode,包括中文。',
'Language for this device. Dynamic status messages and dialogs use the same language.':
'此设备使用的界面语言。动态状态消息与对话框也会使用同一语言。',
English: 'English',
Appearance: '外观',
Skin: '皮肤',
'Visual theme for this device (not synced)': '此设备的视觉主题(不同步)',
'Daylight Blue': '日光蓝',
'Daylight Green': '日光绿',
'OG Codeman': '经典 {name}',
Performance: '性能',
'WebGL Renderer': 'WebGL 渲染器',
'Header Displays': '顶部栏显示',
'Font Controls': '字体控制',
'System Stats': '系统状态',
'Lifecycle Log': '生命周期日志',
'Response Viewer': '回复查看器',
'Attachments Button': '附件按钮',
'Multi-monitor Button': '多显示器按钮',
'Session Manager Button': '会话管理器按钮',
'Away Digest Button': '离开期间摘要按钮',
'Cron Button': '定时任务按钮',
'Redraw Terminal Button': '重绘终端按钮',
'Tab Bar': '标签栏',
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
Panels: '面板',
Monitor: '监视器',
'Project Insights': '项目洞察',
'File Browser': '文件浏览器',
Subagents: '子智能体',
'Ultracode Agents': 'Ultracode 智能体',
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
'Subagent Options': '子智能体选项',
'Enable Tracking': '启用跟踪',
'Active Tab Only': '仅活动标签页',
'Image Watcher': '图像监视器',
'Enable Globally': '全局启用',
'Remote Access': '远程访问',
'Cloudflare Tunnel': 'Cloudflare 隧道',
'Tunnel URL': '隧道地址',
'Upload URL': '上传地址',
Updates: '更新',
'Current Version': '当前版本',
'Check for Updates': '检查更新',
'Check now': '立即检查',
'Update available': '有可用更新',
'Update now': '立即更新',
'Show CPU and memory usage in header': '在顶部栏显示 CPU 与内存使用情况',
'Show session lifecycle log button in header': '在顶部栏显示会话生命周期日志按钮',
'Show the response viewer (eye) button in header': '在顶部栏显示回复查看器(眼睛)按钮',
'Show the file viewer button in header (opens the file browser panel for the active session)':
'在顶部栏显示文件查看器按钮(打开当前会话的文件浏览器面板)',
'Show the attachments button in header (opens the attachment history drawer)':
'在顶部栏显示附件按钮(打开附件历史抽屉)',
'Show the multi-monitor button in the header (opens Codeman spanned across all displays)':
'在顶部栏显示多显示器按钮(跨所有显示器打开 {name})',
'Show the session manager button in the header (opens the session manager — sessions also stay reachable via the Ctrl+K palette)':
'在顶部栏显示会话管理器按钮(也可通过 Ctrl+K 面板访问会话)',
"Show the away digest button in the header (opens the 'what happened while you were away' summary)":
'在顶部栏显示离开期间摘要按钮',
'Show the Cron button in the footer toolbar (opens the cron jobs manager)': '在底部工具栏显示定时任务按钮',
'Show a terminal redraw button in the header — refit the terminal to the current screen size (useful when switching between devices)':
'在顶部栏显示终端重绘按钮,以重新适配当前屏幕大小',
'Show folder path below tab name and allow tab bar to wrap into multiple rows':
'在标签名称下显示文件夹路径,并允许标签栏换行',
'Show Monitor panel at bottom right': '在右下角显示监视器面板',
'Show active tools and file viewers in a floating panel': '在浮动面板中显示活动工具与文件查看器',
'Show file browser panel on the right side': '在右侧显示文件浏览器面板',
'Show the Subagents panel (independent from Monitor)': '显示子智能体面板(独立于监视器)',
'Monitor Claude Code background agents in real-time': '实时监视 Claude Code 后台智能体',
'Only show subagent windows when their parent tab is selected': '仅在选中父标签页时显示子智能体窗口',
'Automatically detect and popup new images in session directories': '自动检测并弹出会话目录中的新图像',
'Expose Codeman via Cloudflare Tunnel for remote access': '通过 Cloudflare 隧道远程访问 {name}',
'Codeman version currently running': '当前运行的{name}版本',
'Check GitHub for a newer Codeman release': '检查 GitHub 上是否有新版 {name}',
// Input settings
Input: '输入',
'Local Echo': '本地回显',
'CJK Input': '中日韩输入',
'Extended Keyboard Bar': '扩展键盘栏',
'Gesture Control (beta)': '手势控制(测试版)',
'Wheel Scrolls Local History': '滚轮滚动本地历史',
'Instant typing feedback with local echo': '通过本地回显即时显示输入',
'Dedicated IME input field for CJK languages': '为中日韩语言提供专用输入法文本框',
'Extra keys: Tab, Esc, arrows, Ctrl+O': '附加按键:Tab、Esc、方向键、Ctrl+O',
// CLI / model settings
'Startup Mode': '启动模式',
'Skip Permissions (default)': '跳过权限确认(默认)',
'Auto (classifier-guarded, low prompts)': '自动(分类器保护,较少提示)',
'Normal (with prompts)': '普通(显示提示)',
'Allowed Tools Only': '仅允许指定工具',
'Allowed Tools': '允许的工具',
'Comma-separated list of tools to allow': '以逗号分隔允许使用的工具',
'Enable Ralph / Todo Tracker': '启用 Ralph / 待办跟踪器',
'Claude Permissions': 'Claude 权限',
'Agent Teams': '智能体团队',
'Claude Model': 'Claude 模型',
'1M Opus Context': 'Opus 100 万上下文',
'Remote auto-reconnect': '远程自动重连',
'Thinking Effort': '思考强度',
Low: '低',
Medium: '中',
High: '高',
Max: '最高',
'Nice Priority': 'Nice 优先级',
'Enable Nice Priority Reduction': '启用 Nice 优先级调整',
'Nice Value': 'Nice 值',
'Bypass Approvals and Sandbox': '绕过审批与沙箱',
'Default Model': '默认模型',
'Show Optimizer Recommendations': '显示优化器建议',
'Agent Type Overrides': '按智能体类型覆盖',
'Use Default': '使用默认值',
// Notifications / voice / shortcuts
'Enable Notifications': '启用通知',
'Master toggle for all notification layers': '所有通知层的总开关',
'Browser Notifications': '浏览器通知',
'Audio Alerts': '声音提醒',
'Push Notifications': '推送通知',
'Notification Levels': '通知级别',
Critical: '严重',
'Per-Event Settings': '按事件设置',
'Permission prompts': '权限提示',
'Questions from Claude': 'Claude 提问',
'Session idle': '会话空闲',
'Response complete': '回复完成',
'Respawn cycles': '重生循环',
'Task complete': '任务完成',
'Subagent activity': '子智能体活动',
Browser: '浏览器',
Audio: '声音',
Push: '推送',
'Voice Input': '语音输入',
Provider: '服务商',
'Active Provider': '当前服务商',
'API Key': 'API 密钥',
'Domain Keywords': '领域关键词',
'Input Mode': '输入模式',
'Direct to input': '直接输入',
'Compose dialog': '编辑对话框',
'Keyboard Shortcuts': '键盘快捷键',
'Customize keyboard shortcuts. Click the binding to capture a new key combination.':
'自定义键盘快捷键。点击按键组合即可录入新的组合。',
'Show Shortcuts': '显示快捷键',
'Full shortcut reference': '完整快捷键参考',
// Session/case dialogs
'Session Options': '会话选项',
'Session Name': '会话名称',
'Session Color': '会话颜色',
'Working Directory': '工作目录',
'Set working directory': '设置工作目录',
'Resume Conversation': '继续对话',
'Close Session': '关闭会话',
'Choose how to close': '选择关闭方式',
'Tmux session keeps running in background': 'Tmux 会话继续在后台运行',
'Terminate the session completely': '彻底终止会话',
'Cancel close session': '取消关闭会话',
'Case Name': '案例名称',
'Folder Path': '文件夹路径',
'Default Working Directory': '默认工作目录',
'Default directory for new sessions.': '新会话的默认目录。',
'Default CLAUDE.md Template': '默认 CLAUDE.md 模板',
'Used when creating new cases. Leave empty for built-in template.': '创建新案例时使用;留空则使用内置模板。',
'Remote Path': '远程路径',
'SSH Host/IP': 'SSH 主机/IP',
'SSH Username': 'SSH 用户名',
'SSH Port': 'SSH 端口',
'Identity File': '身份文件',
'Jump Host': '跳板主机',
'Advanced SSH': '高级 SSH',
'Discover existing sessions': '发现现有会话',
'Workspace Path': '工作区路径',
'Container settings (optional, sensible defaults)': '容器设置(可选,默认值合理)',
Template: '模板',
Network: '网络',
CPUs: 'CPU 数',
Memory: '内存',
GPUs: 'GPU',
// Cron / lifecycle / panels
'Cron Jobs': '定时任务',
'+ New Job': '+ 新建任务',
'New Cron Job': '新建定时任务',
Schedule: '计划',
'Schedule Type': '计划类型',
Once: '一次',
Interval: '间隔',
Daily: '每天',
Weekly: '每周',
'Run At': '运行时间',
'Every (minutes)': '每隔(分钟)',
Weekdays: '工作日',
"Times use the server's local timezone.": '时间使用服务器本地时区。',
'All Events': '全部事件',
Created: '已创建',
Started: '已启动',
Exit: '退出',
Deleted: '已删除',
Recovered: '已恢复',
'Stale Cleaned': '已清理过期项',
'Mux Died': 'Mux 已终止',
'Server Started': '服务器已启动',
'Server Stopped': '服务器已停止',
Extra: '附加信息',
'Token Usage Statistics': 'Token 使用统计',
'Daily Breakdown': '每日明细',
'Export JSON': '导出 JSON',
'Export MD': '导出 Markdown',
// Dynamic common status / toasts
'Settings saved': '设置已保存',
'Settings saved locally': '设置已保存到本机',
'Tunnel active': '隧道已启用',
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
'Push notifications enabled': '推送通知已启用',
'Push notifications disabled': '推送通知已禁用',
'Permission Required': '需要授权',
'Waiting for Input': '等待输入',
'Question Asked': 'Claude 正在提问',
'Response Complete': '回复完成',
'Task Completed': '任务已完成',
'Teammate Idle': '队友空闲',
'Session Error': '会话错误',
'Respawn Blocked': '重生已阻止',
'Task Complete': '任务完成',
'Copied to clipboard': '已复制到剪贴板',
'Checking…': '正在检查…',
'Starting…': '正在启动…',
'Starting update…': '正在开始更新…',
'Queued…': '已排队…',
'Preparing…': '正在准备…',
'Stashing local changes…': '正在暂存本地更改…',
'Fetching release…': '正在获取发行版…',
'Checking out release…': '正在检出发行版…',
'Installing dependencies…': '正在安装依赖…',
'Building…': '正在构建…',
'Restarting Codeman…': '正在重启 {name}…',
'Try again': '重试',
'Could not check for updates. Try again later.': '无法检查更新,请稍后重试。',
'The previous version is still running.': '先前版本仍在运行。',
// Remaining settings, wizard, case and management surfaces
'Advanced Options': '高级选项',
'Advanced container settings': '高级容器设置',
Basics: '基本设置',
Behavior: '行为',
Alerts: '提醒',
Limits: '限制',
Paths: '路径',
Notes: '备注',
Context: '上下文',
Duration: '持续时间',
Iterations: '迭代次数',
Elapsed: '已用时间',
Launch: '启动',
'Launch Command': '启动命令',
'Background Agents': '后台智能体',
'Background Tasks': '后台任务',
Tasks: '任务',
'Explore Tasks': '探索任务',
'Implement Tasks': '实现任务',
'Test Tasks': '测试任务',
'Review Tasks': '审查任务',
'Agent Type': '智能体类型',
'Implementation Plan': '实施计划',
Plan: '计划',
'Plan:': '计划:',
'Plan Usage Limits': '套餐使用限制',
'Plan Wizard Agents': '计划向导智能体',
'Fix Plan Menu': '修复计划菜单',
'View Fix Plan': '查看修复计划',
'Regenerate Plan': '重新生成计划',
'Cancel plan generation': '取消生成计划',
'Describe your task below. Claude will generate an implementation plan with testing steps.':
'请在下方描述任务,Claude 将生成包含测试步骤的实施计划。',
'What do you want to build?': '你想构建什么?',
'A brief description...': '简要描述…',
Describe: '描述',
Enhanced: '增强',
'Enhanced: parallel subagents + verification (slower but more thorough)':
'增强:并行子智能体 + 验证(速度较慢,但更全面)',
Standard: '标准',
'Single-pass generation with Opus 4.5': '使用 Opus 4.5 单轮生成',
'Initializing deep reasoning model': '正在初始化深度推理模型',
'Starting Opus 4.5...': '正在启动 Opus 4.5…',
'Auto-launch when plan completes': '计划完成后自动启动',
'Auto-accept prompts': '自动接受提示',
'Presses Enter for plan approvals and default question options': '对计划审批和默认问题选项自动按 Enter',
'Auto-accepts, auto-clears, agent completions': '自动接受、自动清理和智能体完成提醒',
'Or click Run to start': '或点击“运行”开始',
'to edit your task, or': '以编辑任务,或',
'to continue without a plan': '以不使用计划直接继续',
// Ralph / respawn
Respawn: '重生',
'Respawn loop': '重生循环',
'Enable Respawn': '启用重生',
'Stop Respawn': '停止重生',
'Auto-resume when usage limit resets': '使用限制重置后自动继续',
'Auto-restart sessions when context fills up (usually not needed)': '上下文已满时自动重启会话(通常不需要)',
'Auto-Compact': '自动压缩',
'Auto-Clear': '自动清空',
'Token Management': 'Token 管理',
'Use 1M token context window': '使用 100 万 Token 上下文窗口',
'Use 1M token context window for new sessions': '新会话使用 100 万 Token 上下文窗口',
'Full context reset at threshold (use higher than compact)': '达到阈值时完全重置上下文(阈值应高于压缩阈值)',
'Idle Threshold': '空闲阈值',
'Max Iterations': '最大迭代次数',
'Max Iterations:': '最大迭代次数:',
'Max Todos': '最大待办数',
'Todo Expiration': '待办过期时间',
'Completion Phrase': '完成短语',
'Completion Phrase:': '完成短语:',
'Phrase Claude outputs when loop is complete (without <promise> tags)':
'循环完成时 Claude 输出的短语(不含 <promise> 标签)',
'Prompt to send when idle': '空闲时发送的提示词',
'Prompt to send into the session': '发送到会话的提示词',
'Prompt Source': '提示词来源',
'Prompt File Path': '提示词文件路径',
'Prompt file path': '提示词文件路径',
'Prompt Preview': '提示词预览',
'Load Preset': '加载预设',
Presets: '预设',
'Apply preset': '应用预设',
'Save Preset': '保存预设',
'Save Respawn Preset': '保存重生预设',
'Save current config as preset': '将当前配置保存为预设',
'Preset Name': '预设名称',
'Description (optional)': '描述(可选)',
'When to use this preset': '此预设的适用场景',
'Start Loop': '启动循环',
'Start Ralph Loop': '启动 Ralph 循环',
'Start Ralph Loop →': '启动 Ralph 循环 →',
'Enable Tracker': '启用跟踪器',
'Ralph / Todo': 'Ralph / 待办',
'Ralph / Todo Tracker': 'Ralph / 待办跟踪器',
'Cycle Steps': '循环步骤',
'1. Update Prompt': '1. 更新提示词',
'2. Send /clear': '2. 发送 /clear',
'3. Send /init': '3. 发送 /init',
'4. Kickstart Prompt': '4. 启动提示词',
'Sent only when /init completes but Claude stays idle · Auto-accept presses Enter for plan approvals and default options':
'仅在 /init 完成后 Claude 仍空闲时发送;自动接受会对计划审批和默认选项按 Enter',
'One autonomous work cycle: whenever Claude goes idle, Codeman sends the update prompt, optionally runs /clear + /init, and kickstarts the next round — repeating for the chosen duration. All settings below belong to this loop; configure them, then press Enable.':
'一个自主工作循环:Claude 每次空闲时,{name}都会发送更新提示词,可选执行 /clear + /init,并启动下一轮,持续到设定时长。下方设置均属于此循环;配置后点击“启用”。',
'If Claude pauses on a usage limit ("limit reached · resets 3pm"), Codeman waits for the reset time and automatically continues the work. Independent of the respawn loop below.':
'如果 Claude 因使用限制暂停(“limit reached · resets 3pm”),{name}会等待限制重置并自动继续工作。此功能独立于下方的重生循环。',
// Search, session and panel surfaces
'Search sessions, events, files…': '搜索会话、事件和文件…',
'Search across sessions': '跨会话搜索',
'Filter by case': '按案例筛选',
'Filter by date range': '按日期范围筛选',
'Filter by session status': '按会话状态筛选',
'Filter files...': '筛选文件…',
'Any status': '任意状态',
'Any time': '任意时间',
'Last hour': '最近一小时',
'Last 7 Days': '最近 7 天',
'Past 24h': '过去 24 小时',
'Past 7 days': '过去 7 天',
'Past 30 days': '过去 30 天',
'Since last visit': '自上次访问以来',
Since: '开始时间',
Until: '结束时间',
'Away digest range': '离开期间摘要范围',
'Open the digest to load recent activity': '打开摘要以加载最近活动',
'Refresh away digest': '刷新离开期间摘要',
'Refresh summary': '刷新摘要',
'Select a session to view files': '选择会话以查看文件',
'Select a session to view summary': '选择会话以查看摘要',
'Select an agent to view details': '选择智能体以查看详情',
'Select a run to view its agents': '选择一次运行以查看其智能体',
'Source type filter': '来源类型筛选',
'Copy content': '复制内容',
'Export as JSON': '导出为 JSON',
'Export as Markdown': '导出为 Markdown',
'Mark all read': '全部标为已读',
'Clear search': '清除搜索',
'Clear all tracked subagents': '清除所有已跟踪的子智能体',
'Kill All Sessions': '终止所有会话',
'Kill all sessions and their tmux processes': '终止所有会话及其 tmux 进程',
'Kill All Claude + Tmux': '终止全部 Claude + Tmux',
'Kill Tmux & Claude Code': '终止 Tmux 与 Claude Code',
'Terminate everything completely': '彻底终止所有内容',
'Tmux Sessions': 'Tmux 会话',
'Tmux sessions keep running in background': 'Tmux 会话继续在后台运行',
'Refresh tmux sessions': '刷新 Tmux 会话',
'Restore Terminal Size': '恢复终端大小',
'Clear Terminal': '清空终端',
'Stop current run': '停止当前运行',
'Stop respawn': '停止重生',
'Stop (Ctrl+C)': '停止(Ctrl+C)',
// Case, remote and Docker details
Case: '案例',
'Case:': '案例:',
'Case settings': '案例设置',
'Create New': '新建',
'Auto (directory name)': '自动(目录名)',
'Custom name shown in the tab (right-click tab to rename inline)':
'标签页中显示的自定义名称(右键标签可直接重命名)',
'Name to identify this case in Codeman': '用于在{name}中标识此案例的名称',
'Name to identify this remote case in Codeman': '用于在{name}中标识此远程案例的名称',
'Absolute path on the remote host. Codeman will not create or delete it.':
'远程主机上的绝对路径;{name}不会创建或删除该目录。',
'Absolute path to an existing project folder, e.g. /home/you/my-project':
'现有项目文件夹的绝对路径,例如 /home/you/my-project',
'Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/':
'仅允许字母、数字、连字符和下划线;将在 ~/codeman-cases/ 中创建。',
'Docker exports': 'Docker 导出',
'No exports yet. Export a docker case from its tab.': '暂无导出;请从 Docker 案例标签页导出。',
'Runs inside an isolated container. Multiple sessions can share the same container.':
'在隔离容器内运行;多个会话可以共享同一容器。',
'Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.':
'在加固的隔离容器中运行此案例。首次使用时会自动构建基础镜像;必须安装 Docker/Podman。',
'Run in an isolated Docker container': '在隔离的 Docker 容器中运行',
'Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.':
'绑定挂载到容器中的主机绝对目录;{name}会在其中生成 CLAUDE.md 和 hooks。',
'A reusable docker host profile. Reuse the same ID across cases to share settings.':
'可复用的 Docker 主机配置;多个案例使用同一 ID 可共享设置。',
'Mount host credentials (~/.claude etc.)': '挂载主机凭据(~/.claude 等)',
'On: your existing login just works (creds stay on the host, never in exports). Off: sealed sandbox, log in inside the container.':
'开启:直接使用现有登录(凭据保留在主机且不会进入导出);关闭:使用密封沙箱,需要在容器内登录。',
'Disk is elastic: storage grows automatically as data flows in (no fixed cap).':
'磁盘为弹性容量:会随数据自动增长(无固定上限)。',
'Needs the NVIDIA container toolkit on the host.': '主机需要安装 NVIDIA Container Toolkit。',
'GPU — 8 GB RAM, 4 CPU, all GPUs': 'GPU — 8 GB 内存、4 CPU、全部 GPU',
'Large — 8 GB RAM, 4 CPU': '大型 — 8 GB 内存、4 CPU',
'Medium — 4 GB RAM, 2 CPU (default)': '中型 — 4 GB 内存、2 CPU(默认)',
'Small — 2 GB RAM, 1 CPU': '小型 — 2 GB 内存、1 CPU',
'bridge (internet on, default)': '桥接(可联网,默认)',
'bridge (internet on)': '桥接(可联网)',
'none (fully isolated, no network)': '无(完全隔离,不联网)',
'none (fully isolated)': '无(完全隔离)',
'Resume last conversation on relaunch': '重新启动时继续最近一次对话',
'Extra -o Options': '附加 -o 选项',
'SOCKS Proxy': 'SOCKS 代理',
'Host ID': '主机 ID',
'Optional. Leave blank for the default port 22.': '可选;留空使用默认端口 22。',
'Optional. Path to a private key on this machine (passed to ssh -i). Never the key contents.':
'可选;本机私钥文件路径(传给 ssh -i),请勿填写密钥内容。',
'Optional. [user@]host[:port] for ssh -J (jump/bastion host).':
'可选;ssh -J 使用的 [user@]host[:port](跳板机)。',
'Optional. One KEY=VALUE per line; each becomes an ssh -o option.':
'可选;每行一个 KEY=VALUE,每项都会成为 ssh -o 选项。',
// Settings descriptions and remaining common controls
'Use the GPU-accelerated WebGL terminal renderer (desktop only). Turn off to force the DOM renderer if you hit GPU glitches. Codeman also auto-falls-back to the DOM renderer after repeated GPU stalls.':
'使用 GPU 加速的 WebGL 终端渲染器(仅桌面端)。如遇 GPU 显示问题,可关闭以强制使用 DOM 渲染器;多次 GPU 卡顿后{name}也会自动回退。',
'Show A-/A+ font size buttons in header': '在顶部栏显示 A-/A+ 字体大小按钮',
'Show Claude plan usage limits (5-hour & weekly) in the header. Applies to newly created sessions.':
'在顶部栏显示 Claude 套餐使用限制(5 小时和每周);适用于新建会话。',
'Show ultracode / Workflow runs as a master-detail tab (tasks on the left, agents with tokens + tool calls on the right)':
'以主从标签页显示 Ultracode / Workflow 运行(左侧任务,右侧智能体 Token 与工具调用)',
'Pop a floating window for each active ultracode / Workflow run, connected by a line to its session tab (additional to the Ultracode Agents panel)':
'为每个活动的 Ultracode / Workflow 运行弹出浮动窗口,并用连线连接到其会话标签页',
'Shows typed characters instantly via overlay while forwarding keystrokes to the server in the background. Enables Tab completion, preserves input across tab switches, and protects against session crashes. Recommended for mobile and high-latency connections.':
'通过覆盖层即时显示输入,同时在后台把按键转发到服务器。支持 Tab 补全、切换标签时保留输入并防止会话崩溃丢字;推荐移动端和高延迟连接使用。',
"Show a dedicated input field below the terminal for CJK (Chinese/Japanese/Korean) IME composition. Recommended for mobile devices with Chinese input methods where xterm's native input handling may drop characters.":
'在终端下方显示中日韩输入法专用文本框。推荐在可能因 xterm 原生输入而丢字的移动端中文输入法中使用。',
'Show additional buttons (Tab, Shift+Tab, Ctrl+O, Esc, Alt+Enter, left/right arrows) in the mobile keyboard accessory bar.':
'在移动端键盘工具栏显示附加按键(Tab、Shift+Tab、Ctrl+O、Esc、Alt+Enter、左右方向键)。',
'Scroll local history (when mouse passthrough is active)': '滚动本地历史(鼠标直通启用时)',
'Plain wheel/trackpad pages the terminal scrollback': '使用普通滚轮/触控板翻阅终端历史',
'Camera hand-tracking overlay (applied on reload)': '摄像头手势跟踪覆盖层(重新加载后生效)',
'Enable the camera hand-tracking gesture overlay (applied on reload). The instance must run with CODEMAN_GESTURE=1.':
'启用摄像头手势跟踪覆盖层(重新加载后生效);实例必须以 CODEMAN_GESTURE=1 运行。',
'How Claude CLI is started in screen sessions. Auto Mode runs without routine prompts behind a background safety classifier (needs Claude Code 2.1.207+ and Opus 4.6+/Sonnet 4.6+/Fable 5)':
'设置 Claude CLI 在会话中的启动方式。自动模式由后台安全分类器保护,无需常规确认(需要 Claude Code 2.1.207+ 和 Opus 4.6+/Sonnet 4.6+/Fable 5)。',
'Auto-enable for new sessions (otherwise auto-enables on Ralph pattern detection)':
'为新会话自动启用(否则检测到 Ralph 模式时自动启用)',
'Enable experimental Agent Teams for all new Claude sessions (disabled by default)':
'为所有新 Claude 会话启用实验性智能体团队(默认关闭)',
'Automatically re-establish remote (SSH) sessions when the connection drops, reattaching to the durable remote tmux session (on by default; bounded backoff)':
'连接断开时自动重建远程 SSH 会话,并重新附加到持久化远程 tmux 会话(默认开启,有限退避)',
'Default effort for new Claude sessions — soft default, switchable anytime in-session via /effort (e.g. /effort ultracode)':
'新 Claude 会话的默认思考强度;这是软默认值,可随时在会话中通过 /effort 切换。',
'Lower priority of Claude sessions (reduces system impact, only affects new sessions)':
'降低 Claude 会话的进程优先级(减少系统影响,仅影响新会话)',
'Process priority (-20 to 19, higher = lower priority, default: 10)':
'进程优先级(-20 到 19;数值越大优先级越低;默认 10)',
'Start new Codex sessions with --dangerously-bypass-approvals-and-sandbox':
'使用 --dangerously-bypass-approvals-and-sandbox 启动新的 Codex 会话',
'Model used for execution tasks. Optimizer suggestions are advisory only.':
'执行任务使用的模型;优化器建议仅供参考。',
"Show what the optimizer recommends (doesn't override your choice)": '显示优化器建议(不会覆盖你的选择)',
'Optionally set specific models for each task type. Leave as "Use Default" to use your default model.':
'可为每种任务类型指定模型;保留“使用默认值”即可使用默认模型。',
'Request browser notification permission': '请求浏览器通知权限',
'Show OS-level notifications when tab is hidden': '标签页隐藏时显示系统级通知',
'OS-level push notifications — works even when tab is closed': '系统级推送通知,即使标签页关闭也可接收',
'Play a short beep for critical events': '严重事件发生时播放短提示音',
'Completions, budget warnings, stuck sessions': '完成提醒、预算警告和会话卡住提醒',
'Errors, crashes, agent failures': '错误、崩溃和智能体失败',
'Notify when a session is idle longer than this': '会话空闲超过此时长时通知',
'Stored locally only, never sent to server. Get a key at': '仅存储在本机,绝不会发送到服务器。可在此获取密钥:',
'Comma-separated terms to boost recognition accuracy': '以逗号分隔可提高识别准确率的术语',
'Start voice input': '开始语音输入',
'Voice input': '语音输入',
'Voice input (Ctrl+Shift+V)': '语音输入(Ctrl+Shift+V)',
'Insert Newline': '插入换行',
'Close Panels': '关闭面板',
'Previous / Next Session': '上一个 / 下一个会话',
'Next Session': '下一个会话',
'Switch to Tab N': '切换到第 N 个标签页',
'Move Active Tab Left': '向左移动当前标签页',
'Move Active Tab Right': '向右移动当前标签页',
'Focus First Tab': '聚焦第一个标签页',
'Focus Last Tab': '聚焦最后一个标签页',
'Focus Next Tab': '聚焦下一个标签页',
'Focus Previous Tab': '聚焦上一个标签页',
'Activate Focused Tab': '激活聚焦的标签页',
'Remove Tab': '移除标签页',
'Remove All Tabs': '移除所有标签页',
'Use arrows to reorder. Changes are saved automatically.': '使用方向键重新排序;更改会自动保存。',
});
const ZH_CN_LOWER = new Map(Object.entries(ZH_CN).map(([key, value]) => [key.toLocaleLowerCase('en'), value]));
const textState = new WeakMap();
const attributeState = new WeakMap();
let language = normalizeLanguage(global.__codemanLanguage);
let displayName = DEFAULT_NAME;
let observer = null;
let applying = false;
function normalizeLanguage(value) {
return SUPPORTED_LANGUAGES.has(value) ? value : 'en';
}
function normalizeDisplayName(value) {
if (typeof value !== 'string') return DEFAULT_NAME;
const normalized = value
.normalize('NFC')
.replace(/[\u0000-\u001f\u007f]/g, '')
.trim();
return normalized ? Array.from(normalized).slice(0, 40).join('') : DEFAULT_NAME;
}
function interpolate(value, variables) {
return value.replace(/\{([a-zA-Z][\w]*)\}/g, (_match, key) => String(variables[key] ?? ''));
}
function translateDynamic(source) {
const patterns = [
[/^(\d+) tokens?$/, (_m, count) => `${count} 个 Token`],
[/^(\d+) sessions?$/, (_m, count) => `${count} 个会话`],
[/^(\d+) tasks?$/, (_m, count) => `${count} 个任务`],
[/^(\d+) running$/, (_m, count) => `${count} 个运行中`],
[/^(\d+) active$/, (_m, count) => `${count} 个活动`],
[/^Show (\d+) more$/, (_m, count) => `再显示 ${count} 项`],
[/^Show (\d+) more \((\d+) remaining\)$/, (_m, count, remaining) => `再显示 ${count} 项(剩余 ${remaining} 项)`],
[/^Lifetime: (\d+) sessions created$/, (_m, count) => `累计已创建 ${count} 个会话`],
[/^Tunnel active: (.+)$/, (_m, url) => `隧道已启用:${url}`],
[/^Tunnel error: (.+)$/, (_m, error) => `隧道错误:${error}`],
[/^Update to v(.+)$/, (_m, version) => `更新到 v${version}`],
[/^You're up to date \(v(.+)\)\.$/, (_m, version) => `已是最新版本(v${version})。`],
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
];
for (const [pattern, replacement] of patterns) {
const match = source.match(pattern);
if (match) return replacement(...match);
}
const actionMatch = source.match(
/^(Open|Close|Show|Hide|Enable|Disable|Start|Stop|Refresh|Save|Cancel|Clear|Select|View|Export|Import|Remove|Kill|Toggle|Increase|Decrease) (.+)$/i
);
if (actionMatch) {
const action = {
open: '打开',
close: '关闭',
show: '显示',
hide: '隐藏',
enable: '启用',
disable: '禁用',
start: '启动',
stop: '停止',
refresh: '刷新',
save: '保存',
cancel: '取消',
clear: '清除',
select: '选择',
view: '查看',
export: '导出',
import: '导入',
remove: '移除',
kill: '终止',
toggle: '切换',
increase: '增大',
decrease: '减小',
}[actionMatch[1].toLowerCase()];
const object = ZH_CN[actionMatch[2]] || ZH_CN_LOWER.get(actionMatch[2].toLocaleLowerCase('en'));
if (action && object) return `${action}${object}`;
}
return null;
}
function brand(source) {
if (!source || displayName === DEFAULT_NAME) return source;
return source.replace(/Codeman/g, displayName).replace(/codeman(?=:)/g, displayName);
}
function t(source, variables = {}) {
if (typeof source !== 'string' || !source) return source;
const vars = { name: displayName, ...variables };
if (language === 'zh-CN') {
const translated = ZH_CN[source] || ZH_CN_LOWER.get(source.toLocaleLowerCase('en')) || translateDynamic(source);
if (translated) return brand(interpolate(translated, vars));
}
return brand(interpolate(source, vars));
}
function shouldSkip(node) {
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return !element || Boolean(element.closest(SKIP_SELECTOR));
}
function shouldSkipText(node) {
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return shouldSkip(node) || Boolean(element?.closest(USER_TEXT_SELECTOR));
}
function preserveWhitespace(source, translated) {
const leading = source.match(/^\s*/)?.[0] || '';
const trailing = source.match(/\s*$/)?.[0] || '';
return leading + translated + trailing;
}
function translateTextNode(node) {
let state = textState.get(node);
if (shouldSkipText(node) || (!state && !/[A-Za-z]/.test(node.nodeValue || ''))) return;
if (!state || node.nodeValue !== state.applied) {
state = { source: node.nodeValue, applied: node.nodeValue };
}
const trimmed = state.source.trim();
if (!trimmed) return;
const next = preserveWhitespace(state.source, t(trimmed));
state.applied = next;
textState.set(node, state);
if (node.nodeValue !== next) node.nodeValue = next;
}
function translateAttributes(element) {
if (shouldSkip(element) || element.matches('.history-item[title]')) return;
let states = attributeState.get(element);
if (!states) states = new Map();
for (const attribute of TRANSLATABLE_ATTRIBUTES) {
if (!element.hasAttribute(attribute)) continue;
const current = element.getAttribute(attribute) || '';
let state = states.get(attribute);
if (!state || current !== state.applied) state = { source: current, applied: current };
const next = t(state.source);
state.applied = next;
states.set(attribute, state);
if (current !== next) element.setAttribute(attribute, next);
}
attributeState.set(element, states);
}
function translateNode(root) {
if (!root || applying) return;
applying = true;
try {
if (root.nodeType === Node.TEXT_NODE) {
translateTextNode(root);
return;
}
if (root.nodeType !== Node.ELEMENT_NODE && root.nodeType !== Node.DOCUMENT_NODE) return;
if (root.nodeType === Node.ELEMENT_NODE) translateAttributes(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node.nodeType === Node.TEXT_NODE) translateTextNode(node);
else translateAttributes(node);
}
} finally {
applying = false;
}
}
function refreshDocumentTitle() {
const current = document.title || '';
const titleState = document.documentElement.dataset.i18nTitleSource || current;
document.documentElement.dataset.i18nTitleSource = titleState;
document.title = brand(titleState);
}
function configure(options = {}) {
const previousDisplayName = displayName;
language = normalizeLanguage(options.language ?? language);
displayName = normalizeDisplayName(options.displayName ?? displayName);
global.__codemanLanguage = language;
global.__codemanDisplayName = displayName;
document.documentElement.lang = language;
document.documentElement.dataset.language = language;
if (previousDisplayName !== displayName) {
const source = document.documentElement.dataset.i18nTitleSource || document.title || '';
if (previousDisplayName !== DEFAULT_NAME && source.includes(previousDisplayName)) {
document.documentElement.dataset.i18nTitleSource = source.replaceAll(previousDisplayName, displayName);
}
}
translateNode(document.body);
refreshDocumentTitle();
return { language, displayName };
}
function start() {
translateNode(document.body);
refreshDocumentTitle();
if (observer) return;
observer = new MutationObserver((mutations) => {
if (applying) return;
for (const mutation of mutations) {
if (mutation.type === 'characterData') translateNode(mutation.target);
if (mutation.type === 'attributes') translateAttributes(mutation.target);
for (const added of mutation.addedNodes) translateNode(added);
}
});
observer.observe(document.body, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: TRANSLATABLE_ATTRIBUTES,
});
}
const api = Object.freeze({
t,
configure,
start,
translateNode,
normalizeDisplayName,
normalizeLanguage,
get language() {
return language;
},
get displayName() {
return displayName;
},
});
global.CodemanI18n = api;
global.codemanT = t;
const nativeConfirm = typeof global.confirm === 'function' ? global.confirm.bind(global) : null;
const nativeAlert = typeof global.alert === 'function' ? global.alert.bind(global) : null;
if (nativeConfirm) global.confirm = (message) => nativeConfirm(t(String(message)));
if (nativeAlert) global.alert = (message) => nativeAlert(t(String(message)));
document.addEventListener('DOMContentLoaded', start, { once: true });
})(window);
+80 -24
View File
@@ -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));
+583 -17
View File
@@ -46,6 +46,10 @@
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Synchronous skin selection — runs before first paint to prevent theme flash -->
<script>try{var s=localStorage.getItem('codeman:skin');if(s!=='og'&&s!=='daylight-green'&&s!=='daylight-blue')s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
<!-- Apply the saved per-device language before first paint. The full translation
layer loads below; setting lang/dir here prevents an English accessibility
tree from flashing while the deferred scripts start. -->
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var l=JSON.parse(localStorage.getItem(k)||'{}').language;l=l==='zh-CN'?'zh-CN':'en';document.documentElement.lang=l;window.__codemanLanguage=l;}catch(e){document.documentElement.lang='en';window.__codemanLanguage='en';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#11151c}
@@ -76,7 +80,9 @@
<!-- Compact Header with Session Tabs -->
<header class="header">
<div class="header-brand">
<span class="logo" onclick="app.goHome()" title="Go to main page">Codeman</span>
<span class="logo" onclick="app.goHome()" title="Go to main page"
><span class="logo-text">Codeman</span><span class="logo-compact" aria-hidden="true">C</span></span
>
</div>
<!-- Session Tabs -->
@@ -87,6 +93,10 @@
<div class="solo-session-title" id="soloSessionTitle" style="display: none;" aria-live="polite"></div>
<div class="header-right" id="headerRight">
<button class="btn-admin-panel btn-admin-panel--hidden" id="adminPanelBtn" onclick="window.codemanAdmin.openAdminPanel()" title="Admin Panel (multi-user administration)" aria-label="Open admin panel">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span>Admin Panel</span>
</button>
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">&#x229E;</button>
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
<span class="tunnel-dot"></span>
@@ -118,10 +128,13 @@
</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>
@@ -305,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 &rarr;</button>
</div>
</div>
@@ -383,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>
@@ -400,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>
@@ -422,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>
@@ -494,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">&#x2699; Orchestrator</button> -->
<button class="btn-toolbar btn-sm btn-cron btn-cron--hidden" onclick="app.openCron()" title="Cron Jobs">&#x23F0; Cron</button>
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
</div>
</footer>
@@ -507,17 +582,158 @@
<button class="modal-close" onclick="app.closeHelp()" aria-label="Close help">&times;</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">&times;</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>
@@ -949,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 &amp; Language</div>
<div class="settings-item" title="Name shown in the browser UI and window title. Supports Unicode, including Chinese.">
<span class="settings-item-label">Display Name</span>
<input type="text" id="appSettingsDisplayName" class="settings-inline-input" maxlength="40" placeholder="Codeman" autocomplete="off">
</div>
<div class="settings-item" title="Language for this device. Dynamic status messages and dialogs use the same language.">
<span class="settings-item-label">Interface Language</span>
<select id="appSettingsLanguage" class="form-select settings-inline-select">
<option value="en">English</option>
<option value="zh-CN">简体中文</option>
</select>
</div>
<!-- Appearance Section -->
<div class="settings-section-header">Appearance</div>
<div class="settings-item settings-item-skin" title="Visual theme for this device (not synced)">
@@ -964,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>
@@ -1045,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">
@@ -1059,6 +1314,27 @@
<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">
@@ -1197,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>
@@ -1248,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">
@@ -1555,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>
@@ -1563,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">&#x2715;</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>
@@ -1574,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">
@@ -1588,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">
@@ -1602,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&#10;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">
@@ -1863,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">&#x21BB; Refresh</button>
<button class="modal-close" onclick="app.closeAwayDigest()" aria-label="Close away digest">&times;</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">&times;</button>
</div>
<div class="modal-body">
<input type="search" id="sessionManagerSearch" class="search-input" placeholder="Search sessions by name, prompt, or path&hellip;" 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>
@@ -1959,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>
@@ -1971,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>
+139 -5
View File
@@ -53,13 +53,46 @@ const CjkInput = (() => {
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 = {};
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)
@@ -93,6 +126,16 @@ const CjkInput = (() => {
}
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);
}
@@ -103,7 +146,17 @@ const CjkInput = (() => {
/** 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);
}
@@ -150,12 +203,37 @@ const CjkInput = (() => {
_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 (!_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.
@@ -173,23 +251,32 @@ const CjkInput = (() => {
// ── Composition tracking (keyboard IME — works for CJK typing) ──
_listeners.compositionstart = () => {
_t(`compstart ${_vdesc(_textarea.value)}`);
_composing = true;
_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;
_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) => {
_t(`keydown ${_kdesc(e.key)} kc=${e.keyCode} ic=${e.isComposing} c=${_composing}`);
if (e.key === 'Enter') {
e.preventDefault();
_composing = false;
@@ -246,6 +333,7 @@ const CjkInput = (() => {
e.preventDefault();
_send(e.key);
_keydownSentAt = performance.now();
_keydownSentText = e.key;
_resetToPhantom();
return;
}
@@ -254,6 +342,23 @@ const CjkInput = (() => {
// ── Input event: primary path for virtual keyboards + dictation ──
_listeners.input = (e) => {
_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 ──
if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') {
if (_composing) return;
@@ -283,11 +388,18 @@ const CjkInput = (() => {
if (_composing) return;
// Keydown handler already sent this character — just clear the
// textarea echo that the IME inserted despite preventDefault.
// 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) {
_resetToPhantom();
return;
const cur = _strip(_textarea.value);
if (cur === '' || cur === _keydownSentText) {
_t('echo-drop');
_resetToPhantom();
return;
}
}
// Outside composition: keyboard typing or voice dictation.
@@ -301,8 +413,30 @@ 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) {
+29
View File
@@ -43,6 +43,35 @@ const MobileDetection = {
);
},
/**
* Check whether this browser belongs to a handheld device.
*
* Unlike getDeviceType(), this classification must remain stable when a
* foldable changes posture. An unfolded phone can expose a desktop-width
* viewport, but it still needs the same per-device settings that were saved
* while folded. User-Agent Client Hints are preferred where available; the
* legacy token fallback covers Android WebView and iPhone browsers.
*/
isHandheldDevice() {
if (!this.isTouchDevice()) return false;
const userAgent = navigator.userAgent || '';
// Prefer explicit UA form-factor signals. Besides matching real browsers,
// this avoids Chromium emulation reporting userAgentData.mobile=true for
// an iPad/tablet context created with isMobile=true.
if (/iPad|Tablet|Silk|PlayBook|Kindle|Windows NT|CrOS|Macintosh/i.test(userAgent)) {
return false;
}
if (/Android/i.test(userAgent) && !/Mobile/i.test(userAgent)) return false;
if (/Mobi|iPhone|iPod/i.test(userAgent)) return true;
const uaDataMobile = navigator.userAgentData?.mobile;
if (typeof uaDataMobile === 'boolean') return uaDataMobile;
return false;
},
/** Check if device is iOS (iPhone, iPad, iPod) */
isIOS() {
return (
+135 -7
View File
@@ -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%;
@@ -1163,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;
@@ -1204,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);
+5 -3
View File
@@ -273,7 +273,7 @@ class NotificationManager {
const readClass = n.read ? '' : ' unread';
const countLabel = n.count > 1 ? `<span class="notif-item-count">&times;${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
+2 -2
View File
@@ -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
View File
@@ -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">&#x2715;</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">&#x2715;</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>
`;
File diff suppressed because it is too large Load Diff

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