Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28c5b5c1eb | ||
|
|
af9db455ff | ||
|
|
a406aef2fa | ||
|
|
77bcbc9b94 | ||
|
|
d4540c5ce6 | ||
|
|
4a83efcd48 | ||
|
|
473c57c7ca | ||
|
|
b586007f14 | ||
|
|
b388b84cc2 | ||
|
|
d13642ebce | ||
|
|
3cff98fe56 | ||
|
|
bc232e5ff3 | ||
|
|
2a7e035d2b | ||
|
|
80a88ea857 | ||
|
|
5c45d434ac | ||
|
|
390516ca3f | ||
|
|
57b6be1ed5 | ||
|
|
cbae989e02 | ||
|
|
84f47e8ee0 | ||
|
|
541d9c8131 | ||
|
|
e4ea785a28 | ||
|
|
b7a6a189f9 | ||
|
|
e063222ac2 | ||
|
|
346bc8b173 | ||
|
|
b34fcaf928 | ||
|
|
ea4c935d51 | ||
|
|
716b7ccdbb | ||
|
|
da7a095e33 | ||
|
|
149cee6bcd | ||
|
|
7cda2194c3 | ||
|
|
eb8724bbf2 | ||
|
|
cb6c25220f | ||
|
|
de87c4e315 | ||
|
|
63710cf2c1 | ||
|
|
8c089a4819 | ||
|
|
f812f65a33 | ||
|
|
2667150f33 | ||
|
|
a842b091bf | ||
|
|
dae82388ed | ||
|
|
bca56b4273 | ||
|
|
86c634959d | ||
|
|
fc5294e7c2 | ||
|
|
8e9f25482a | ||
|
|
211f3c07dd | ||
|
|
876f9a75b4 | ||
|
|
d7bb726213 | ||
|
|
715aef2076 | ||
|
|
608ec8a10e | ||
|
|
303afd7fe1 | ||
|
|
0ee268ba82 | ||
|
|
1be98ff8a3 | ||
|
|
b710013add | ||
|
|
4343805672 | ||
|
|
4f8471189e | ||
|
|
fad7cdc1ab | ||
|
|
56db02412b | ||
|
|
689d9fc5e5 | ||
|
|
50547a4e89 | ||
|
|
3c2a5bfef3 | ||
|
|
bc66add7ed | ||
|
|
24b5d8fa63 | ||
|
|
8d9fc4195b | ||
|
|
5abcae16b4 | ||
|
|
66ad681666 | ||
|
|
6c8d4ca72f | ||
|
|
2fdf7dabac | ||
|
|
51cb3a7205 | ||
|
|
b10e354936 | ||
|
|
64559b60d1 | ||
|
|
6351b4143f | ||
|
|
3d6e3f3d6e | ||
|
|
5c20fcf464 | ||
|
|
683544a22e | ||
|
|
5181c9abb0 | ||
|
|
25c67f9415 | ||
|
|
5ec71ace5a | ||
|
|
b27a0e9188 | ||
|
|
35a0217ccd | ||
|
|
7a86cf87f7 | ||
|
|
5792c2d62e | ||
|
|
8807b3ff6d | ||
|
|
115ada1e9e | ||
|
|
b2ebdcbf47 | ||
|
|
6dba8b5227 | ||
|
|
897bfdff59 | ||
|
|
fb013e9de0 | ||
|
|
7f24a132d0 |
@@ -4,7 +4,7 @@ Codeman launches AI coding sessions with `--dangerously-skip-permissions`, so th
|
||||
web UI is **by design a remote-code-execution surface for whoever can reach it**.
|
||||
The entire security model exists to control *who* that is. Please read this before
|
||||
exposing an instance beyond `localhost`. The full model lives in
|
||||
[`docs/security-architecture.md`](docs/security-architecture.md).
|
||||
[`docs/security-architecture.md`](../docs/security-architecture.md).
|
||||
|
||||
## Supported versions
|
||||
|
||||
@@ -75,4 +75,4 @@ subscribe and send time), and tmux session names discovered on the shared socket
|
||||
are validated against the safe-name pattern before reaching any shell call site.
|
||||
|
||||
For the detailed rationale, defenses, and recommended secure setups, see
|
||||
[`docs/security-architecture.md`](docs/security-architecture.md).
|
||||
[`docs/security-architecture.md`](../docs/security-architecture.md).
|
||||
@@ -2,6 +2,9 @@
|
||||
.agents/
|
||||
skills-lock.json
|
||||
|
||||
# Written by install.sh into end-user clones when setup finishes
|
||||
.install-complete
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
|
||||
@@ -26,3 +26,6 @@ src/web/public/terminal-ui.js
|
||||
src/web/public/voice-input.js
|
||||
src/web/public/upload.html
|
||||
scripts/remotion/
|
||||
|
||||
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
|
||||
CLAUDE.md
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 120,
|
||||
"trailingComma": "es5",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -1,5 +1,206 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.9.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Rewrite the `xterm-zerolag-input` package README as a value-first document and correct the drift that had accumulated against the source.
|
||||
- Added the side-by-side phone demo GIF (`docs/images/zerolag-demo-20260728.gif`) as the hero image, referenced by absolute raw URL so it renders on npmjs.com as well as GitHub. The two-phone comparison shows 0ms local echo next to a 600ms-2.7s server echo on the same session.
|
||||
- New "Why this one" comparison table, an explicit list of target use cases (SSH web clients, cloud IDEs, mobile terminals, container consoles), and a bundle-size badge (6.1 kB gzipped, measured from the ESM build).
|
||||
- Corrected the test-count badge from 78 to the actual 175 tests across 5 files, in both the package README and the Published Packages section of the root README.
|
||||
- Removed the stale "Unicode/emoji rendered at single-cell width" limitation. CJK, fullwidth forms and emoji have had double-width rendering and visual-column positioning since the wide-character fix; the honest remaining caveat (per-code-point width summing over-counts ZWJ grapheme clusters) replaces it.
|
||||
- Documented the previously undocumented public `setPrompt()` method for switching prompt strategies at runtime, and the new "Wide characters (CJK, emoji)" integration section covering the optional `Unicode11Addon` path and the built-in range-table fallback.
|
||||
- Documented `backgroundColor: 'transparent'`, corrected the `foregroundColor` default, and updated the grid-alignment math to reflect visual-column positioning rather than character index.
|
||||
|
||||
No source changes, docs only.
|
||||
|
||||
## 1.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Narrow the Run dropdown, and close the last two gaps in web-tab asset rewriting.
|
||||
|
||||
**The Run dropdown was pinned at its full width.** It capped at 300px, and the recent-session rows wanted 326px, so it always rendered at the cap and reached further across the terminal than it needed to. Now 250px, chosen as the width at which a `~/<dir>/<repo>` + timestamp row still fits whole, since identifying a session to resume is what that list is for. Three fixes were needed to make the narrower menu degrade instead of clip: the saved-URL label now has its own element, because `text-overflow` on the row button did nothing (a bare text node inside a flex container becomes an anonymous flex item that ellipsis cannot reach); `.hist-dir` got `min-width: 0`, without which a flex item refuses to shrink below its own text and pushes the date out of the box; and history rows are held to the container width, because the list's `overflow-y: auto` implicitly makes `overflow-x: auto` and let each row size to its own content and scroll sideways. Phone and tablet widths are unchanged, being set separately in `mobile.css`.
|
||||
|
||||
**A dashboard's own `/api/...` assets are relayed again.** The `Referer`-keyed 404 fallback, which rescues a root-absolute asset that no rewrite layer could reach, refused everything under `/api` outright. Dashboards commonly serve their assets from exactly that namespace, so those requests had no rescue at all. The refusal is now precise: the relay runs before the API-shaped 404, and the auth exemption refuses only paths that resolve to a REAL Codeman route, with `/ws/` and `/q/` still refused by prefix.
|
||||
|
||||
Two findings shaped that fence, both from probing Fastify rather than reading it. `hasRoute()` matches the registered PATTERN literally, so `/api/sessions/abc` reports no match against a registered `/api/sessions/:id` and would have granted an unauthenticated exemption on a live session-scoped route; `findRoute()` performs the real lookup and is what the fence uses. And `@fastify/static` is mounted at `/`, so it registers a root catch-all matching every path, which has to count as "no real route" or the fence would refuse every referer-form request and break the rescue that already worked. A root catch-all is distinguishable because it is the only route whose wildcard param comes back equal to the whole request path. The fence fails closed, and both edges are pinned in `test/webview-auth-exemption.test.ts`.
|
||||
|
||||
**`url()` inside runtime CSS is rewritten.** Measuring the fallback against a purpose-built dashboard showed one sink no relay can reach: a `<style>` element built by page script has no URL of its own, so the browser sends an EMPTY `Referer` with the image request it triggers. The injected URL shim now rewrites root-absolute `url()` in `<style>` blocks, both as markup and when a `<style>` node is inserted. Verified in Chromium: a stylesheet-only `/api/hero.png` and a runtime `<style>` `/api/late.png` both load, where both previously failed. The remaining known gap is self-navigation via `location.href`, which cannot be patched because `Location.href` is unforgeable.
|
||||
|
||||
## 1.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 2667150: feat(mobile): browse and insert local file and folder paths
|
||||
|
||||
Add a root-confined filesystem picker to Link Existing and the extended mobile
|
||||
keyboard bar. Selected paths remain editable at the active prompt, supported
|
||||
images/documents/text files open in a safe inline preview, and a new one-tap
|
||||
action clears only the current unsent input without invoking `/clear`.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3cff98f: Fix two multi-user scoping holes in the new filesystem path picker. `GET /api/filesystem/browse` and `GET /api/filesystem/preview` accept an optional `sessionId` that contributes the session's working directory as a browse root, but they resolved it straight off the session map without an ownership check, unlike the nine other session-scoped handlers in the same route file. A non-admin could therefore pin another user's working directory as a root simply by passing their session id, then list and preview files under it. Both endpoints now run `canAccessOwned` and report 404, which also avoids confirming that a session id exists.
|
||||
|
||||
Separately, `Home` and `CASES_DIR` were unconditional browse roots for every caller. Per-user spaces live at `<USER_SPACES_DIR>/<username>`, which is inside `homedir()`, so the `Home` root alone exposed every other user's workspace to any authenticated user. In multi-user mode a non-admin now gets only their own space plus anything explicitly listed in `CODEMAN_FILE_PICKER_ROOTS`; `/mnt/d` is no longer offered by default, since a broad host mount should be an explicit operator decision in a multi-user deployment. Admins keep the host-wide roots, and single-user mode is unchanged.
|
||||
|
||||
Both holes are regression-guarded in `test/routes/file-routes.test.ts`, verified to fail against the previous code. Multi-user mode is opt-in and off by default, so single-user installs were never affected.
|
||||
|
||||
- Web tabs: delete saved URLs from the Run dropdown, and fix images in proxied dashboards.
|
||||
|
||||
**Saved URLs are now manageable from the dropdown.** Each row under "Web / URL" gains a gear and an `x`, so a URL can be edited or deleted without first opening it as a tab. Previously the only delete path ran through the gear on an open tab, which was a dead end for a URL you no longer wanted open at all. Both controls stay permanently visible rather than hover-revealed, because the same menu is used on touch, and they get a larger hit box there. Deleting leaves the dropdown open on the remaining rows, and deleting the dashboard that is currently open also closes its tab and unmounts its frame.
|
||||
|
||||
**Runtime-injected images no longer 404.** A dashboard that renders its own markup from script (`card.innerHTML = '<img src="/api/hero?slug=x">'`, `img.src = '/api/slide'`) escaped every rewrite layer at once: `<base href>` never applies to a root-absolute URL, the server-side attribute rewrite only ever sees the initial document, and `runtimeUrlShim()` patched only `fetch`, `XMLHttpRequest`, `WebSocket` and `EventSource`. Those requests landed on Codeman's own root and 404'd, with a symptom that reads as an upstream fault: the dashboard's data loaded while every image stayed broken.
|
||||
|
||||
The shim now also covers the DOM URL sinks, so the request is never emitted in the first place and neither the `/api` fence in the 404 fallback nor the one in the auth middleware had to move. It wraps `innerHTML`, `outerHTML`, `insertAdjacentHTML` (including on `ShadowRoot`), `setAttribute`/`setAttributeNS`, and the `src`/`srcset`/`href`/`poster`/`data`/`action` property setters on img, source, media, video poster, script, iframe, embed, track, link, anchor, area, object and form, with a `MutationObserver` as a last net for sinks not patched above. Every rewrite routes through the same idempotent helper, which matters because unlike the server-side rewrite this one sees markup that may already be proxied, and a page re-injecting its own `outerHTML` would otherwise double-prefix. Everything is defensively guarded and marked so a double injection cannot wrap an already-wrapped setter.
|
||||
|
||||
Measured against a real dashboard: 693 image elements, 0 of them under the proxy prefix and 0 of 23 in-viewport images decoded before, 693 and 23 of 23 after. Covered by a new jsdom suite over the shim's DOM half and a new frontend suite over the dropdown rows. Known remaining gaps are documented in `docs/web-tabs.md`: a root-absolute `url()` inside a stylesheet injected at runtime, and self-navigation via `location.href`, which cannot be patched because `Location.href` is unforgeable.
|
||||
|
||||
Also in this release: a value-first README overhaul pointing at getcodeman.com, and the QR-auth distribution test now uses a chi-square check instead of a max-deviation threshold that failed on random variance.
|
||||
|
||||
- bca56b4: Normalize Claude conversations in the response viewer. A Claude transcript is an append-only event log, so one logical exchange spans many JSONL rows: tool-result rows, meta/image/skill rows, compact summaries, task and team notifications, sidechains, replayed assistant snapshots, and multi-block assistant output. The viewer rendered a card per row, which produced duplicate and truncated cards that read as lost responses. Cards are now built at real human-turn boundaries, replayed assistant snapshots are deduplicated, and sidechain rows (which belong to subagents, not the main conversation) no longer leak in. An identical prompt that legitimately recurs after an assistant reply is still kept as its own turn.
|
||||
|
||||
Measured over 40 real transcripts: 3108 cards became 621, duplicate cards dropped from 74 to 8 (all of them genuinely repeated turns), no assistant text was lost, and the non-`context=full` last-response text was byte-identical on every file.
|
||||
|
||||
Also rebinds recovered sessions to their transcript. `reconcileSessions()` can recover a lost mux session as a `restored-<uuid8>` placeholder with a stale working directory, which made transcript lookup by cwd find nothing. The placeholder still carries the first eight characters of the conversation UUID, so the viewer now rebinds to the matching top-level transcript when exactly one candidate matches.
|
||||
|
||||
## 1.8.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8c089a4: Add four light UI and terminal skins: Paper Gray, Solarized Light, Catppuccin Latte, and Rosé Pine Dawn. The Skin picker now groups Light and Dark options, and each light skin ships a matching xterm ANSI palette plus `color-scheme: light` so native selects, date pickers and scrollbars stop rendering as dark OS widgets on a light page. Terminals set `minimumContrastRatio: 4.5` under a light skin (main terminal and teammate terminals both), which keeps CLI output that assumes a dark background readable, and `applyTerminalSkin()` now refreshes the zero-lag input overlay so typed-but-unflushed text does not keep the previous theme's colors.
|
||||
|
||||
Elevated surfaces (modals, command palette, dropdowns, subagent and ultracode windows, file preview, attachment tray, mobile sheets) now resolve through shared `--floating-bg` / `--control-*` / `--banner-bg-*` / `--modal-backdrop` / `--elevated-shadow` tokens instead of hardcoded near-black rgba, so they follow whichever skin is active. On the Daylight skins this lifts modals slightly off the page background; OG Codeman pins its own near-black value to keep that palette neutral.
|
||||
|
||||
Also defines twelve CSS compatibility aliases (`--bg-primary`, `--bg-secondary`, `--bg-tertiary`, `--text-primary`, `--text-secondary`, `--border-color`, `--accent-color`, `--success`, `--error`, `--danger`, `--font-mono`, `--shadow-lg`) that panels and overlays already referenced in about 79 places but which were never actually declared, so those rules silently resolved to nothing. Status badges and accent-tinted pills (search filter chips and result badges, session tab mode pills, respawn state, Ralph priority and circuit-breaker badges, tunnel and voice status, mobile case picker) no longer keep their pale light-on-dark ink under a light skin, where it measured 1.0 to 1.9:1 and made the search filter chips invisible.
|
||||
|
||||
New static regression `test/skin-themes.test.ts` guards the four-way parity between the CSS token block, the xterm palette, the pre-paint allowlist and the Settings picker.
|
||||
|
||||
## 1.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Web tabs: open dashboard URLs as tabs beside agent sessions, plus terminal link fixes.
|
||||
|
||||
**Web tabs.** The Run dropdown gains a "Web / URL" section. A saved URL renders as a tab in the same strip as Claude/Codex/Gemini sessions, with the same Alt+1-9 numbering, an icon picker, and per-device tab order. Frames stay mounted while hidden (LRU-bounded), so switching tabs never reloads a dashboard.
|
||||
|
||||
Dashboards are proxied through Codeman's own origin, because a direct iframe fails three ways at once: an HTTPS Codeman cannot embed a plain-HTTP target (mixed content, with no override at all on iOS Safari), many dashboards send `X-Frame-Options: DENY`, and Codeman's own `default-src 'self'` CSP blocks cross-origin frames. Proxying dissolves all three and leaves the production CSP unchanged. The fetch happens server-side, so a tailnet-only or localhost-only dashboard is reachable from any device that can reach Codeman.
|
||||
|
||||
The proxy is not an API surface: it authenticates on a 192-bit capability in the path (memory-only, rolling TTL, bound to the minting user, revoked on edit or delete) and is exempt from the cookie and Origin checks, because a sandboxed iframe is opaque-origin and sends neither. The Host allowlist is never bypassed. Iframes omit `allow-same-origin` unless a URL is explicitly marked trusted, and `Authorization` plus the session cookie are stripped upstream in both modes so `CODEMAN_PASSWORD` cannot leak into a dashboard. Includes an HTTP and WebSocket proxy, redirect/cookie/`<base>` rewriting, a runtime URL shim for requests built by dashboard JavaScript, and CORS handling for the opaque-origin frame. New endpoints under `/api/webviews`, storage in `~/.codeman/webviews.json`, user guide in `docs/web-tabs.md`.
|
||||
|
||||
**Terminal links no longer truncate.** Three separate cuts, each producing a link that opened the wrong target or none at all:
|
||||
- A single `&` ended the match, so every query string was cut. A WordPress edit link resolved to `?post=1479` and Claude Code's own `/login` URL was unusable. `&` is now part of a URL while `&&` remains a boundary.
|
||||
- Links wider than the terminal were cut at the row boundary. The link provider now stitches continuation rows into one logical line and maps offsets back across rows. Handles both soft wraps (emulator, `isWrapped`) and hard wraps (a program wrapping its own output and emitting a newline, as Ink does), the latter being why the `/login` URL grew longer as the window was widened.
|
||||
- Image and PDF paths were not matched at all, so pasted-screenshot paths rendered as plain text. They now link and open the file preview, which renders images inline.
|
||||
|
||||
**Also fixes** a pre-existing bug where `.toolbar`'s `backdrop-filter` created a stacking context that trapped the Run menu's z-index, letting the welcome overlay cover it: with no session open, every item in that menu (Claude Code included) was unclickable.
|
||||
|
||||
## 1.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Mobile toolbar: a dedicated Enter button, and Shell moves into the Run dropdown.
|
||||
|
||||
Submitting is a constant need on a touch keyboard, so on phones (≤430px) the toolbar slot that held "Shell" now holds a dark blue **Enter** button. Starting a shell, the far rarer action, moves into the expandable Run dropdown as `Terminal / Shell` (the Run button then reads "Run SH"). Desktop and tablet are unchanged: the green Run Shell button stays exactly where it was.
|
||||
|
||||
Enter is replayed through the terminal's own input path rather than posted to the input API. This matters because local echo is on by default on touch devices: the characters you type are buffered client-side and have not yet reached the PTY, so sending a bare carriage return would submit an empty line and leave your text stranded on screen. Replaying the keypress flushes the buffered text first, then submits.
|
||||
|
||||
Installer: re-runs and updates now preserve the existing network binding instead of silently reverting it, so upgrading no longer changes how the dashboard is reachable.
|
||||
|
||||
Default desktop header is cleaner: the file viewer is shown by default and the plan-usage chip is unchanged, while the token-count chip and lifecycle-log button now default off. Stored preferences are still honored.
|
||||
|
||||
Docs and repo housekeeping: fresh phone screenshots and a new hero GIF in both READMEs, contributor and total-commit badges, and a much shorter repo root. `SECURITY.md` moved to `.github/` (GitHub resolves it there, so the Security policy tab is unaffected), `SPEEDRUN.md` to `docs/`, the knip config to `config/`, and Prettier's config into the `"prettier"` key of `package.json`. `CLAUDE.md` was split so the always-loaded guidance is roughly half its former size, with the deep implementation detail preserved verbatim in `docs/architecture-invariants.md`.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
<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">
|
||||
<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>
|
||||
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
|
||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -21,7 +24,33 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/subagent-demo.gif" alt="Codeman — parallel subagent visualization" width="900">
|
||||
<img src="docs/images/subagent-demo-20260724.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.
|
||||
|
||||
Get started in one line (macOS & Linux, Windows via WSL):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://getcodeman.com/install | bash
|
||||
```
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
# Open http://localhost:3000 and start your first session
|
||||
```
|
||||
|
||||
The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation).
|
||||
|
||||
- **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 private** - loopback-only by default, MIT licensed, no telemetry, runs entirely on your machine
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/codeman-tour-20260724.png" alt="Codeman dashboard tour: session tabs per case, one-click Run for new agents, live plan usage in the header" width="900">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -29,21 +58,37 @@
|
||||
## Quick Start - Installation
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
|
||||
curl -fsSL https://getcodeman.com/install | bash
|
||||
```
|
||||
|
||||
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it.
|
||||
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it. A few things worth knowing:
|
||||
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), or [Gemini CLI](https://github.com/google-gemini/gemini-cli) (any combination works). After install:
|
||||
- **It asks first.** Every system change (package installs, AI CLI download) is prompted, and a menu at the end lets you choose: run Codeman in this terminal, install it as a background service (systemd/launchd, auto-start on boot), or don't start yet. Nothing runs in the background unless you pick it.
|
||||
- **Network or local-only, your choice.** The installer asks whether the dashboard should be reachable from other devices on your network (`0.0.0.0`, the default, with a strongly recommended password prompt) or from this machine only (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. A bare `codeman web` started by hand still defaults to loopback.
|
||||
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh update` and `install.sh uninstall` also exist.
|
||||
- **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
|
||||
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), or [Gemini CLI](https://github.com/google-gemini/gemini-cli) (any combination works). The installer detects whichever of the four is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install:
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
# Open http://localhost:3000 and start your first session
|
||||
```
|
||||
|
||||
**Sharing with a small team?** Start it in multi-user mode instead: each person gets their own login and workspace.
|
||||
|
||||
```bash
|
||||
codeman users add alice --admin # create the first admin account
|
||||
codeman web --multiuser # named logins + per-user case spaces
|
||||
```
|
||||
|
||||
Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
|
||||
|
||||
<details>
|
||||
<summary><strong>Run as a background service</strong></summary>
|
||||
|
||||
The installer's final menu sets this up for you (option 2) and verifies the service actually comes up before claiming success. To configure it manually instead:
|
||||
|
||||
**Linux (systemd):**
|
||||
|
||||
```bash
|
||||
@@ -103,7 +148,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
<summary><strong>Windows (WSL)</strong></summary>
|
||||
|
||||
```powershell
|
||||
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
|
||||
wsl bash -c "curl -fsSL https://getcodeman.com/install | 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), [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.
|
||||
@@ -112,6 +157,58 @@ Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/e
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="40%"><img src="docs/screenshots/mobile-session-keyboard-20260727.png" alt="Mobile — answering an agent's plan prompt with the keyboard accessory bar and Enter button" width="300"></td>
|
||||
<td align="center" width="60%"><img src="docs/screenshots/mobile-toolbar-enter-20260727.png" alt="Mobile toolbar: accessory bar with /init, /clear, clipboard and Esc above the Run, case, stop, Enter, voice and settings controls" width="440"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Answering prompts by touch</em></td>
|
||||
<td align="center"><em>Accessory bar + dedicated Enter button</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Terminal Apps</th>
|
||||
<th>Codeman Mobile</th>
|
||||
</tr>
|
||||
<tr><td>200-300ms input lag over remote</td><td><b>Local echo — instant feedback</b></td></tr>
|
||||
<tr><td>Tiny text, no context</td><td>Full xterm.js terminal</td></tr>
|
||||
<tr><td>No session management</td><td>Swipe between sessions</td></tr>
|
||||
<tr><td>No notifications</td><td>Push alerts for approvals and idle</td></tr>
|
||||
<tr><td>Manual reconnect</td><td>tmux persistence</td></tr>
|
||||
<tr><td>No agent visibility</td><td>Background agents in real-time</td></tr>
|
||||
<tr><td>Copy-paste slash commands</td><td>One-tap <code>/init</code>, <code>/clear</code>, <code>/compact</code></td></tr>
|
||||
<tr><td>Password typing on phone</td><td><b>QR code scan — instant auth</b></td></tr>
|
||||
</table>
|
||||
|
||||
- **Keyboard accessory bar** — `/init`, `/clear`, `/compact` quick-action buttons above the virtual keyboard; destructive commands require a double-press to confirm, so you never fire one by accident
|
||||
- **Dedicated Enter button** — replays the keypress through the terminal, so text buffered by local echo is flushed first rather than stranded
|
||||
- **Swipe navigation & smart keyboard handling** — swipe left/right to switch sessions; toolbar and terminal shift up when the keyboard opens (`visualViewport` API)
|
||||
- **Built for phones** — safe-area insets for notch and home indicator, 44px touch targets, bottom-sheet case picker, native momentum scrolling
|
||||
|
||||
```bash
|
||||
codeman web --https
|
||||
# Open on your phone: https://<your-ip>:3000
|
||||
```
|
||||
|
||||
> `localhost` works over plain HTTP. Use `--https` when accessing from another device, or use [Tailscale](https://tailscale.com/) (recommended) — it provides a private network so you can access `http://<tailscale-ip>:3000` from your phone without TLS certificates.
|
||||
|
||||
### Secure QR Code Authentication
|
||||
|
||||
Typing passwords on a phone keyboard is miserable. Codeman replaces it with **cryptographically secure single-use QR tokens** — scan the code displayed on your desktop and your phone is authenticated instantly.
|
||||
|
||||
Each QR encodes a URL containing a 6-character short code that maps to a 256-bit secret (`crypto.randomBytes(32)`) on the server. Tokens auto-rotate every **60 seconds**, are **atomically consumed on first scan** (replays always fail), and use **hash-based `Map.get()` lookup** that leaks nothing through response timing. The short code is an opaque pointer — the real secret never appears in browser history, `Referer` headers, or Cloudflare edge logs.
|
||||
|
||||
The security design addresses all 6 critical QR auth flaws identified in ["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): single-use enforcement, short TTL, cryptographic randomness, server-side generation, real-time desktop notification on scan (QRLjacking detection), and IP + User-Agent session binding with manual revocation. Dual-layer rate limiting (per-IP + global) makes brute force infeasible across 62^6 = 56.8 billion possible codes. Full security analysis: [`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -142,9 +239,9 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
||||
|
||||
### 3. Read the dashboard
|
||||
|
||||
- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder.
|
||||
- **Tabs (top)** — one per session. `Alt+1`-`9` to jump, `Ctrl+Tab` for next, drag to reorder (tab order syncs across your devices).
|
||||
- **Terminal (center)** — a real `xterm.js` terminal; full TUIs render correctly. Type directly and press **Enter** to send. `Shift+Enter` inserts a newline.
|
||||
- **Side panels** — Respawn, Ralph, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar).
|
||||
- **Side panels** — Respawn, Orchestrator, Cron, Subagents, Settings (toggled from the toolbar).
|
||||
|
||||
### 4. Talk to the agent
|
||||
|
||||
@@ -155,13 +252,12 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
||||
|
||||
### 5. Make it autonomous
|
||||
|
||||
| Mode | Use it for | Where |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
|
||||
| **Respawn** | Long unattended runs — auto-restarts the CLI on idle/limit, with adaptive timing. Presets: `solo-work`, `overnight-autonomous`, … | Respawn tab |
|
||||
| **Ralph / Todo** | A self-driving loop that tracks a todo list and keeps working until done. | Ralph tab |
|
||||
| **Orchestrator** | Turn one goal into a phased plan and drive it to completion across agents. | Orchestrator panel |
|
||||
| **Cron** | Saved, named jobs on a schedule (`once`/`interval`/`daily`/`weekly`) that spawn a session and send a prompt when due. | ⏰ Cron button |
|
||||
| **Auto-resume** | Automatically continue after a subscription rate-limit resets. | Respawn tab (top) |
|
||||
| Mode | Use it for | Where |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| **Respawn** | Long unattended runs — auto-restarts the CLI on idle/limit, with adaptive timing. Presets: `solo-work`, `overnight-autonomous`, … | Respawn tab |
|
||||
| **Orchestrator** | Turn one goal into a phased plan and drive it to completion across agents. | Orchestrator panel |
|
||||
| **Cron** | Saved, named jobs on a schedule (`once`/`interval`/`daily`/`weekly`) that spawn a session and send a prompt when due. | ⏰ Cron button _(opt-in: App Settings → Display → Header Displays)_ |
|
||||
| **Auto-resume** | Automatically continue after a subscription rate-limit resets. | Respawn tab (top) |
|
||||
|
||||
### 6. Reach it from anywhere
|
||||
|
||||
@@ -171,7 +267,7 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
||||
|
||||
### 7. Operate & maintain
|
||||
|
||||
- **App Settings** — model, effort, theme/skin, notifications, display toggles, per-CLI options.
|
||||
- **App Settings** — model, effort, permission startup mode, theme/skin, notifications, display toggles, per-CLI options, a synced custom display name, and per-device English/Simplified Chinese UI language.
|
||||
- **Self-update** — git-clone installs update in place from **Settings → Updates**.
|
||||
- **Deploy your own changes** — see [Development](#development).
|
||||
|
||||
@@ -179,87 +275,10 @@ Hit start — Codeman spawns the CLI via a real PTY and streams it to your brows
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-landing-qr.png" alt="Mobile — landing page with QR auth" width="260"></td>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-idle.png" alt="Mobile — idle session with keyboard accessory" width="260"></td>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-active.png" alt="Mobile — active agent session" width="260"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>Landing page with QR auth</em></td>
|
||||
<td align="center"><em>Keyboard accessory bar</em></td>
|
||||
<td align="center"><em>Agent working in real-time</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Terminal Apps</th>
|
||||
<th>Codeman Mobile</th>
|
||||
</tr>
|
||||
<tr><td>200-300ms input lag over remote</td><td><b>Local echo — instant feedback</b></td></tr>
|
||||
<tr><td>Tiny text, no context</td><td>Full xterm.js terminal</td></tr>
|
||||
<tr><td>No session management</td><td>Swipe between sessions</td></tr>
|
||||
<tr><td>No notifications</td><td>Push alerts for approvals and idle</td></tr>
|
||||
<tr><td>Manual reconnect</td><td>tmux persistence</td></tr>
|
||||
<tr><td>No agent visibility</td><td>Background agents in real-time</td></tr>
|
||||
<tr><td>Copy-paste slash commands</td><td>One-tap <code>/init</code>, <code>/clear</code>, <code>/compact</code></td></tr>
|
||||
<tr><td>Password typing on phone</td><td><b>QR code scan — instant auth</b></td></tr>
|
||||
</table>
|
||||
|
||||
### Secure QR Code Authentication
|
||||
|
||||
Typing passwords on a phone keyboard is miserable. Codeman replaces it with **cryptographically secure single-use QR tokens** — scan the code displayed on your desktop and your phone is authenticated instantly.
|
||||
|
||||
Each QR encodes a URL containing a 6-character short code that maps to a 256-bit secret (`crypto.randomBytes(32)`) on the server. Tokens auto-rotate every **60 seconds**, are **atomically consumed on first scan** (replays always fail), and use **hash-based `Map.get()` lookup** that leaks nothing through response timing. The short code is an opaque pointer — the real secret never appears in browser history, `Referer` headers, or Cloudflare edge logs.
|
||||
|
||||
The security design addresses all 6 critical QR auth flaws identified in ["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): single-use enforcement, short TTL, cryptographic randomness, server-side generation, real-time desktop notification on scan (QRLjacking detection), and IP + User-Agent session binding with manual revocation. Dual-layer rate limiting (per-IP + global) makes brute force infeasible across 62^6 = 56.8 billion possible codes. Full security analysis: [`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
|
||||
|
||||
### Touch-Optimized Interface
|
||||
|
||||
- **Keyboard accessory bar** — `/init`, `/clear`, `/compact` quick-action buttons above the virtual keyboard. Destructive commands (`/clear`, `/compact`) require a double-press to confirm — first tap arms the button, second tap executes — so you never fire one by accident on a bumpy commute
|
||||
- **Swipe navigation** — left/right on the terminal to switch sessions (80px threshold, 300ms)
|
||||
- **Smart keyboard handling** — toolbar and terminal shift up when keyboard opens (uses `visualViewport` API with 100px threshold for iOS address bar drift)
|
||||
- **Safe area support** — respects iPhone notch and home indicator via `env(safe-area-inset-*)`
|
||||
- **44px touch targets** — all buttons meet iOS Human Interface Guidelines minimum sizes
|
||||
- **Bottom sheet case picker** — slide-up modal replaces the desktop dropdown
|
||||
- **Native momentum scrolling** — `-webkit-overflow-scrolling: touch` for buttery scroll
|
||||
|
||||
```bash
|
||||
codeman web --https
|
||||
# Open on your phone: https://<your-ip>:3000
|
||||
```
|
||||
|
||||
> `localhost` works over plain HTTP. Use `--https` when accessing from another device, or use [Tailscale](https://tailscale.com/) (recommended) — it provides a private network so you can access `http://<tailscale-ip>:3000` from your phone without TLS certificates.
|
||||
|
||||
---
|
||||
|
||||
## Live Agent Visualization
|
||||
|
||||
Watch background agents work in real-time. Codeman monitors agent activity and displays each agent in a draggable floating window with animated Matrix-style connection lines back to the parent session.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/subagent-spawn.png" alt="Subagent Visualization" width="900">
|
||||
</p>
|
||||
|
||||
- **Floating terminal windows** — draggable, resizable panels for each agent with a live activity log showing every tool call, file read, and progress update as it happens
|
||||
- **Connection lines** — animated green lines linking parent sessions to their child agents, updating in real-time as agents spawn and complete
|
||||
- **Status & model badges** — green (active), yellow (idle), blue (completed) indicators with Haiku/Sonnet/Opus model color coding
|
||||
- **Auto-behavior** — windows auto-open on spawn, auto-minimize on completion, tab badge shows "AGENT" or "AGENTS (n)" count
|
||||
- **Nested agents** — supports 3-level hierarchies (lead session -> teammate agents -> sub-subagents)
|
||||
|
||||
**Agent Teams** — first-class support for Claude Code's native multi-agent teams (`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`). `TeamWatcher` polls `~/.claude/teams/`, matches teammates to their lead session, and surfaces them as live subagent windows with **team-aware idle detection** — so the Respawn Controller won't fire while teammates are still working. See [`docs/agent-teams/`](docs/agent-teams/).
|
||||
|
||||
---
|
||||
|
||||
## Zero-Lag Input Overlay
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/zerolag-demo.gif" alt="Zerolag Demo — local echo vs server echo side-by-side" width="900">
|
||||
<img src="docs/images/zerolag-demo-20260728.gif" alt="Zerolag demo: instant local echo next to 600ms-2.7s server echo, side by side on two phones" width="900">
|
||||
</p>
|
||||
|
||||
When accessing your coding agent remotely (VPN, Tailscale, SSH tunnel), every keystroke normally takes 200-300ms to round-trip. Codeman implements a **Mosh-inspired local echo system** that makes typing feel instant regardless of latency.
|
||||
@@ -276,6 +295,30 @@ A pixel-perfect DOM overlay inside xterm.js renders keystrokes at 0ms. Backgroun
|
||||
|
||||
---
|
||||
|
||||
## Live Agent Visualization
|
||||
|
||||
Watch background agents work in real-time. Codeman monitors agent activity and displays each agent in a draggable floating window with animated Matrix-style connection lines back to the parent session.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/subagent-windows-20260724.png" alt="Subagent Visualization: three parallel Explore agents as floating windows with live tool-call feeds" width="900">
|
||||
</p>
|
||||
|
||||
- **Floating terminal windows** — draggable, resizable panels for each agent with a live activity log showing every tool call, file read, and progress update as it happens
|
||||
- **Connection lines** — animated green lines linking parent sessions to their child agents, updating in real-time as agents spawn and complete
|
||||
- **Status & model badges** — green (active), yellow (idle), blue (completed) indicators with Haiku/Sonnet/Opus model color coding
|
||||
- **Auto-behavior** — windows auto-open on spawn, auto-minimize on completion, tab badge shows "AGENT" or "AGENTS (n)" count
|
||||
- **Nested agents** — supports 3-level hierarchies (lead session -> teammate agents -> sub-subagents)
|
||||
|
||||
Multi-agent Workflow runs ("ultracode") get the same treatment: a floating run window tracks the whole workflow live, with phases, per-agent token counts, and the current tool of every agent:
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/ultracode-window-20260724.png" alt="Ultracode workflow visualization: a live run window with per-agent tokens and phases" width="900">
|
||||
</p>
|
||||
|
||||
**Agent Teams** — first-class support for Claude Code's native multi-agent teams (`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`). `TeamWatcher` polls `~/.claude/teams/`, matches teammates to their lead session, and surfaces them as live subagent windows with **team-aware idle detection** — so the Respawn Controller won't fire while teammates are still working. See [`docs/agent-teams/`](docs/agent-teams/).
|
||||
|
||||
---
|
||||
|
||||
## Respawn Controller
|
||||
|
||||
The core of autonomous work. When the agent goes idle, the Respawn Controller detects it, sends a continue prompt, cycles context management commands for fresh context, and resumes — running **24+ hours** completely unattended.
|
||||
@@ -302,7 +345,7 @@ Beyond single-session respawn, the **Orchestrator** turns a high-level goal into
|
||||
- **Crash-safe** — full state persists under the `orchestrator` key in `state.json`, so it survives restarts
|
||||
- **Driven from the UI or API** — the Orchestrator panel, or `POST /api/orchestrator/start` → `/approve` → `/status` (10 endpoints)
|
||||
|
||||
> Distinct from Ralph (a single-session autonomous loop): the orchestrator coordinates multi-phase, multi-agent execution. Full design: [`docs/orchestrator-loop-architecture.md`](docs/orchestrator-loop-architecture.md).
|
||||
> Full design: [`docs/orchestrator-loop-architecture.md`](docs/orchestrator-loop-architecture.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -310,14 +353,18 @@ Beyond single-session respawn, the **Orchestrator** turns a high-level goal into
|
||||
|
||||
Run **20 parallel sessions** with full visibility — real-time xterm.js terminals at 60fps, per-session token and cost tracking, tab-based navigation, and one-click management.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/multi-session-dashboard.png" alt="Multi-Session Dashboard" width="800">
|
||||
</p>
|
||||
|
||||
### Persistent Sessions
|
||||
|
||||
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:
|
||||
@@ -340,14 +387,6 @@ The title is templated into the served HTML on first byte, so it's correct from
|
||||
|
||||
Real-time desktop alerts when sessions need attention — `permission_prompt` and `elicitation_dialog` trigger critical red tab blinks, `idle_prompt` triggers yellow blinks. Click any notification to jump directly to the affected session. Hooks auto-configured per case directory.
|
||||
|
||||
### Ralph / Todo Tracking
|
||||
|
||||
Auto-detects Ralph Loops, `<promise>` tags, TodoWrite progress (`4/9 complete`), and iteration counters (`[5/50]`) with real-time progress rings and elapsed time tracking.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/ralph-tracker-8tasks-44percent.png" alt="Ralph Loop Tracking" width="800">
|
||||
</p>
|
||||
|
||||
### Run Summary
|
||||
|
||||
Click the chart icon on any session tab to see a timeline of everything that happened — respawn cycles, token milestones, auto-compact triggers, idle/working transitions, hook events, errors, and more.
|
||||
@@ -367,6 +406,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
||||
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
|
||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, or **Gemini** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*`/`GOOGLE_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||
- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
|
||||
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
|
||||
- **Voice input** — dictate prompts with Deepgram Nova-3 (Web Speech API fallback): toggle recording, auto-silence stop, live level meter (`Ctrl+Shift+V`)
|
||||
- **Image input** — paste or drag-and-drop images straight into a session
|
||||
@@ -394,6 +434,20 @@ Prerequisite: just Docker (or Podman). The agent base image builds itself automa
|
||||
|
||||
---
|
||||
|
||||
## Remote SSH Sessions
|
||||
|
||||
Point a case at another machine and run the agent **there**, over SSH, with the same dashboard, mobile UI, and autonomy features. Your laptop is just a window onto a session that lives on the remote host.
|
||||
|
||||
- **Durable by design**: the agent runs inside a dedicated tmux session on the remote host, so a dropped SSH connection, network change, or laptop sleep never kills the run. Reconnecting lands back in the same live conversation.
|
||||
- **Auto-reconnect**: a bounded-backoff watcher notices a dead SSH pane and silently reattaches to the still-running remote session (kill-switch in settings; intentional kills are never revived).
|
||||
- **Discover & attach**: list the `codeman-*` sessions already running on a host (started by that machine's own Codeman, or by another operator) and attach to one. Attached sessions you don't own **detach on tab close, never kill**.
|
||||
- **Shared sessions**: several clients can attach the same remote session at different window sizes without clamping each other; discovery shows a "shared" badge with the client count.
|
||||
- **Injection-safe**: every ssh command line flows through a single shell-escaping builder, and host/path/identity fields are schema-guarded.
|
||||
|
||||
Set it up under **New Case → Remote** (host, user, identity file, optional jump host). Full design: [`docs/remote-sessions.md`](docs/remote-sessions.md).
|
||||
|
||||
---
|
||||
|
||||
## Multi-User Mode (opt-in)
|
||||
|
||||
Share one Codeman with a small trusted team, each person getting their own login and workspace. **Off by default** — without the flag, nothing changes.
|
||||
@@ -537,13 +591,14 @@ When someone authenticates via QR, the desktop shows a notification toast with t
|
||||
|
||||
## Security
|
||||
|
||||
Codeman launches sessions with `--dangerously-skip-permissions`, so the web UI is by design a remote-code-execution surface for whoever can reach it — the whole security model exists to control _who_ that is. Recent hardening (v0.9.0 + v0.9.5) closes the browser-driven attack paths that bite self-hosted dev tools. Full model: [`docs/security-architecture.md`](docs/security-architecture.md). **Found a vulnerability?** See [`SECURITY.md`](SECURITY.md) for private disclosure and the list of known limitations.
|
||||
By default Codeman launches sessions with `--dangerously-skip-permissions`, so the web UI is by design a remote-code-execution surface for whoever can reach it — the whole security model exists to control _who_ that is. (The startup permission mode is configurable; see below.) Recent hardening (v0.9.0 + v0.9.5) closes the browser-driven attack paths that bite self-hosted dev tools. Full model: [`docs/security-architecture.md`](docs/security-architecture.md). **Found a vulnerability?** See [`SECURITY.md`](.github/SECURITY.md) for private disclosure and the list of known limitations.
|
||||
|
||||
### Network & access
|
||||
|
||||
- **Loopback by default** — binds `127.0.0.1`, reachable only from the same machine, so the no-password default is safe out of the box. Binding a non-loopback host without `CODEMAN_PASSWORD` _starts but prints a loud warning_ with three concrete fixes (set a password, loopback + an authenticated tunnel, or explicitly acknowledge with `--allow-unauthenticated-network`)
|
||||
- **Loopback by default** — the server binary binds `127.0.0.1`, reachable only from the same machine, so the no-password default is safe out of the box (the guided installer asks about network access and configures the binding + password for you). Binding a non-loopback host without `CODEMAN_PASSWORD` _starts but prints a loud warning_ with three concrete fixes (set a password, loopback + an authenticated tunnel, or explicitly acknowledge with `--allow-unauthenticated-network`)
|
||||
- **Optional auth, real sessions** — HTTP Basic via `CODEMAN_USERNAME` (default `admin`) / `CODEMAN_PASSWORD`. Success issues an opaque 256-bit `codeman_session` cookie (`randomBytes(32)`) — validated server-side, not client-signed, so it can't be forged offline (24h TTL, auto-extend, device-context audit log)
|
||||
- **Per-IP rate limiting** — 10 failed attempts → `429` with `Retry-After` (15-min decay). A valid cookie or correct password recovers _immediately_ even while an attacker hammers the same IP — important because all tunnel traffic shares one loopback IP. QR auth has its own separate limiter
|
||||
- **Configurable permission mode** - `--dangerously-skip-permissions` is only the default. **App Settings → Claude CLI → Startup Mode** can switch new sessions to Anthropic's classifier-guarded `auto` mode (low-prompt, needs Claude Code 2.1.207+), `normal` prompting, or an explicit allowed-tools list. In multi-user mode, non-granted users are forced to `auto`, and shell sessions / skip-permissions require an explicit per-user grant
|
||||
|
||||
### Always-on browser hardening (v0.9.5)
|
||||
|
||||
@@ -679,7 +734,6 @@ 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
|
||||
```
|
||||
|
||||
@@ -693,7 +747,7 @@ Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_pro
|
||||
|
||||
## API
|
||||
|
||||
REST over Fastify — **~160 handlers across 18 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse<T>` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset:
|
||||
REST over Fastify — **~190 handlers across 20 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse<T>` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset:
|
||||
|
||||
### Sessions
|
||||
|
||||
@@ -703,6 +757,9 @@ REST over Fastify — **~160 handlers across 18 route modules**, plus an SSE str
|
||||
| `POST` | `/api/quick-start` | Create case + start session (`{caseName?, mode?, effort?, envOverrides?}`) |
|
||||
| `POST` | `/api/sessions/:id/input` | Send input (`{input, useMux?, clientId?, seq?}` — `clientId`+`seq` = exactly-once) |
|
||||
| `GET` | `/api/sessions/:id/output` | Read terminal output |
|
||||
| `GET` | `/api/sessions/unified` | Unified live + history list (Session Manager) — `?q=&limit=` |
|
||||
| `POST` | `/api/sessions/:id/pin` | Pin/unpin in the Session Manager (`{pinned}`) |
|
||||
| `PUT` | `/api/session-order` | Sync tab order across devices (`{order: [ids]}`) |
|
||||
| `DELETE` | `/api/sessions/:id` | Delete session |
|
||||
|
||||
### Respawn
|
||||
@@ -713,13 +770,6 @@ REST over Fastify — **~160 handlers across 18 route modules**, plus an SSE str
|
||||
| `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 |
|
||||
|
||||
### Orchestrator
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
@@ -782,7 +832,6 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph Detection["Detection Layer"]
|
||||
RT["Ralph Tracker"]
|
||||
SW["Subagent Watcher<br/><small>~/.claude/projects/*/subagents</small>"]
|
||||
TW["Team Watcher<br/><small>~/.claude/teams/*</small>"]
|
||||
end
|
||||
@@ -806,7 +855,6 @@ flowchart TB
|
||||
SM --> RC
|
||||
SM --> ORC
|
||||
SM --> SS
|
||||
S1 --> RT
|
||||
S1 --> SCR
|
||||
S2 --> SCR
|
||||
RC --> SCR
|
||||
@@ -825,7 +873,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.
|
||||
@@ -855,7 +903,7 @@ Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-stru
|
||||
|
||||
[](https://www.npmjs.com/package/xterm-zerolag-input)
|
||||
|
||||
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, configurable prompt detection, full state machine with 78 tests.
|
||||
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 6.1 kB gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 175 tests.
|
||||
|
||||
```bash
|
||||
npm install xterm-zerolag-input
|
||||
@@ -882,3 +930,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>
|
||||
|
||||
@@ -17,35 +17,68 @@
|
||||
<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">
|
||||
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
|
||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/subagent-demo.gif" alt="Codeman — 并行子智能体可视化" width="900">
|
||||
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — 并行子智能体可视化" width="900">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/codeman-tour-20260724.png" alt="Codeman 仪表盘导览:按项目分组的会话标签页、一键 Run 启动新智能体、页头实时用量" width="900">
|
||||
</p>
|
||||
|
||||
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始 — 安装
|
||||
一行命令即可安装(macOS 和 Linux,Windows 通过 WSL):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash
|
||||
curl -fsSL https://getcodeman.com/install | bash
|
||||
```
|
||||
|
||||
该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。
|
||||
|
||||
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli)(任意组合均可)。安装完成后:
|
||||
|
||||
```bash
|
||||
codeman web
|
||||
# 打开 http://localhost:3000,开启你的第一个会话
|
||||
```
|
||||
|
||||
安装器在每次系统改动前都会先询问;重跑同一条命令即可原地更新。详见[快速开始 — 安装](#快速开始--安装)。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始 — 安装
|
||||
|
||||
```bash
|
||||
curl -fsSL https://getcodeman.com/install | bash
|
||||
```
|
||||
|
||||
该脚本会在缺失时自动安装 Node.js 和 tmux,把 Codeman 克隆到 `~/.codeman/app` 并完成构建。几点须知:
|
||||
|
||||
- **先询问,后改动。** 所有系统级改动(安装软件包、下载 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
|
||||
@@ -105,7 +138,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
<summary><strong>Windows(WSL)</strong></summary>
|
||||
|
||||
```powershell
|
||||
wsl bash -c "curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash"
|
||||
wsl bash -c "curl -fsSL https://getcodeman.com/install | 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) 或 [Gemini CLI](https://github.com/google-gemini/gemini-cli))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
|
||||
@@ -114,6 +147,58 @@ Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.
|
||||
|
||||
---
|
||||
|
||||
## 移动端优化的 Web UI
|
||||
|
||||
在任意手机上都能获得最跟手的 AI 编程智能体体验。完整的 xterm.js 终端、本地回显、滑动导航,以及为真正的远程办公而设计的触控优化界面 —— 而不是把桌面 UI 硬塞进小屏幕。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="40%"><img src="docs/screenshots/mobile-session-keyboard-20260727.png" alt="移动端 — 通过键盘配件栏与 Enter 按钮回答智能体的方案提示" width="300"></td>
|
||||
<td align="center" width="60%"><img src="docs/screenshots/mobile-toolbar-enter-20260727.png" alt="移动端工具栏:配件栏的 /init、/clear、剪贴板与 Esc,下方是 Run、案例、停止、Enter、语音与设置控件" width="440"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>触控回答提示</em></td>
|
||||
<td align="center"><em>配件栏 + 独立 Enter 按钮</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>普通终端 App</th>
|
||||
<th>Codeman 移动端</th>
|
||||
</tr>
|
||||
<tr><td>远程输入延迟 200–300 毫秒</td><td><b>本地回显 —— 即时反馈</b></td></tr>
|
||||
<tr><td>字小、无上下文</td><td>完整 xterm.js 终端</td></tr>
|
||||
<tr><td>无会话管理</td><td>滑动切换会话</td></tr>
|
||||
<tr><td>无通知</td><td>审批 / 空闲时推送提醒</td></tr>
|
||||
<tr><td>需手动重连</td><td>tmux 持久化</td></tr>
|
||||
<tr><td>看不到智能体</td><td>实时查看后台智能体</td></tr>
|
||||
<tr><td>斜杠命令靠复制粘贴</td><td>一键 <code>/init</code>、<code>/clear</code>、<code>/compact</code></td></tr>
|
||||
<tr><td>在手机上手打密码</td><td><b>扫二维码 —— 即时认证</b></td></tr>
|
||||
</table>
|
||||
|
||||
- **键盘配件栏** —— 在虚拟键盘上方提供 `/init`、`/clear`、`/compact` 快捷按钮;破坏性命令需双击确认,绝不误触
|
||||
- **独立的 Enter 按钮** —— 以按键方式回放,先冲刷本地回显缓冲的文本,不会让内容滞留在屏幕上
|
||||
- **滑动导航与智能键盘处理** —— 左右滑动切换会话;键盘弹出时工具栏与终端整体上移(`visualViewport` API)
|
||||
- **为手机而生** —— 刘海与 Home 指示条的安全区适配、44px 触控目标、底部抽屉式 case 选择器、原生惯性滚动
|
||||
|
||||
```bash
|
||||
codeman web --https
|
||||
# 在手机上打开:https://<你的IP>:3000
|
||||
```
|
||||
|
||||
> `localhost` 走纯 HTTP 即可。从其他设备访问时请使用 `--https`,或使用 [Tailscale](https://tailscale.com/)(推荐)—— 它提供私有网络,让你无需 TLS 证书即可从手机访问 `http://<tailscale-ip>:3000`。
|
||||
|
||||
### 安全的二维码认证
|
||||
|
||||
在手机键盘上输密码太痛苦了。Codeman 用**密码学安全的一次性二维码令牌**取而代之 —— 扫描桌面上显示的二维码,手机即刻完成认证。
|
||||
|
||||
每个二维码编码的是一个包含 6 字符短码的 URL,该短码在服务端映射到一个 256 位密钥(`crypto.randomBytes(32)`)。令牌每 **60 秒**自动轮换,**首次扫描即原子性消费**(重放永远失败),并采用**基于哈希的 `Map.get()` 查找**,不会通过响应时延泄露任何信息。短码只是一个不透明指针 —— 真正的密钥永远不会出现在浏览器历史、`Referer` 头或 Cloudflare 边缘日志中。
|
||||
|
||||
该安全设计覆盖了 ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin)(USENIX Security 2025,该研究发现 Top-100 网站中有 47 个存在漏洞)所指出的全部 6 个关键二维码认证缺陷:强制一次性使用、短 TTL、密码学随机性、服务端生成、扫描时桌面实时通知(QRLjacking 检测),以及 IP + User-Agent 会话绑定与手动吊销。双层速率限制(按 IP + 全局)使得在 62^6 = 568 亿种可能短码空间内进行暴力破解变得不可行。完整安全分析见:[`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
|
||||
|
||||
---
|
||||
|
||||
## 使用 Codeman —— 人类操作指南
|
||||
|
||||
从头到尾走一遍如何在浏览器里驾驭 Codeman。如果你刚装好,就从这里开始。
|
||||
@@ -144,9 +229,9 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
|
||||
|
||||
### 3. 读懂仪表盘
|
||||
|
||||
- **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序。
|
||||
- **标签(顶部)** —— 每个会话一个。`Alt+1`–`9` 跳转,`Ctrl+Tab` 下一个,拖拽排序(标签顺序会跨设备同步)。
|
||||
- **终端(中央)** —— 真实的 `xterm.js` 终端;完整 TUI 正常渲染。直接输入并按 **Enter** 发送。`Shift+Enter` 插入换行。
|
||||
- **侧边面板** —— Respawn、Ralph、Orchestrator、Cron、Subagents、Settings(从工具栏切换)。
|
||||
- **侧边面板** —— Respawn、Orchestrator、Cron、Subagents、Settings(从工具栏切换)。
|
||||
|
||||
### 4. 与智能体对话
|
||||
|
||||
@@ -157,13 +242,12 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
|
||||
|
||||
### 5. 让它自主运行
|
||||
|
||||
| 模式 | 用途 | 位置 |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------- | ---------------------- |
|
||||
| **Respawn** | 长时间无人值守运行 —— 空闲/限额时自动重启 CLI,带自适应时序。预设:`solo-work`、`overnight-autonomous` 等 | Respawn 标签页 |
|
||||
| **Ralph / Todo** | 一个自驱循环,跟踪 todo 列表并持续工作直到完成。 | Ralph 标签页 |
|
||||
| **Orchestrator** | 把一个目标变成分阶段计划,并跨多个智能体推动完成。 | 编排器面板 |
|
||||
| **Cron** | 已保存的、命名的定时任务(`once`/`interval`/`daily`/`weekly`),到期时拉起会话并发送提示。 | ⏰ Cron 按钮 |
|
||||
| **Auto-resume** | 订阅限额重置后自动继续。 | Respawn 标签页(顶部) |
|
||||
| 模式 | 用途 | 位置 |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| **Respawn** | 长时间无人值守运行 —— 空闲/限额时自动重启 CLI,带自适应时序。预设:`solo-work`、`overnight-autonomous` 等 | Respawn 标签页 |
|
||||
| **Orchestrator** | 把一个目标变成分阶段计划,并跨多个智能体推动完成。 | 编排器面板 |
|
||||
| **Cron** | 已保存的、命名的定时任务(`once`/`interval`/`daily`/`weekly`),到期时拉起会话并发送提示。 | ⏰ Cron 按钮(可选启用:App Settings → Display → Header Displays) |
|
||||
| **Auto-resume** | 订阅限额重置后自动继续。 | Respawn 标签页(顶部) |
|
||||
|
||||
### 6. 随时随地访问
|
||||
|
||||
@@ -173,7 +257,7 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
|
||||
|
||||
### 7. 运维与维护
|
||||
|
||||
- **App Settings** —— 模型、effort、主题/皮肤、通知、显示开关、各 CLI 的专属选项。
|
||||
- **App Settings** —— 模型、effort、权限启动模式、主题/皮肤、通知、显示开关、各 CLI 的专属选项,以及跨设备同步的自定义显示名称和按设备保存的英文/简体中文界面语言。
|
||||
- **自更新** —— git-clone 安装可在 **Settings → Updates** 中原地更新。
|
||||
- **部署你自己的改动** —— 见[开发](#开发)。
|
||||
|
||||
@@ -181,87 +265,10 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
|
||||
|
||||
---
|
||||
|
||||
## 移动端优化的 Web UI
|
||||
|
||||
在任意手机上都能获得最跟手的 AI 编程智能体体验。完整的 xterm.js 终端、本地回显、滑动导航,以及为真正的远程办公而设计的触控优化界面 —— 而不是把桌面 UI 硬塞进小屏幕。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-landing-qr.png" alt="移动端 — 带二维码认证的登录页" width="260"></td>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-idle.png" alt="移动端 — 带键盘配件栏的空闲会话" width="260"></td>
|
||||
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-active.png" alt="移动端 — 活动中的智能体会话" width="260"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><em>带二维码认证的登录页</em></td>
|
||||
<td align="center"><em>键盘配件栏</em></td>
|
||||
<td align="center"><em>智能体实时工作中</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>普通终端 App</th>
|
||||
<th>Codeman 移动端</th>
|
||||
</tr>
|
||||
<tr><td>远程输入延迟 200–300 毫秒</td><td><b>本地回显 —— 即时反馈</b></td></tr>
|
||||
<tr><td>字小、无上下文</td><td>完整 xterm.js 终端</td></tr>
|
||||
<tr><td>无会话管理</td><td>滑动切换会话</td></tr>
|
||||
<tr><td>无通知</td><td>审批 / 空闲时推送提醒</td></tr>
|
||||
<tr><td>需手动重连</td><td>tmux 持久化</td></tr>
|
||||
<tr><td>看不到智能体</td><td>实时查看后台智能体</td></tr>
|
||||
<tr><td>斜杠命令靠复制粘贴</td><td>一键 <code>/init</code>、<code>/clear</code>、<code>/compact</code></td></tr>
|
||||
<tr><td>在手机上手打密码</td><td><b>扫二维码 —— 即时认证</b></td></tr>
|
||||
</table>
|
||||
|
||||
### 安全的二维码认证
|
||||
|
||||
在手机键盘上输密码太痛苦了。Codeman 用**密码学安全的一次性二维码令牌**取而代之 —— 扫描桌面上显示的二维码,手机即刻完成认证。
|
||||
|
||||
每个二维码编码的是一个包含 6 字符短码的 URL,该短码在服务端映射到一个 256 位密钥(`crypto.randomBytes(32)`)。令牌每 **60 秒**自动轮换,**首次扫描即原子性消费**(重放永远失败),并采用**基于哈希的 `Map.get()` 查找**,不会通过响应时延泄露任何信息。短码只是一个不透明指针 —— 真正的密钥永远不会出现在浏览器历史、`Referer` 头或 Cloudflare 边缘日志中。
|
||||
|
||||
该安全设计覆盖了 ["Demystifying the (In)Security of QR Code-based Login"](https://www.usenix.org/conference/usenixsecurity25/presentation/zhang-xin)(USENIX Security 2025,该研究发现 Top-100 网站中有 47 个存在漏洞)所指出的全部 6 个关键二维码认证缺陷:强制一次性使用、短 TTL、密码学随机性、服务端生成、扫描时桌面实时通知(QRLjacking 检测),以及 IP + User-Agent 会话绑定与手动吊销。双层速率限制(按 IP + 全局)使得在 62^6 = 568 亿种可能短码空间内进行暴力破解变得不可行。完整安全分析见:[`docs/qr-auth-plan.md`](docs/qr-auth-plan.md)
|
||||
|
||||
### 触控优化界面
|
||||
|
||||
- **键盘配件栏** —— 在虚拟键盘上方提供 `/init`、`/clear`、`/compact` 快捷按钮。破坏性命令(`/clear`、`/compact`)需双击确认 —— 第一次点击「上膛」,第二次点击执行 —— 这样在颠簸的通勤路上也不会误触
|
||||
- **滑动导航** —— 在终端上左右滑动切换会话(阈值 80px,300ms)
|
||||
- **智能键盘处理** —— 键盘弹出时工具栏与终端整体上移(使用 `visualViewport` API,并对 iOS 地址栏漂移设置 100px 阈值)
|
||||
- **安全区适配** —— 通过 `env(safe-area-inset-*)` 适配 iPhone 刘海与底部 Home 指示条
|
||||
- **44px 触控目标** —— 所有按钮均满足 iOS 人机界面指南的最小尺寸
|
||||
- **底部抽屉式 case 选择器** —— 用上滑模态框替代桌面端下拉菜单
|
||||
- **原生惯性滚动** —— `-webkit-overflow-scrolling: touch`,丝滑流畅
|
||||
|
||||
```bash
|
||||
codeman web --https
|
||||
# 在手机上打开:https://<你的IP>:3000
|
||||
```
|
||||
|
||||
> `localhost` 走纯 HTTP 即可。从其他设备访问时请使用 `--https`,或使用 [Tailscale](https://tailscale.com/)(推荐)—— 它提供私有网络,让你无需 TLS 证书即可从手机访问 `http://<tailscale-ip>:3000`。
|
||||
|
||||
---
|
||||
|
||||
## 实时智能体可视化
|
||||
|
||||
实时观看后台智能体工作。Codeman 监控智能体活动,将每个智能体显示在一个可拖拽的浮动窗口中,并用「黑客帝国」风格的动态连接线连回父会话。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/subagent-spawn.png" alt="子智能体可视化" width="900">
|
||||
</p>
|
||||
|
||||
- **浮动终端窗口** —— 每个智能体一个可拖拽、可调整大小的面板,带实时活动日志,逐条展示每一次工具调用、文件读取与进度更新
|
||||
- **连接线** —— 用动态绿色线条连接父会话与其子智能体,随智能体的产生与完成实时更新
|
||||
- **状态与模型徽标** —— 绿色(活动)、黄色(空闲)、蓝色(已完成)指示,并以 Haiku/Sonnet/Opus 的颜色编码区分模型
|
||||
- **自动行为** —— 窗口在产生时自动打开、完成时自动最小化,标签徽标显示「AGENT」或「AGENTS (n)」计数
|
||||
- **嵌套智能体** —— 支持 3 层层级(主会话 → 团队成员智能体 → 子-子智能体)
|
||||
|
||||
**智能体团队(Agent Teams)** —— 一等公民式支持 Claude Code 原生的多智能体团队(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`)。`TeamWatcher` 轮询 `~/.claude/teams/`,将团队成员匹配到其主会话,并以实时子智能体窗口呈现,且具备**团队感知的空闲检测** —— 因此当团队成员仍在工作时,重生控制器不会被触发。详见 [`docs/agent-teams/`](docs/agent-teams/)。
|
||||
|
||||
---
|
||||
|
||||
## 零延迟输入叠加层
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/zerolag-demo.gif" alt="Zerolag 演示 —— 本地回显与服务端回显并排对比" width="900">
|
||||
<img src="docs/images/zerolag-demo-20260728.gif" alt="Zerolag 演示:两台手机并排对比,即时本地回显与 600ms-2.7s 服务端回显" width="900">
|
||||
</p>
|
||||
|
||||
远程访问你的编程智能体时(VPN、Tailscale、SSH 隧道),每次按键通常需要 200–300 毫秒往返。Codeman 实现了一套**受 Mosh 启发的本地回显系统**,无论延迟多高,打字都感觉即时。
|
||||
@@ -278,6 +285,30 @@ xterm.js 内部一个像素级精准的 DOM 叠加层以 0ms 渲染按键。后
|
||||
|
||||
---
|
||||
|
||||
## 实时智能体可视化
|
||||
|
||||
实时观看后台智能体工作。Codeman 监控智能体活动,将每个智能体显示在一个可拖拽的浮动窗口中,并用「黑客帝国」风格的动态连接线连回父会话。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/subagent-windows-20260724.png" alt="子智能体可视化 —— 三个并行 Explore 智能体的浮动窗口与实时工具调用日志" width="900">
|
||||
</p>
|
||||
|
||||
- **浮动终端窗口** —— 每个智能体一个可拖拽、可调整大小的面板,带实时活动日志,逐条展示每一次工具调用、文件读取与进度更新
|
||||
- **连接线** —— 用动态绿色线条连接父会话与其子智能体,随智能体的产生与完成实时更新
|
||||
- **状态与模型徽标** —— 绿色(活动)、黄色(空闲)、蓝色(已完成)指示,并以 Haiku/Sonnet/Opus 的颜色编码区分模型
|
||||
- **自动行为** —— 窗口在产生时自动打开、完成时自动最小化,标签徽标显示「AGENT」或「AGENTS (n)」计数
|
||||
- **嵌套智能体** —— 支持 3 层层级(主会话 → 团队成员智能体 → 子-子智能体)
|
||||
|
||||
多智能体 Workflow 运行(「ultracode」)同样可视化:一个浮动运行窗口实时跟踪整个工作流,展示阶段、各智能体的 token 用量与当前工具:
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/ultracode-window-20260724.png" alt="Ultracode 工作流可视化 —— 实时运行窗口,含各智能体 token 与阶段" width="900">
|
||||
</p>
|
||||
|
||||
**智能体团队(Agent Teams)** —— 一等公民式支持 Claude Code 原生的多智能体团队(`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`)。`TeamWatcher` 轮询 `~/.claude/teams/`,将团队成员匹配到其主会话,并以实时子智能体窗口呈现,且具备**团队感知的空闲检测** —— 因此当团队成员仍在工作时,重生控制器不会被触发。详见 [`docs/agent-teams/`](docs/agent-teams/)。
|
||||
|
||||
---
|
||||
|
||||
## 重生控制器(Respawn Controller)
|
||||
|
||||
自主工作的核心。当智能体进入空闲,重生控制器会检测到,发送继续提示,循环执行上下文管理命令以获得全新上下文,然后恢复工作 —— 可完全无人值守运行 **24 小时以上**。
|
||||
@@ -304,7 +335,7 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
|
||||
- **崩溃安全** —— 完整状态持久化在 `state.json` 的 `orchestrator` 键下,可在重启后存续
|
||||
- **可从 UI 或 API 驱动** —— 编排器面板,或 `POST /api/orchestrator/start` → `/approve` → `/status`(共 10 个端点)
|
||||
|
||||
> 与 Ralph(单会话自主循环)不同:编排器协调多阶段、多智能体执行。完整设计:[`docs/orchestrator-loop-architecture.md`](docs/orchestrator-loop-architecture.md)。
|
||||
> 完整设计:[`docs/orchestrator-loop-architecture.md`](docs/orchestrator-loop-architecture.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -312,14 +343,18 @@ WATCHING → IDLE DETECTED → SEND UPDATE → /clear → /init → CONTINUE →
|
||||
|
||||
运行 **20 个并行会话**且全程可见 —— 60fps 的实时 xterm.js 终端、按会话的 token 与成本跟踪、基于标签的导航,以及一键管理。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/multi-session-dashboard.png" alt="多会话仪表盘" width="800">
|
||||
</p>
|
||||
|
||||
### 持久化会话
|
||||
|
||||
每个会话都运行在 **tmux** 内 —— 会话可在服务器重启、网络中断与机器休眠后存续。启动时自动恢复,具备双重冗余。幽灵会话发现机制能找到孤立的 tmux 会话。受管会话带有环境标签,因此智能体不会杀掉自己的会话。
|
||||
|
||||
### 会话管理器与命令面板
|
||||
|
||||
`Ctrl/Cmd/Alt+K` 打开模糊搜索的会话面板;**Browse all sessions** 打开会话管理器:一份去重后的完整清单,涵盖 Codeman 所知的一切(活动会话、来自状态与生命周期历史的既往会话,以及 Claude 转录),每一行都显示其第一条与最近一条提示。
|
||||
|
||||
- **置顶(Pin)**:把会话固定到列表顶部。被置顶的会话甚至能挺过被杀掉(降级为一条轻量的已停止记录,依然可见、可恢复)。
|
||||
- **名称保留**:从会话管理器恢复既往会话时保留其原有名称,而不是生成一个新名称。
|
||||
- **跨设备标签顺序**:拖拽排序的标签顺序保存在服务端,你的排列会从桌面跟随到手机。
|
||||
|
||||
### 主机名感知的窗口标题
|
||||
|
||||
在多台主机上运行 Codeman(笔记本、开发机、NAS)?浏览器标签标题是 `codeman:<主机名>`,让你无需点进去就能分辨每个标签对应哪个后端:
|
||||
@@ -342,14 +377,6 @@ codeman web --title-hostname dev-box # codeman:dev-box(用于覆盖嘈
|
||||
|
||||
当会话需要关注时实时桌面提醒 —— `permission_prompt` 与 `elicitation_dialog` 触发关键的红色标签闪烁,`idle_prompt` 触发黄色闪烁。点击任意通知即可直接跳转到相关会话。Hook 按 case 目录自动配置。
|
||||
|
||||
### Ralph / Todo 跟踪
|
||||
|
||||
自动检测 Ralph 循环、`<promise>` 标签、TodoWrite 进度(`4/9 complete`)以及迭代计数器(`[5/50]`),并提供实时进度环与已用时间跟踪。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/ralph-tracker-8tasks-44percent.png" alt="Ralph 循环跟踪" width="800">
|
||||
</p>
|
||||
|
||||
### 运行摘要(Run Summary)
|
||||
|
||||
点击任意会话标签上的图表图标,即可看到所发生一切的时间线 —— 重生周期、token 里程碑、自动 compact 触发、空闲/工作切换、hook 事件、错误等等。
|
||||
@@ -369,6 +396,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
||||
- **自更新** —— systemd/launchd 管理下的 git-clone 安装可在 **App Settings → Updates** 中原地更新:它会检测最新发行版,自动暂存(stash)脏工作树,并在服务重启期间流式展示构建进度(npm 安装会被报告为不可更新)
|
||||
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex** 或 **Gemini**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*` 与 `GEMINI_*`/`GOOGLE_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)
|
||||
- **Docker 会话** —— 在隔离且加固的容器中运行案例。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一案例的多个会话共享一个容器;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||
- **远程 SSH 会话**:把案例指向另一台机器,让智能体在那里一个持久的远程 tmux 中运行:SSH 断连不中断任务、自动重连,还能发现并附着主机上已在运行的会话。详见 [`docs/remote-sessions.md`](docs/remote-sessions.md)
|
||||
- **Effort 与 Ultracode** —— 设置每会话的默认 effort(`low`–`max`),或启用 **ultracode**(动态多智能体工作流)。这些都只是软默认值 —— 会话中可随时用 `/effort` 切换。扩展思考预算也可配置
|
||||
- **语音输入** —— 用 Deepgram Nova-3 口述提示(带 Web Speech API 回退):切换录音、自动静音停止、实时音量表(`Ctrl+Shift+V`)
|
||||
- **图像输入** —— 直接把图片粘贴或拖放进会话
|
||||
@@ -396,6 +424,40 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
||||
|
||||
---
|
||||
|
||||
## 远程 SSH 会话
|
||||
|
||||
把案例(case)指向另一台机器,通过 SSH 让智能体**在那台机器上**运行,同时保留同样的仪表盘、移动端 UI 与自主运行特性。你的笔记本只是一扇窗口,会话本体活在远程主机上。
|
||||
|
||||
- **天生持久**:智能体运行在远程主机上一个专用的 tmux 会话里,SSH 断连、网络切换或笔记本休眠都不会中断任务。重新连接后回到同一个活跃对话。
|
||||
- **自动重连**:一个带上限退避的监视器发现 SSH 面板断开后,会静默重新附着到仍在运行的远程会话(设置中有总开关;主动杀掉的会话绝不会被复活)。
|
||||
- **发现与附着**:列出主机上已在运行的 `codeman-*` 会话(由那台机器自己的 Codeman 或其他操作者启动)并附着其一。非你所有的已附着会话在关闭标签时**只分离,绝不杀掉**。
|
||||
- **共享会话**:多个客户端可以以不同窗口尺寸同时附着同一个远程会话而互不挤压;发现列表会显示带客户端计数的「shared」徽标。
|
||||
- **注入安全**:所有 ssh 命令行都经由单一的 shell 转义构建器生成,主机/路径/身份文件字段均有模式校验。
|
||||
|
||||
在 **New Case → Remote** 中配置(主机、用户、身份文件、可选跳板机)。完整设计:[`docs/remote-sessions.md`](docs/remote-sessions.md)。
|
||||
|
||||
---
|
||||
|
||||
## 多用户模式(可选启用)
|
||||
|
||||
与一个小型互信团队共享同一个 Codeman,每人拥有自己的登录与工作空间。**默认关闭**:不加该开关时,行为与单用户完全一致。
|
||||
|
||||
用 `codeman web --multiuser`(或 `CODEMAN_MULTIUSER=1`)启用。创建第一个管理员后,可通过 CLI 或 App Settings 中的 **Users** 标签页管理用户:
|
||||
|
||||
```bash
|
||||
codeman users add alice --admin # 提示输入密码(或 --password-stdin)
|
||||
codeman users add bob # 普通用户
|
||||
codeman users list
|
||||
```
|
||||
|
||||
- **按用户的空间**:每个用户的案例位于 `~/codeman-users/<name>/cases`;会话、案例、搜索与实时事件都按属主隔离。管理员可以看到全部。
|
||||
- **可单独吊销的登录**:命名用户的密码以 scrypt 哈希保存在 `~/.codeman/users.json`;可随时禁用、重置(一次性密码)或删除账号。管理员操作审计记录在 `~/.codeman/admin-audit.jsonl`。
|
||||
- **普通用户的更安全默认值**:非管理员以 `--permission-mode auto` 运行 Claude(Anthropic 的分类器护栏模式);raw shell 会话、cron `launchCommand` 与跳过权限模式需要按用户显式授权。
|
||||
|
||||
> ⚠️ **这只是工作空间的划分,不是用户之间的沙箱。** 所有会话都以同一个操作系统账户运行,因此有心用户的智能体依然能触及他人的文件。若需要真正的隔离,请结合 **Docker 案例**,或在不同的操作系统账户下运行独立实例。参见 [`docs/multi-user-plan.md`](docs/multi-user-plan.md) 与 [`docs/security-architecture.md`](docs/security-architecture.md) 的多用户章节。
|
||||
|
||||
---
|
||||
|
||||
## 远程访问 —— Cloudflare 隧道
|
||||
|
||||
使用免费的 [Cloudflare 快速隧道](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/),从手机或本地网络外的任意设备访问 Codeman —— 无需端口转发、无需 DNS、无需静态 IP。
|
||||
@@ -519,13 +581,14 @@ URL 被刻意保持精简(`/q/` 路径 + 6 字符码 ≈ 53–56 个字符)
|
||||
|
||||
## 安全
|
||||
|
||||
Codeman 用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设计上对任何能访问到它的人都是一个远程代码执行面 —— 整套安全模型的存在就是为了控制*谁*能访问。近期加固(v0.9.0 + v0.9.5)封堵了那些常困扰自托管开发工具的浏览器驱动攻击路径。完整模型:[`docs/security-architecture.md`](docs/security-architecture.md)。**发现了漏洞?** 私下披露方式与已知限制清单见 [`SECURITY.md`](SECURITY.md)。
|
||||
Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设计上对任何能访问到它的人都是一个远程代码执行面 —— 整套安全模型的存在就是为了控制*谁*能访问。(启动权限模式可配置,见下文。)近期加固(v0.9.0 + v0.9.5)封堵了那些常困扰自托管开发工具的浏览器驱动攻击路径。完整模型:[`docs/security-architecture.md`](docs/security-architecture.md)。**发现了漏洞?** 私下披露方式与已知限制清单见 [`SECURITY.md`](.github/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)
|
||||
|
||||
@@ -661,7 +724,6 @@ 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 上下文
|
||||
```
|
||||
|
||||
@@ -675,7 +737,7 @@ Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission
|
||||
|
||||
## API
|
||||
|
||||
基于 Fastify 的 REST —— **18 个路由模块中约 160 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。所有响应都使用 `ApiResponse<T>` 信封(`{success, data}` / `{success, error, errorCode}`);`/api/v1/*` 是稳定别名。以下是一个有代表性的子集:
|
||||
基于 Fastify 的 REST —— **20 个路由模块中约 190 个处理器**,外加一条 SSE 流和一条 WebSocket 终端通道。所有响应都使用 `ApiResponse<T>` 信封(`{success, data}` / `{success, error, errorCode}`);`/api/v1/*` 是稳定别名。以下是一个有代表性的子集:
|
||||
|
||||
### 会话(Sessions)
|
||||
|
||||
@@ -685,6 +747,9 @@ Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission
|
||||
| `POST` | `/api/quick-start` | 创建 case + 启动会话(`{caseName?, mode?, effort?, envOverrides?}`) |
|
||||
| `POST` | `/api/sessions/:id/input` | 发送输入(`{input, useMux?, clientId?, seq?}` —— `clientId`+`seq` = 精确一次) |
|
||||
| `GET` | `/api/sessions/:id/output` | 读取终端输出 |
|
||||
| `GET` | `/api/sessions/unified` | 统一的活动 + 历史清单(会话管理器):`?q=&limit=` |
|
||||
| `POST` | `/api/sessions/:id/pin` | 在会话管理器中置顶 / 取消置顶(`{pinned}`) |
|
||||
| `PUT` | `/api/session-order` | 跨设备同步标签顺序(`{order: [ids]}`) |
|
||||
| `DELETE` | `/api/sessions/:id` | 删除会话 |
|
||||
|
||||
### 重生(Respawn)
|
||||
@@ -695,13 +760,6 @@ Codeman 会注册 Claude Code hook,它们 `POST /api/hook-event`(`permission
|
||||
| `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` | 配置跟踪 |
|
||||
|
||||
### 编排器(Orchestrator)
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
@@ -764,7 +822,6 @@ flowchart TB
|
||||
end
|
||||
|
||||
subgraph Detection["检测层"]
|
||||
RT["Ralph 跟踪器"]
|
||||
SW["子智能体监视器<br/><small>~/.claude/projects/*/subagents</small>"]
|
||||
TW["团队监视器<br/><small>~/.claude/teams/*</small>"]
|
||||
end
|
||||
@@ -788,7 +845,6 @@ flowchart TB
|
||||
SM --> RC
|
||||
SM --> ORC
|
||||
SM --> SS
|
||||
S1 --> RT
|
||||
S1 --> SCR
|
||||
S2 --> SCR
|
||||
RC --> SCR
|
||||
@@ -807,7 +863,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)。
|
||||
|
||||
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 941 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 28 MiB |
|
After Width: | Height: | Size: 537 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 808 KiB |
|
Before Width: | Height: | Size: 806 KiB |
@@ -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).
|
||||
|
Before Width: | Height: | Size: 894 KiB |
|
Before Width: | Height: | Size: 576 KiB |
|
After Width: | Height: | Size: 661 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
Before Width: | Height: | Size: 99 KiB |
@@ -500,6 +500,16 @@ Full feature guide: [`docker-cases.md`](docker-cases.md).
|
||||
|
||||
---
|
||||
|
||||
## 10b. Web tabs (dashboard proxy)
|
||||
|
||||
A saved dashboard URL renders as a tab, served through Codeman's own origin at `/webview/<capability>/`. User guide: [`web-tabs.md`](web-tabs.md). Three properties carry the security weight:
|
||||
|
||||
- **The proxy is exempt from cookie auth and the Origin/CSRF guard, and that is deliberate.** The iframe is sandboxed without `allow-same-origin`, so it is opaque‑origin: its requests are cross‑site, meaning the `SameSite=lax` session cookie is never attached and its writes and WS upgrades arrive with `Origin: null`. The credential is instead a 192‑bit capability in the path, minted only by an authenticated `POST /api/webviews/:id/open`, held in memory (a restart invalidates every one), rolling TTL, bound to the minting user, and granting nothing but "relay bytes to this one saved URL". ⚠️ **The Host allowlist is NOT bypassed**, so DNS‑rebinding protection is unaffected. A second `Referer`‑keyed form exists for root‑absolute assets and is the only exemption decided by a request‑supplied header, so it is fenced to safe methods on non‑`/api`, non‑`/ws`, non‑`/q` paths. Edges pinned by `test/webview-auth-exemption.test.ts`.
|
||||
- **Sandboxed by default; `allow-same-origin` is an explicit per‑dashboard opt‑in.** A proxied page is same‑origin with Codeman, so without the sandbox its JavaScript could read the Codeman document and call the agent‑spawning API. ⚠️ In BOTH modes the `Authorization` header and the `codeman_session` cookie are stripped before the upstream request, because a trusted (same‑origin) frame makes the browser attach Codeman's own Basic‑auth credentials to every proxied request; forwarding them would hand `CODEMAN_PASSWORD` to the dashboard.
|
||||
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from).
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick reference
|
||||
|
||||
| Env / flag | Effect |
|
||||
|
||||
@@ -75,5 +75,5 @@ allowance. The commitments above take effect at `1.0.0`.
|
||||
## See also
|
||||
|
||||
- `CLAUDE.md` — the COM release workflow (changesets, version bump, deploy)
|
||||
- `SECURITY.md` — security reporting and the supported-version policy
|
||||
- `.github/SECURITY.md` — security reporting and the supported-version policy
|
||||
- `docs/security-architecture.md` — the full trust model
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# Web tabs: two fixes (planned + implemented 2026-07-28)
|
||||
|
||||
Both found against the saved dashboard
|
||||
`https://macminis-mac-mini.tailf80371.ts.net:4000` (Bio-Hacking-Dashboard).
|
||||
Kept because the root-cause analysis of the second one is not obvious from the
|
||||
resulting diff.
|
||||
|
||||
Status: **both implemented and verified end-to-end.** The one deliberate
|
||||
non-change is recorded at the bottom.
|
||||
|
||||
---
|
||||
|
||||
## Bug 1: saved URLs could not be deleted from the Run dropdown
|
||||
|
||||
### What happened
|
||||
|
||||
The "Web / URL" section of the Run dropdown listed every saved dashboard as a
|
||||
single clickable row whose only action was "open". Deleting required opening the
|
||||
dashboard as a tab, clicking the tab's gear, then Delete in the modal, so a URL
|
||||
you no longer wanted open at all could not be removed without first opening it.
|
||||
|
||||
### What shipped
|
||||
|
||||
- `renderWebviewMenuItems()` (`src/web/public/webview-tabs.js`) now renders each
|
||||
saved URL as a `.run-mode-row--web` flex row: the open button, a gear
|
||||
(`showWebviewModal`), and an `x` (`deleteWebviewById`). Nested buttons are
|
||||
invalid HTML, hence the wrapper rather than a button inside a button.
|
||||
- `deleteWebview()` split into the modal entry point, the new row entry point
|
||||
`deleteWebviewById(id)`, and the shared `_confirmAndDeleteWebview(id)`.
|
||||
- Both side buttons call `event.stopPropagation()` so the click does not also
|
||||
open the dashboard.
|
||||
- The dropdown's outside-click handler (`session-ui.js`) closes when the click
|
||||
target is not inside `#runModeMenu`, and the row is gone by the time the delete
|
||||
resolves, so `deleteWebviewById` re-asserts `.active` on the menu. Verified in a
|
||||
browser: deleting one of several URLs leaves you looking at the rest of the list.
|
||||
- CSS in `styles.css` (`.run-mode-row--web`, `.run-mode-row-btn`) plus a larger
|
||||
touch target in `mobile.css`. The side buttons are permanently visible rather
|
||||
than hover-revealed, because this menu is used on touch.
|
||||
|
||||
No server change: `DELETE /api/webviews/:id` already existed, owner-scoped, and
|
||||
already revoked the capability and broadcast `WebviewChanged`.
|
||||
|
||||
---
|
||||
|
||||
## Bug 2: images did not load in a proxied dashboard
|
||||
|
||||
### Reproduction (before the fix)
|
||||
|
||||
```
|
||||
CAP=<from POST /api/webviews/<id>/open>
|
||||
# A) upstream direct -> 200 image/jpeg 118150
|
||||
curl -sk "https://macminis-mac-mini.tailf80371.ts.net:4000/api/hero?slug=120-minutes-in-nature"
|
||||
# B) through the proxy prefix -> 200 image/jpeg 118150
|
||||
curl -sk "https://localhost:3000/webview/$CAP/api/hero?slug=120-minutes-in-nature"
|
||||
# C) what the browser ACTUALLY requested -> 404 {"errorCode":"NOT_FOUND"}
|
||||
curl -sk -H "Referer: https://localhost:3000/webview/$CAP/" \
|
||||
"https://localhost:3000/api/hero?slug=120-minutes-in-nature"
|
||||
# D) same shape but NOT under /api -> 200 (referer fallback rescues it)
|
||||
curl -sk -H "Referer: https://localhost:3000/webview/$CAP/" "https://localhost:3000/styles.css"
|
||||
```
|
||||
|
||||
The proxy itself was fine (B). The failure was entirely about which URL the
|
||||
browser ended up requesting (C).
|
||||
|
||||
### Root cause
|
||||
|
||||
The dashboard builds its image markup at runtime with root-absolute URLs:
|
||||
`c.innerHTML = '<img class="thumb" src="/api/hero?slug=...">'`, `img.src =
|
||||
slideSrc(...)` returning `/api/slide?owner=...`, `/api/story`, `/api/video`, and a
|
||||
nested `<iframe src="/api/preview?slug=...">`.
|
||||
|
||||
All three rewrite layers missed that shape:
|
||||
|
||||
1. `<base href="/webview/<cap>/">` only affects **relative** URLs. A root-absolute
|
||||
`/api/hero` ignores the base path and resolves against Codeman's origin.
|
||||
2. `rewriteHtml()` only runs over the **initial HTML document**. This markup is
|
||||
created later by page script. (The static header `<img src="/api/logo">` DID
|
||||
work, having been rewritten at proxy time, which is why only the
|
||||
runtime-injected images were broken.)
|
||||
3. `runtimeUrlShim()` patched only `fetch`, `XMLHttpRequest.open`, `WebSocket` and
|
||||
`EventSource`, so the dashboard's **data** loaded while its **pictures** did
|
||||
not.
|
||||
|
||||
The safety net was fenced off from `/api` in two places, both deliberate:
|
||||
`server.ts`'s not-found handler returns the API-envelope 404 before reaching
|
||||
`tryWebviewRefererFallback`, and `middleware/auth.ts` refuses the Referer-form
|
||||
auth exemption for `/api/`, `/ws/`, `/q/`.
|
||||
|
||||
### What shipped
|
||||
|
||||
`runtimeUrlShim()` in `src/web/webview-proxy.ts` now also covers the DOM sinks, so
|
||||
a root-absolute `/api/...` request is never emitted in the first place and neither
|
||||
security fence had to move:
|
||||
|
||||
- `innerHTML` / `outerHTML` / `insertAdjacentHTML` (and `ShadowRoot.innerHTML`),
|
||||
- `setAttribute` / `setAttributeNS`,
|
||||
- the `src`/`srcset`/`href`/`poster`/`data`/`action` property setters on img,
|
||||
source, media, video poster, script, iframe, embed, track, link, anchor, area,
|
||||
object and form,
|
||||
- a `MutationObserver` as a last net for any sink not patched above (it costs one
|
||||
wasted 404 per node, since the browser starts fetching on insert, so it is a net
|
||||
and not the mechanism).
|
||||
|
||||
Two details that mattered:
|
||||
|
||||
- Every rewrite routes through the existing idempotent `rw()` rather than a blind
|
||||
prefix concat. The first draft used the server-side regex shape and
|
||||
double-prefixed markup that was already proxied (a page re-injecting its own
|
||||
`outerHTML`); the jsdom test caught it.
|
||||
- Everything stays inside `try`/`catch` and is marked `__cmrw`, so a double
|
||||
injection cannot wrap an already-wrapped setter, and nothing can throw into a
|
||||
page we do not control.
|
||||
|
||||
### Verification
|
||||
|
||||
- `test/webview-proxy.test.ts` gained a jsdom `runtimeUrlShim DOM sinks` block:
|
||||
innerHTML, insertAdjacentHTML, property setters, setAttribute, srcset candidate
|
||||
lists, the MutationObserver net via an unpatched sink
|
||||
(`createContextualFragment`), idempotence, re-injected markup, empty `src`, and
|
||||
the pass-throughs (relative, cross-origin, `#hash`, `data:`). 73 tests pass.
|
||||
- End-to-end in a real browser against an isolated instance
|
||||
(`CODEMAN_INSTANCE=wvtest`, port 3151), with prod's old build as the negative
|
||||
control:
|
||||
|
||||
| | before (prod, old build) | after (fixed) |
|
||||
| --- | --- | --- |
|
||||
| images found | 693 | 693 |
|
||||
| src under the proxy prefix | 0 | 693 |
|
||||
| in-viewport images decoded | 0 / 23 | 23 / 23 |
|
||||
| sample src | `/api/hero?slug=...` | `/webview/<cap>/api/hero?slug=...` |
|
||||
|
||||
(The dashboard marks thumbs `loading="lazy"`, so only in-viewport images are
|
||||
ever fetched. All 27 proxied image responses returned 200.)
|
||||
|
||||
---
|
||||
|
||||
## Follow-up (same day): the `/api` referer fallback, done safely
|
||||
|
||||
Originally deferred, then implemented on request. Both gates had to move, and the
|
||||
auth one is the security-sensitive half: auth runs in `onRequest`, before routing,
|
||||
so it cannot tell a real Codeman API route from a 404, and simply dropping the
|
||||
`/api` fence would let a page holding a capability forge a `Referer` and reach
|
||||
Codeman's **real** API unauthenticated.
|
||||
|
||||
What shipped:
|
||||
|
||||
- `server.ts`: `tryWebviewRefererFallback` is tried **before** the API-shaped 404.
|
||||
Reaching that handler already proves no route matched, and the relay declines
|
||||
unless the `Referer` carries a live capability, so unknown `/api` paths still
|
||||
get the envelope.
|
||||
- `middleware/auth.ts`: the `/api/` prefix refusal is replaced by
|
||||
`matchesRegisteredRoute()`, which refuses the exemption for any path that
|
||||
resolves to a real route. `/ws/` and `/q/` stay refused by prefix.
|
||||
|
||||
Two findings that decided the implementation, both established by probing Fastify
|
||||
rather than by reading its docs:
|
||||
|
||||
- **`hasRoute()` is the wrong tool and would have been a hole.** It matches the
|
||||
registered PATTERN literally, so `hasRoute({url: '/api/sessions/abc'})` returns
|
||||
false against a registered `/api/sessions/:id` and would have handed out an
|
||||
exemption on a live, session-scoped API route. `findRoute()` performs the real
|
||||
radix-tree lookup and is what the fence uses.
|
||||
- **`@fastify/static` is mounted at `/`, so it registers a root catch-all that
|
||||
matches every path.** A match on it means "heading for the 404 handler", not
|
||||
"real route", and it is distinguishable because a root catch-all is the only
|
||||
route whose `*` param comes back equal to the whole request path. Without that
|
||||
carve-out the fence would have refused every referer-form request and broken the
|
||||
rescue that already worked.
|
||||
|
||||
The fence fails closed, and `test/webview-auth-exemption.test.ts` pins both edges
|
||||
(a concrete URL onto a parametric API route stays 401; the dashboard's own
|
||||
`/api/...` namespace is served).
|
||||
|
||||
### And the CSS gap, which the fallback could NOT close
|
||||
|
||||
Testing the fallback against a purpose-built upstream showed the runtime-injected
|
||||
stylesheet case is unreachable by any relay: a `<style>` element has no URL of its
|
||||
own, so Chromium sends an **empty `Referer`** with the image request it triggers
|
||||
and there is nothing to key on. Measured directly:
|
||||
|
||||
| sink | Referer the browser sends | fixed by |
|
||||
| --- | --- | --- |
|
||||
| `url()` in a proxied `.css` | the stylesheet's proxied URL | the referer relay |
|
||||
| `url()` in a runtime `<style>` | *empty* | `rwCss()` in the shim |
|
||||
|
||||
So the shim also rewrites `url()` inside `<style>` blocks, both when they arrive as
|
||||
markup and when a `<style>` node is inserted (via the existing MutationObserver).
|
||||
|
||||
The only gap left is self-navigation via `location.href = '/x'`, which cannot be
|
||||
patched because `Location.href` is unforgeable.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Web Tabs (dashboards as Codeman tabs)
|
||||
|
||||
Open any dashboard you run, Grafana, Uptime Kuma, Portainer, a status page on port
|
||||
4000, as a tab beside your Claude/Codex/Gemini sessions. Codeman becomes one mission
|
||||
control instead of Codeman plus a pile of browser tabs.
|
||||
|
||||
## Using it
|
||||
|
||||
1. Click the chevron next to **Run** to expand the dropdown.
|
||||
2. Under **Web / URL**, pick **Add dashboard...**
|
||||
3. Give it a name and a URL, optionally hit **Test**, then **Save**.
|
||||
|
||||
The dashboard opens as a tab immediately, and appears in the Run dropdown from then
|
||||
on. Web tabs sit in the same strip as session tabs, continue the same `Alt+1..9`
|
||||
numbering, and carry a globe icon so they never read as a running agent.
|
||||
|
||||
Closing a tab (the `x`) only closes it. The saved dashboard stays in the dropdown.
|
||||
To delete it for good, use the `x` on its **dropdown row** (the tab's own `x` is
|
||||
close, not delete). Each dropdown row also has a gear for editing, so a saved URL
|
||||
can be changed or removed without opening it first.
|
||||
|
||||
Switching tabs does **not** reload a dashboard. Frames stay alive in the background,
|
||||
so a dashboard that took a while to authenticate is still there when you come back.
|
||||
Past six live frames the least-recently-viewed one is dropped to bound memory
|
||||
(`CODEMAN_MAX_LIVE_WEBVIEW_FRAMES`).
|
||||
|
||||
## Why dashboards are proxied
|
||||
|
||||
A plain `<iframe src="http://your-box:4000">` does not work in the setup Codeman
|
||||
actually ships in, for three separate reasons:
|
||||
|
||||
| Blocker | What happens |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| **Mixed content** | Production serves HTTPS (behind `tailscale serve`). Browsers hard-block `http://` iframes on an HTTPS page, with no override, and none at all on iOS Safari. |
|
||||
| **Framing refusal** | Grafana, Portainer, Home Assistant and many others send `X-Frame-Options: DENY` or `frame-ancestors 'none'`. |
|
||||
| **Codeman's CSP** | `default-src 'self'` means `frame-src` falls back to `'self'`, so a cross-origin iframe is blocked before it starts. |
|
||||
|
||||
Serving the dashboard **through Codeman's own origin** dissolves all three. So by
|
||||
default a web tab loads `/webview/<capability>/` on Codeman, and Codeman relays to
|
||||
the dashboard: stripping the framing refusal, rewriting redirects, cookies and
|
||||
root-absolute URLs, and relaying WebSockets so live panels actually update.
|
||||
|
||||
A useful consequence: the dashboard is fetched **from the Codeman server**, so a
|
||||
tailnet-only or `localhost`-only dashboard works from any device that can reach
|
||||
Codeman, including a phone that is not on the tailnet.
|
||||
|
||||
`direct` mode (a plain cross-origin iframe) still exists and is cheaper, but it only
|
||||
works for an HTTPS dashboard that permits framing. The **Test** button probes from
|
||||
the server and tells you which mode applies.
|
||||
|
||||
## The sandbox, and when to turn it off
|
||||
|
||||
Because a proxied dashboard is served from Codeman's own address, it is
|
||||
*same-origin with Codeman* as far as the browser is concerned. Left unchecked, its
|
||||
JavaScript could read the Codeman page and call the API that spawns agents.
|
||||
|
||||
So the iframe is sandboxed **without** `allow-same-origin` by default. The page runs
|
||||
in an opaque origin: it cannot touch Codeman, and it gets no cookies or
|
||||
`localStorage` of its own.
|
||||
|
||||
Unchecking **Open sandboxed** grants `allow-same-origin`. Do that only for a
|
||||
dashboard you fully trust, and only if you need it, which in practice means a
|
||||
dashboard with its own login that stores a session in a cookie or `localStorage`.
|
||||
|
||||
Even in trusted mode, Codeman never forwards its own credentials upstream: the
|
||||
`Authorization` header and the `codeman_session` cookie are stripped on the way out,
|
||||
so `CODEMAN_PASSWORD` cannot leak into a dashboard.
|
||||
|
||||
## How the proxy authenticates
|
||||
|
||||
A sandboxed iframe is opaque-origin, so every request it makes is cross-site: the
|
||||
`SameSite=lax` session cookie is not sent, and writes and WebSocket upgrades arrive
|
||||
with `Origin: null`. Cookie auth cannot work.
|
||||
|
||||
Instead, opening a dashboard mints a **capability**: 192 bits of entropy in the URL
|
||||
path, held in memory only, with a rolling 12-hour TTL, bound to the user who minted
|
||||
it, and granting exactly one thing, relaying bytes to that one saved URL. Editing or
|
||||
deleting a dashboard revokes it, and a server restart invalidates every outstanding
|
||||
capability (tabs re-mint transparently on next click).
|
||||
|
||||
## Limits and env vars
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| ------------------------------------ | ------- | ------------------------------------------ |
|
||||
| `CODEMAN_MAX_WEBVIEWS` | 50 | Saved dashboards per owner |
|
||||
| `CODEMAN_MAX_LIVE_WEBVIEW_FRAMES` | 6 | Iframes kept mounted at once |
|
||||
| `CODEMAN_WEBVIEW_CAPABILITY_TTL_MS` | 12h | Rolling capability lifetime |
|
||||
| `CODEMAN_WEBVIEW_TIMEOUT_MS` | 30000 | Upstream request timeout |
|
||||
| `CODEMAN_WEBVIEW_PROBE_TIMEOUT_MS` | 8000 | Timeout for the Test button |
|
||||
| `CODEMAN_MAX_WEBVIEW_HTML_BYTES` | 8MB | Largest HTML document rewritten |
|
||||
| `CODEMAN_MAX_WEBVIEW_SOCKETS` | 8 | Concurrent proxied WebSockets per dashboard |
|
||||
|
||||
Saved dashboards live in `~/.codeman/webviews.json`. Which tabs you have open is
|
||||
per-device (`localStorage`), since that is workspace layout rather than config.
|
||||
|
||||
## How a dashboard's own API calls keep working
|
||||
|
||||
Worth knowing, because it is where this feature does its least obvious work. Three
|
||||
layers cooperate so a dashboard talking to its own backend just works:
|
||||
|
||||
1. `<base href>` handles relative URLs in the markup.
|
||||
2. Attribute rewriting handles root-absolute `src`/`href`/`action` in the page the
|
||||
proxy serves.
|
||||
3. A small injected script rebases URLs built at **runtime**, which the first two
|
||||
cannot see: `fetch('/api/data')` and `new WebSocket('/live')`, but equally
|
||||
`card.innerHTML = '<img src="/api/hero">'`, `img.src = '/api/slide'`, and
|
||||
`url(/img.png)` inside a `<style>` the page injects. That second group is why
|
||||
images are covered too. A dashboard that renders its thumbnails from script
|
||||
would otherwise show all its data and none of its pictures, because `<base>`
|
||||
does not apply to root-absolute URLs and the attribute rewriting only ever saw
|
||||
the initial document.
|
||||
4. As a last resort, a request that still lands on Codeman's own root is relayed
|
||||
using its `Referer` to identify the dashboard. This only fires for a request
|
||||
that already missed every Codeman route, and never for one that resolves to a
|
||||
real route, which is what keeps it from being an authentication bypass.
|
||||
|
||||
On top of that, the proxy answers those requests with CORS headers. That sounds
|
||||
wrong for same-host requests, but a sandboxed iframe has an *opaque* origin, so the
|
||||
browser treats every one of its `fetch`/XHR calls as cross-origin even though the
|
||||
URL is on Codeman itself. Without those headers, a dashboard renders perfectly and
|
||||
then every API call fails, which looks like the dashboard being broken.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **Exotic loaders.** The layers above cover normal `fetch`/XHR/WebSocket/
|
||||
EventSource, normal markup, the DOM sinks a page uses to build markup at runtime,
|
||||
and `url()` inside stylesheets. Something that constructs requests by an unusual
|
||||
route can still slip through. Symptom: the page renders but a panel stays empty.
|
||||
- **Root-absolute `location` navigation.** A dashboard that navigates itself with
|
||||
`location.href = '/login'` escapes the prefix, because `Location.href` is
|
||||
unforgeable and cannot be patched the way the other sinks are. A relative
|
||||
`location.href = 'login'` is fine (`<base>` covers it).
|
||||
- **Cross-origin redirects are not followed.** If a dashboard bounces to a different
|
||||
host (an external SSO provider, say), the proxy hands the redirect back unchanged
|
||||
rather than relaying it, because relaying would make this an open proxy. Use
|
||||
**Open in new tab** for those.
|
||||
- **Login-protected dashboards need trusted mode**, since a sandboxed frame has no
|
||||
cookie jar. A server-side per-dashboard cookie jar would lift this and is the
|
||||
natural next step if it becomes annoying.
|
||||
- **Not a security boundary.** The proxy reaches whatever the Codeman server can
|
||||
reach. That is not an escalation for someone who already commands
|
||||
`--dangerously-skip-permissions` agents, but in multi-user mode it does mean a
|
||||
non-admin user's dashboard is fetched from the server's network position.
|
||||
|
||||
## Where the code lives
|
||||
|
||||
| Concern | File |
|
||||
| ------------------------ | --------------------------------------- |
|
||||
| Pure rewrite helpers | `src/web/webview-proxy.ts` |
|
||||
| Routes + proxy + sockets | `src/web/routes/webview-routes.ts` |
|
||||
| Capability tokens | `src/webview-capabilities.ts` |
|
||||
| Persistence | `src/webview-store.ts` |
|
||||
| Limits | `src/config/webview-limits.ts` |
|
||||
| Frontend | `src/web/public/webview-tabs.js` |
|
||||
| Auth exemption | `src/web/middleware/auth.ts` |
|
||||
@@ -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,21 @@ 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"
|
||||
|
||||
# Binding found in an already-installed service (read_existing_binding), used
|
||||
# so updates and re-installs preserve the user's previous choice instead of
|
||||
# silently loosening it to the new network-access default.
|
||||
EXISTING_FOUND="0"
|
||||
EXISTING_HOST=""
|
||||
EXISTING_PASSWORD=""
|
||||
EXISTING_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 +78,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 +152,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 +404,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 +807,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 +869,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 +980,206 @@ 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/&/\&/g' -e 's/</\</g' -e 's/>/\>/g'
|
||||
}
|
||||
|
||||
systemd_env_unescape() {
|
||||
printf '%s' "$1" | sed 's/\\\(["\\]\)/\1/g'
|
||||
}
|
||||
|
||||
xml_unescape() {
|
||||
printf '%s' "$1" | sed -e 's/</</g' -e 's/>/>/g' -e 's/&/\&/g'
|
||||
}
|
||||
|
||||
# Read the binding out of an already-installed service file, if any. A service
|
||||
# file WITHOUT our CODEMAN_HOST line is a pre-1.8 install, which effectively
|
||||
# ran loopback (the server default), so it reports 127.0.0.1.
|
||||
read_existing_binding() {
|
||||
EXISTING_FOUND="0"; EXISTING_HOST=""; EXISTING_PASSWORD=""; EXISTING_ACK="0"
|
||||
local unit="$HOME/.config/systemd/user/codeman-web.service"
|
||||
local plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||
|
||||
if [[ -f "$unit" ]]; then
|
||||
EXISTING_FOUND="1"
|
||||
EXISTING_HOST=$(sed -n 's/^Environment=CODEMAN_HOST=//p' "$unit" | head -1)
|
||||
local pwline
|
||||
pwline=$(sed -n 's/^Environment="CODEMAN_PASSWORD=\(.*\)"$/\1/p' "$unit" | head -1)
|
||||
[[ -n "$pwline" ]] && EXISTING_PASSWORD=$(systemd_env_unescape "$pwline")
|
||||
grep -q '^Environment=CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1' "$unit" && EXISTING_ACK="1"
|
||||
elif [[ -f "$plist" ]]; then
|
||||
EXISTING_FOUND="1"
|
||||
EXISTING_HOST=$(awk '/<key>CODEMAN_HOST<\/key>/{getline; print}' "$plist" | sed -n 's/.*<string>\(.*\)<\/string>.*/\1/p')
|
||||
local pwraw
|
||||
pwraw=$(awk '/<key>CODEMAN_PASSWORD<\/key>/{getline; print}' "$plist" | sed -n 's/.*<string>\(.*\)<\/string>.*/\1/p')
|
||||
[[ -n "$pwraw" ]] && EXISTING_PASSWORD=$(xml_unescape "$pwraw")
|
||||
grep -q '<key>CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK</key>' "$plist" && EXISTING_ACK="1"
|
||||
fi
|
||||
|
||||
if [[ "$EXISTING_FOUND" == "1" && -z "$EXISTING_HOST" ]]; then
|
||||
EXISTING_HOST="127.0.0.1"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
# A previous install's choice is the baseline: re-installing must never
|
||||
# silently loosen it.
|
||||
read_existing_binding
|
||||
|
||||
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
|
||||
if [[ "$EXISTING_FOUND" == "1" ]]; then
|
||||
BIND_HOST="$EXISTING_HOST"
|
||||
BIND_PASSWORD="$EXISTING_PASSWORD"
|
||||
BIND_ACK="$EXISTING_ACK"
|
||||
info "Non-interactive install: preserving existing binding ($BIND_HOST)"
|
||||
else
|
||||
BIND_HOST="127.0.0.1"
|
||||
info "Non-interactive install: binding 127.0.0.1 (preset CODEMAN_HOST=0.0.0.0 to override)"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Default follows the existing setup when there is one, else network.
|
||||
local default_choice="1"
|
||||
if [[ "$EXISTING_FOUND" == "1" && "$EXISTING_HOST" == "127.0.0.1" ]]; then
|
||||
default_choice="2"
|
||||
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}"
|
||||
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 ""
|
||||
if [[ "$EXISTING_FOUND" == "1" ]]; then
|
||||
echo -e " ${DIM}Current setup: $EXISTING_HOST$([[ -n "$EXISTING_PASSWORD" ]] && echo ", password set"). Enter keeps it.${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
local bind_choice=""
|
||||
while true; do
|
||||
echo -en "${CYAN}Choose [1/2] (default $default_choice):${NC} " >&2
|
||||
read_reply bind_choice || bind_choice="$default_choice"
|
||||
bind_choice="${bind_choice:-$default_choice}"
|
||||
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
|
||||
|
||||
# Keep a custom non-loopback host from a previous install (e.g. a specific
|
||||
# interface IP); otherwise bind all interfaces.
|
||||
if [[ "$EXISTING_FOUND" == "1" && -n "$EXISTING_HOST" && "$EXISTING_HOST" != "127.0.0.1" ]]; then
|
||||
BIND_HOST="$EXISTING_HOST"
|
||||
else
|
||||
BIND_HOST="0.0.0.0"
|
||||
fi
|
||||
|
||||
if [[ -n "${CODEMAN_PASSWORD:-}" ]]; then
|
||||
BIND_PASSWORD="$CODEMAN_PASSWORD"
|
||||
info "Using CODEMAN_PASSWORD from the environment"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
local pw="" pw2="" keep_hint=""
|
||||
[[ -n "$EXISTING_PASSWORD" ]] && keep_hint="Enter to keep the current one" || keep_hint="Enter to skip"
|
||||
while true; do
|
||||
echo -en "${CYAN}Set a dashboard password (recommended; $keep_hint):${NC} " >&2
|
||||
read_secret pw || pw=""
|
||||
if [[ -z "$pw" ]]; then
|
||||
if [[ -n "$EXISTING_PASSWORD" ]]; then
|
||||
BIND_PASSWORD="$EXISTING_PASSWORD"
|
||||
success "Keeping the existing password"
|
||||
break
|
||||
fi
|
||||
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 +1212,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 +1248,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 +1268,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 +1291,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 +1317,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 +1341,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 +1435,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 +1454,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 +1479,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 +1487,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 +1502,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 +1563,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 +1664,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 +1684,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 +1712,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 +1733,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 +1755,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 +1772,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 +1806,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 +1846,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 +1871,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 +1888,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 +1915,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
|
||||
@@ -1436,6 +1933,15 @@ update() {
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Reflect the service's actual binding in the closing notice. Updates
|
||||
# never rewrite the service files, so the existing choice is authoritative.
|
||||
read_existing_binding
|
||||
if [[ "$EXISTING_FOUND" == "1" ]]; then
|
||||
BIND_HOST="$EXISTING_HOST"
|
||||
BIND_PASSWORD="$EXISTING_PASSWORD"
|
||||
BIND_ACK="$EXISTING_ACK"
|
||||
fi
|
||||
|
||||
print_security_notice
|
||||
}
|
||||
|
||||
@@ -1493,6 +1999,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 +2031,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
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.5.1",
|
||||
"version": "1.9.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.5.1",
|
||||
"version": "1.9.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -34,6 +34,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"bin": {
|
||||
@@ -12332,7 +12333,7 @@
|
||||
}
|
||||
},
|
||||
"packages/xterm-zerolag-input": {
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"jsdom": "^24.1.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.5.1",
|
||||
"version": "1.9.2",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -32,25 +32,44 @@
|
||||
"changeset": "changeset",
|
||||
"version-packages": "changeset version && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs",
|
||||
"check:lockfile": "node scripts/check-lockfile-sync.mjs",
|
||||
"knip": "npx --yes knip@latest",
|
||||
"knip": "npx --yes knip@latest --config config/knip.json",
|
||||
"release": "changeset publish"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 120,
|
||||
"trailingComma": "es5",
|
||||
"endOfLine": "lf"
|
||||
},
|
||||
"workspaces": [
|
||||
".",
|
||||
"packages/*"
|
||||
],
|
||||
"keywords": [
|
||||
"claude",
|
||||
"claude-code",
|
||||
"claude-ai",
|
||||
"claude",
|
||||
"anthropic",
|
||||
"ai-agent",
|
||||
"automation",
|
||||
"opencode",
|
||||
"codex",
|
||||
"gemini-cli",
|
||||
"ai-agents",
|
||||
"agent",
|
||||
"session-manager",
|
||||
"self-hosted",
|
||||
"developer-tools",
|
||||
"tmux",
|
||||
"terminal",
|
||||
"xterm",
|
||||
"docker",
|
||||
"mosh",
|
||||
"local-echo",
|
||||
"web-dashboard",
|
||||
"cli",
|
||||
"llm",
|
||||
"autonomous-agent",
|
||||
"ralph-loop"
|
||||
"automation"
|
||||
],
|
||||
"author": "arkon",
|
||||
"license": "MIT",
|
||||
@@ -75,6 +94,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# xterm-zerolag-input
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Rewrite the `xterm-zerolag-input` package README as a value-first document and correct the drift that had accumulated against the source.
|
||||
- Added the side-by-side phone demo GIF (`docs/images/zerolag-demo-20260728.gif`) as the hero image, referenced by absolute raw URL so it renders on npmjs.com as well as GitHub. The two-phone comparison shows 0ms local echo next to a 600ms-2.7s server echo on the same session.
|
||||
- New "Why this one" comparison table, an explicit list of target use cases (SSH web clients, cloud IDEs, mobile terminals, container consoles), and a bundle-size badge (6.1 kB gzipped, measured from the ESM build).
|
||||
- Corrected the test-count badge from 78 to the actual 175 tests across 5 files, in both the package README and the Published Packages section of the root README.
|
||||
- Removed the stale "Unicode/emoji rendered at single-cell width" limitation. CJK, fullwidth forms and emoji have had double-width rendering and visual-column positioning since the wide-character fix; the honest remaining caveat (per-code-point width summing over-counts ZWJ grapheme clusters) replaces it.
|
||||
- Documented the previously undocumented public `setPrompt()` method for switching prompt strategies at runtime, and the new "Wide characters (CJK, emoji)" integration section covering the optional `Unicode11Addon` path and the built-in range-table fallback.
|
||||
- Documented `backgroundColor: 'transparent'`, corrected the `foregroundColor` default, and updated the grid-alignment math to reflect visual-column positioning rather than character index.
|
||||
|
||||
No source changes, docs only.
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
<p align="center">
|
||||
<h1 align="center">xterm-zerolag-input</h1>
|
||||
<p align="center">
|
||||
Instant keystroke feedback overlay for <a href="https://xtermjs.org/">xterm.js</a><br>
|
||||
<em>Eliminates perceived input latency over high-RTT connections</em>
|
||||
<strong>Make typing feel instant in <a href="https://xtermjs.org/">xterm.js</a>, no matter how far away the server is.</strong><br>
|
||||
<em>A pixel-perfect local echo overlay. Client-side only. Zero dependencies.</em>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/xterm-zerolag-input"><img src="https://img.shields.io/npm/v/xterm-zerolag-input?style=flat-square&color=22c55e" alt="npm"></a>
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="MIT"></a>
|
||||
<img src="https://img.shields.io/badge/Dependencies-0-22c55e?style=flat-square" alt="Zero deps">
|
||||
<img src="https://img.shields.io/badge/Tests-78-22c55e?style=flat-square" alt="78 tests">
|
||||
<img src="https://img.shields.io/badge/xterm.js-v5%20%7C%20v7+-3b82f6?style=flat-square" alt="xterm.js">
|
||||
<img src="https://img.shields.io/badge/Dependencies-0-22c55e?style=flat-square" alt="Zero dependencies">
|
||||
<img src="https://img.shields.io/badge/Size-6.1%20kB%20gzip-22c55e?style=flat-square" alt="6.1 kB gzipped">
|
||||
<img src="https://img.shields.io/badge/Tests-175-22c55e?style=flat-square" alt="175 tests">
|
||||
<img src="https://img.shields.io/badge/xterm.js-v5%20%7C%20v7+-3b82f6?style=flat-square" alt="xterm.js v5 and v7+">
|
||||
</p>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/zerolag-demo-20260728.gif" alt="Side-by-side phones typing into the same remote session: with zerolag the text appears at 0ms, without it every keystroke waits 600ms to 2.7s for the server echo" width="900">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<em>Two phones, the same remote session, the same slow link.<br>
|
||||
Left: the zerolag overlay paints every keystroke at <strong>0ms</strong>. Right: stock xterm.js waits <strong>600ms to 2.7s</strong> for the server to echo it back.</em>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
## The 30-second version
|
||||
|
||||
When using xterm.js over a remote connection (SSH web clients, cloud IDEs, mobile terminals), every keystroke takes a full round-trip to the server before appearing on screen. At 100-500ms RTT, typing feels sluggish and unresponsive. Users type blind, make mistakes they can't see, and the experience feels broken.
|
||||
Over a remote connection, xterm.js shows you a character only after it has flown to the server and back. At 100-500ms RTT that reads as broken: you type ahead of the screen, you cannot see your typos, and you start pecking one key at a time to stay in sync.
|
||||
|
||||
## The Solution
|
||||
|
||||
`xterm-zerolag-input` renders typed characters **immediately** as a pixel-perfect DOM overlay positioned on the terminal's character grid. The overlay covers the terminal canvas at the prompt location, showing characters instantly while the server echo travels back. Once the server responds, the overlay seamlessly disappears and the real terminal text takes over.
|
||||
`xterm-zerolag-input` paints your keystrokes **immediately**, as an absolutely-positioned DOM overlay locked to the terminal's character grid. When the server echo lands 300ms later, the overlay clears and the real terminal text takes over. The handoff is invisible.
|
||||
|
||||
```
|
||||
Keystroke Flow:
|
||||
┌─── DOM overlay (instant, 0ms)
|
||||
User types 'h' ─── onData('h') ───┤
|
||||
└─── Your app sends to PTY ──→ Server
|
||||
@@ -32,14 +39,24 @@ User types 'h' ─── onData('h') ───┤
|
||||
Server echoes 'h' ←──────────────────────────────────────────────────┘
|
||||
│ (200-500ms RTT)
|
||||
└──→ terminal.write('h') ──→ overlay.clear()
|
||||
(server output replaces overlay — seamless transition)
|
||||
(server output replaces overlay, seamless transition)
|
||||
```
|
||||
|
||||
**No changes to your backend needed.** The addon is purely client-side.
|
||||
**No backend changes. No protocol. No server support.** It is a client-side addon that never touches the wire.
|
||||
|
||||
## Origin
|
||||
## Why this one
|
||||
|
||||
This library was extracted from [Codeman](https://github.com/Ark0N/Codeman), mission control for AI coding agents — multi-session management, real-time agent visualization, autonomous respawn loops, and a mobile-first web UI for Claude Code, OpenCode, and Codex. The local echo system was built to make mobile and remote access feel instant, then battle-tested across thousands of hours of real usage. After 3 deep code audits, it was extracted into this standalone library with 78 tests covering every state transition.
|
||||
| | |
|
||||
|---|---|
|
||||
| **Survives full-screen TUIs** | Ink, blessed, and friends repaint the whole screen constantly. The overlay is a separate DOM layer they cannot reach, so it does not get clobbered. |
|
||||
| **Pixel-matched to the canvas** | Each character is its own absolutely-positioned `<span>` at exact cell coordinates, so it does not drift out of the grid like normal DOM text flow. |
|
||||
| **Wide characters included** | CJK, fullwidth forms and emoji render double-width and position by visual column, using the terminal's Unicode addon when one is loaded. |
|
||||
| **Backspace that actually works** | A three-layer cascade (unsent, in-flight, already on screen) tells you exactly what to forward to the PTY, so editing works through any mix of typed, flushed and tab-completed text. |
|
||||
| **You keep control of input** | The addon never hooks `onData` for you. You decide what gets echoed and what gets forwarded, which is what makes char-at-a-time, buffered, and multi-session tab switching all possible. |
|
||||
| **Small and self-contained** | 6.1 kB gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. |
|
||||
| **Proven under load** | Extracted from a production app, hardened over thousands of hours of real remote and mobile usage, 175 tests over every state transition. |
|
||||
|
||||
Built for anything that puts a terminal behind a network hop: SSH web clients, cloud IDEs, mobile terminals, Kubernetes and container consoles, remote agent dashboards, browser-based dev environments.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -47,12 +64,9 @@ This library was extracted from [Codeman](https://github.com/Ark0N/Codeman), mis
|
||||
npm install xterm-zerolag-input
|
||||
```
|
||||
|
||||
- **Zero runtime dependencies**
|
||||
- Compatible with both `xterm` (pre-5.4) and `@xterm/xterm` (5.4+)
|
||||
- Dual CJS/ESM build with full TypeScript declarations
|
||||
- Works with canvas, WebGL, and DOM renderers
|
||||
Works with both `xterm` (pre-5.4) and `@xterm/xterm` (5.4+), and with the canvas, WebGL and DOM renderers.
|
||||
|
||||
## Quick Start
|
||||
## Quick start
|
||||
|
||||
```typescript
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
@@ -61,7 +75,7 @@ import { ZerolagInputAddon } from 'xterm-zerolag-input';
|
||||
const terminal = new Terminal();
|
||||
terminal.open(document.getElementById('terminal')!);
|
||||
|
||||
// 1. Create addon with your prompt character
|
||||
// 1. Create the addon with your prompt character
|
||||
const zerolag = new ZerolagInputAddon({
|
||||
prompt: { type: 'character', char: '$', offset: 2 },
|
||||
});
|
||||
@@ -75,7 +89,7 @@ terminal.onData((data) => {
|
||||
ws.send(text + '\r');
|
||||
} else if (data === '\x7f') {
|
||||
const source = zerolag.removeChar();
|
||||
if (source === 'flushed') ws.send(data); // only backspace text already in PTY
|
||||
if (source === 'flushed') ws.send(data); // only backspace text already in the PTY
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
zerolag.addChar(data);
|
||||
}
|
||||
@@ -87,26 +101,29 @@ terminal.onWriteParsed(() => {
|
||||
});
|
||||
```
|
||||
|
||||
## Why This Is Hard
|
||||
That is the whole integration. Everything below is for tuning it.
|
||||
|
||||
Most terminal UIs can't do local echo because:
|
||||
## Why this is hard
|
||||
|
||||
1. **Buffer writes corrupt**: Frameworks like [Ink](https://github.com/vadimdemedes/ink) (React for terminals) redraw the entire screen on every state change. Writing directly to the terminal buffer gets immediately overwritten.
|
||||
Most terminal UIs cannot do local echo, for three reasons:
|
||||
|
||||
2. **Cursor position lies**: In Ink, `buffer.cursorY` reflects internal state (near the status bar), not the visible prompt. You can't trust it.
|
||||
1. **Buffer writes get corrupted.** Frameworks like [Ink](https://github.com/vadimdemedes/ink) (React for terminals) redraw the entire screen on every state change. Anything written straight into the terminal buffer is overwritten immediately.
|
||||
|
||||
3. **Font matching**: Canvas/WebGL renderers use their own text shaping. A DOM overlay must pixel-match the canvas grid — normal DOM text flow drifts due to sub-pixel glyph width differences.
|
||||
2. **Cursor position lies.** In Ink, `buffer.cursorY` reflects internal render state (often near a status bar), not the visible prompt. You cannot trust it.
|
||||
|
||||
This library solves all three by:
|
||||
- Using a **DOM overlay** that Ink can't touch (separate z-index layer)
|
||||
- **Scanning the buffer** bottom-up for the prompt character instead of trusting cursor position
|
||||
- Rendering each character as an **absolutely-positioned `<span>`** at exact cell-grid coordinates
|
||||
3. **Fonts do not line up.** Canvas and WebGL renderers do their own text shaping. A DOM overlay has to pixel-match that grid, and normal DOM text flow drifts as sub-pixel glyph widths accumulate.
|
||||
|
||||
This library answers all three:
|
||||
|
||||
- a **DOM overlay** on its own z-index layer, which Ink cannot touch
|
||||
- **bottom-up buffer scanning** for the prompt instead of trusting the cursor
|
||||
- **one absolutely-positioned `<span>` per character** at exact cell-grid coordinates
|
||||
|
||||
---
|
||||
|
||||
## Prompt Detection
|
||||
## Prompt detection
|
||||
|
||||
The addon needs to know where user input starts. It scans the terminal buffer bottom-up for the prompt. Three strategies:
|
||||
The addon needs to know where user input starts. It scans the terminal buffer bottom-up. Three strategies:
|
||||
|
||||
### Character (default)
|
||||
|
||||
@@ -118,17 +135,17 @@ The addon needs to know where user input starts. It scans the terminal buffer bo
|
||||
{ type: 'character', char: '%', offset: 2 }
|
||||
|
||||
// Fish / Starship: ❯
|
||||
{ type: 'character', char: '\u276f', offset: 2 }
|
||||
{ type: 'character', char: '❯', offset: 2 }
|
||||
|
||||
// Simple arrow: >
|
||||
{ type: 'character', char: '>', offset: 2 }
|
||||
```
|
||||
|
||||
`offset` = characters between the prompt marker and where user input begins (e.g., `"$ "` = 2).
|
||||
`offset` = characters between the prompt marker and where user input begins (`"$ "` = 2).
|
||||
|
||||
### Regex
|
||||
|
||||
For complex prompts. The `g` flag is safely stripped to prevent `lastIndex` mutation.
|
||||
For complex prompts. The `g` flag is stripped safely, so there is no `lastIndex` mutation.
|
||||
|
||||
```typescript
|
||||
{ type: 'regex', pattern: /\$\s*$/, offset: 2 }
|
||||
@@ -150,77 +167,88 @@ Full control:
|
||||
}
|
||||
```
|
||||
|
||||
### Switching prompts at runtime
|
||||
|
||||
If one terminal hosts several CLIs with different prompts, swap the strategy in place:
|
||||
|
||||
```typescript
|
||||
zerolag.setPrompt({ type: 'character', char: '❯', offset: 2 });
|
||||
```
|
||||
|
||||
`setPrompt()` clears the cached prompt position and re-renders if anything is pending, so a mode switch cannot leave the overlay pinned to the old column.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
## API reference
|
||||
|
||||
### `ZerolagInputAddon`
|
||||
|
||||
Implements xterm.js `ITerminalAddon`. The addon does **not** hook `terminal.onData()` — you wire your own input handler and call these methods. This gives you full control over which keystrokes are echoed vs forwarded.
|
||||
Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not** hook `terminal.onData()`: you wire your own handler and call these methods, which is what gives you control over which keystrokes are echoed and which are forwarded.
|
||||
|
||||
### Input
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on first keystroke. |
|
||||
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on the first keystroke. |
|
||||
| `appendText(text)` | `void` | Append multiple characters (paste). |
|
||||
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove last char. See [backspace handling](#backspace-handling). |
|
||||
| `clear()` | `void` | Clear all state, hide overlay. Call on Enter/Ctrl+C/Escape. |
|
||||
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character. See [backspace handling](#backspace-handling). |
|
||||
| `clear()` | `void` | Clear all state and hide the overlay. Call on Enter, Ctrl+C, Escape. |
|
||||
|
||||
### Backspace Handling
|
||||
### Backspace handling
|
||||
|
||||
`removeChar()` cascades through three layers and tells you what it removed:
|
||||
|
||||
| Return | Source | Your action |
|
||||
|--------|--------|-------------|
|
||||
| `'pending'` | Unsent text (never transmitted to PTY) | Do nothing |
|
||||
| `'flushed'` | Text already sent to PTY | Send `\x7f` backspace to PTY |
|
||||
| `'pending'` | Unsent text (never transmitted to the PTY) | Do nothing |
|
||||
| `'flushed'` | Text already sent to the PTY | Send `\x7f` to the PTY |
|
||||
| `false` | Nothing to remove | Do nothing |
|
||||
|
||||
The cascade: pending text first, then flushed text, then auto-detect buffer text (handles tab completion). This means backspace "just works" through any combination of typed, flushed, and tab-completed text.
|
||||
The cascade order is pending text, then flushed text, then auto-detected buffer text (which is what makes backspace work after tab completion). Backspace "just works" across any combination of typed, in-flight and completed text.
|
||||
|
||||
### Flushed Text
|
||||
### Flushed text
|
||||
|
||||
"Flushed" = sent to PTY but echo hasn't arrived yet. Happens during tab switches and tab completion.
|
||||
"Flushed" means sent to the PTY but the echo has not arrived yet. This happens during tab switches and tab completion.
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `setFlushed(count, text, render?)` | Mark text as flushed. Pass `render=false` during tab-switch restore (buffer not loaded yet). |
|
||||
| `setFlushed(count, text, render?)` | Mark text as flushed. Pass `render=false` during tab-switch restore, when the buffer is not loaded yet. |
|
||||
| `getFlushed()` | Returns `{ count, text }`. |
|
||||
| `clearFlushed()` | Clear flushed state when server echo arrives. |
|
||||
| `clearFlushed()` | Clear flushed state once the server echo arrives. |
|
||||
|
||||
### Buffer Detection
|
||||
### Buffer detection
|
||||
|
||||
Scan the terminal for text that exists after the prompt but wasn't typed through the overlay.
|
||||
Finds text that exists after the prompt but was never typed through the overlay.
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `detectBufferText()` | Scan and return detected text (or `null`). Sets it as flushed. Guarded: runs once per `clear()` cycle. |
|
||||
| `detectBufferText()` | Scan and return the detected text (or `null`), marking it flushed. Guarded: runs once per `clear()` cycle. |
|
||||
| `resetBufferDetection()` | Re-enable detection. |
|
||||
| `suppressBufferDetection()` | Block detection until next `clear()`. Use for sessions with UI framework text after the prompt. |
|
||||
| `undoDetection()` | Undo last detection — clears flushed state, re-enables detection. For tab completion retry. |
|
||||
| `suppressBufferDetection()` | Block detection until the next `clear()`. Use for sessions that render UI framework text after the prompt. |
|
||||
| `undoDetection()` | Undo the last detection: clears flushed state and re-enables detection. For tab-completion retries. |
|
||||
|
||||
### Rendering
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `rerender()` | Force re-render. Call after buffer reloads, screen redraws, resizes, reconnects. |
|
||||
| `refreshFont()` | Re-cache font properties from terminal. Call after font size or theme changes. |
|
||||
| `rerender()` | Force a re-render. Call after buffer reloads, screen redraws, resizes and reconnects. |
|
||||
| `refreshFont()` | Re-cache font and color properties from the terminal. Call after a font size or theme change. |
|
||||
|
||||
### Prompt Utilities
|
||||
### Prompt
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `findPrompt()` | Find prompt position. Returns `{ row, col }` or `null`. |
|
||||
| `readPromptText()` | Read text after prompt marker. Returns string or `null`. |
|
||||
| `setPrompt(finder)` | Replace the prompt detection strategy at runtime. |
|
||||
| `findPrompt()` | Find the prompt position. Returns `{ row, col }` or `null`. |
|
||||
| `readPromptText()` | Read the text after the prompt marker. Returns a string or `null`. |
|
||||
|
||||
### State
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `pendingText` | `string` | Unacknowledged text (read-only) |
|
||||
| `hasPending` | `boolean` | `true` if overlay has any content |
|
||||
| `state` | `ZerolagInputState` | Full snapshot: pendingText, flushedLength, flushedText, visible, promptPosition |
|
||||
| `hasPending` | `boolean` | `true` if the overlay has any content |
|
||||
| `state` | `ZerolagInputState` | Full snapshot: `pendingText`, `flushedLength`, `flushedText`, `visible`, `promptPosition` |
|
||||
|
||||
### Options
|
||||
|
||||
@@ -228,23 +256,23 @@ Scan the terminal for text that exists after the prompt but wasn't typed through
|
||||
{
|
||||
prompt?: PromptFinder, // Default: { type: 'character', char: '>', offset: 2 }
|
||||
zIndex?: number, // Default: 7
|
||||
backgroundColor?: string, // Default: from terminal theme
|
||||
foregroundColor?: string, // Default: from computed .xterm-rows style
|
||||
backgroundColor?: string, // Default: terminal theme background ('transparent' to disable)
|
||||
foregroundColor?: string, // Default: terminal theme / computed .xterm-rows style
|
||||
showCursor?: boolean, // Default: true
|
||||
cursorColor?: string, // Default: from terminal theme
|
||||
cursorColor?: string, // Default: terminal theme cursor
|
||||
scrollDebounceMs?: number, // Default: 50
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Patterns
|
||||
## Integration patterns
|
||||
|
||||
### Buffered Input (hold until Enter)
|
||||
### Buffered input (hold until Enter)
|
||||
|
||||
The quick start example above. Characters accumulate in the overlay and are sent on Enter. Best for remote shells where you want to batch input.
|
||||
The quick start above. Characters accumulate in the overlay and go out on Enter. Best for remote shells where you want to batch input.
|
||||
|
||||
### Char-at-a-Time (send immediately)
|
||||
### Char-at-a-time (send immediately)
|
||||
|
||||
```typescript
|
||||
terminal.onData((data) => {
|
||||
@@ -256,12 +284,14 @@ terminal.onData((data) => {
|
||||
ws.send(data);
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
zerolag.addChar(data);
|
||||
ws.send(data); // send immediately — overlay shows while echo travels back
|
||||
ws.send(data); // overlay shows the char while the echo travels back
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Tab Switching (multi-session)
|
||||
This is the mode that keeps shell features intact: tab completion, `Ctrl+R` history search, and readline bindings all still work, because every byte still reaches the PTY.
|
||||
|
||||
### Tab switching (multi-session)
|
||||
|
||||
```typescript
|
||||
function switchToSession(newId: string) {
|
||||
@@ -280,19 +310,19 @@ function switchToSession(newId: string) {
|
||||
const saved = savedState.get(newId);
|
||||
if (saved) zerolag.setFlushed(saved.count, saved.text, false); // silent
|
||||
|
||||
// Render after buffer loads
|
||||
// Render after the buffer loads
|
||||
terminal.write('', () => zerolag.rerender());
|
||||
}
|
||||
```
|
||||
|
||||
### Tab Completion
|
||||
### Tab completion
|
||||
|
||||
```typescript
|
||||
const baseline = zerolag.readPromptText();
|
||||
zerolag.clear();
|
||||
sendToPty('\t');
|
||||
|
||||
// After response:
|
||||
// After the response:
|
||||
zerolag.resetBufferDetection();
|
||||
const detected = zerolag.detectBufferText();
|
||||
if (detected && detected !== baseline) {
|
||||
@@ -302,7 +332,7 @@ if (detected && detected !== baseline) {
|
||||
}
|
||||
```
|
||||
|
||||
### Resize / Font / Reconnect
|
||||
### Resize, font, reconnect
|
||||
|
||||
```typescript
|
||||
fitAddon.fit();
|
||||
@@ -311,14 +341,31 @@ zerolag.rerender();
|
||||
terminal.options.fontSize = 18;
|
||||
zerolag.refreshFont();
|
||||
|
||||
function onReconnect() { zerolag.rerender(); }
|
||||
function onReconnect() {
|
||||
zerolag.rerender();
|
||||
}
|
||||
```
|
||||
|
||||
### Wide characters (CJK, emoji)
|
||||
|
||||
Wide characters work out of the box: the overlay measures each character's cell width, renders double-width spans for wide ones, and positions later characters by visual column instead of character index. Line wrapping is computed in columns too, so a wrapped Japanese or Chinese line lands on the same cells the server will use.
|
||||
|
||||
For exact Unicode 11+ widths, load xterm's Unicode addon and the overlay will defer to it:
|
||||
|
||||
```typescript
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11';
|
||||
|
||||
terminal.loadAddon(new Unicode11Addon());
|
||||
terminal.unicode.activeVersion = '11';
|
||||
```
|
||||
|
||||
Without it, a built-in range table covers Hangul, Kana, CJK Unified (including Ext A through G), fullwidth forms and the emoji planes.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
## How it works
|
||||
|
||||
### DOM Structure
|
||||
### DOM structure
|
||||
|
||||
```
|
||||
div.xterm-screen (position: relative)
|
||||
@@ -326,53 +373,59 @@ div.xterm-screen (position: relative)
|
||||
├── div.xterm-selection (z-index: 1)
|
||||
├── div.xterm-helpers (z-index: 5)
|
||||
├── div.xterm-decoration-container (z-index: 6-7)
|
||||
└── div[zerolag overlay] (z-index: 7) ← our overlay (invisible to Ink)
|
||||
└── div[zerolag overlay] (z-index: 7) ← our overlay, invisible to Ink
|
||||
```
|
||||
|
||||
### Per-Character Grid Alignment
|
||||
### Per-character grid alignment
|
||||
|
||||
Each character is an absolutely-positioned `<span>`:
|
||||
|
||||
```
|
||||
left = charIndex * cellWidth (CSS pixels)
|
||||
top = lineIndex * cellHeight (CSS pixels)
|
||||
width = cellWidth (exact cell width)
|
||||
left = visualColumn * cellWidth (CSS pixels)
|
||||
top = lineIndex * cellHeight (CSS pixels)
|
||||
width = cellWidth * charCellWidth (1 cell, or 2 for wide characters)
|
||||
```
|
||||
|
||||
This avoids sub-pixel drift from normal DOM text flow.
|
||||
Positioning by visual column instead of letting the browser lay out text is what removes sub-pixel drift.
|
||||
|
||||
### Font Matching
|
||||
### Font matching
|
||||
|
||||
1. `fontFamily`, `fontSize`, `fontWeight` from `terminal.options`
|
||||
2. `letterSpacing` from computed style of `.xterm-rows`
|
||||
3. `-webkit-font-smoothing: antialiased` (matches canvas grayscale)
|
||||
2. `letterSpacing` from the computed style of `.xterm-rows`
|
||||
3. `-webkit-font-smoothing: antialiased` (matches canvas grayscale AA)
|
||||
4. `font-feature-settings: 'liga' 0, 'calt' 0` (no ligatures)
|
||||
5. `text-rendering: geometricPrecision`
|
||||
|
||||
### Cell Dimensions
|
||||
### Cell dimensions
|
||||
|
||||
- **xterm.js v5.x**: `terminal._core._renderService.dimensions.css.cell` (private API)
|
||||
- **xterm.js v7+**: `terminal.dimensions.css.cell` (public API, auto-detected)
|
||||
|
||||
### Prompt Column Locking
|
||||
### Prompt column locking
|
||||
|
||||
When flushed text exists, the prompt column is locked to prevent jitter from full-screen redraws. Row changes are allowed (output can scroll the prompt).
|
||||
While flushed text exists the prompt column is locked, so a full-screen redraw cannot make the overlay jitter sideways. Row changes are still allowed, because output legitimately scrolls the prompt.
|
||||
|
||||
### Scroll Awareness
|
||||
### Scroll awareness
|
||||
|
||||
Overlay hides when scrolled up (`viewportY !== baseY`). Debounced re-render when scrolling back to bottom.
|
||||
The overlay hides when the viewport is scrolled up (`viewportY !== baseY`) and re-renders, debounced, when you scroll back to the bottom.
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
## Known limitations
|
||||
|
||||
- **Canvas/WebGL font mismatch**: Minor sub-pixel differences possible. Per-character absolute positioning minimizes this.
|
||||
- **Unicode/emoji**: Multi-byte characters occupy variable cell widths — rendered at single-cell width, causing misalignment.
|
||||
- **Password prompts**: Overlay shows characters that aren't echoed. Call `clear()` when you detect no-echo mode.
|
||||
- **Prompt in output**: If `$` appears in command output, prompt detection may find the wrong position. Use regex or custom finder.
|
||||
- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character absolute positioning keeps them small.
|
||||
- **Grapheme clusters**: widths are summed per code point, so ZWJ emoji sequences (for example 👨👩👧) and combining marks can be over-counted. Single-code-point emoji and CJK are correct.
|
||||
- **Password prompts**: the overlay will happily show characters the server is not echoing. Call `clear()` when you detect a no-echo prompt.
|
||||
- **Prompt characters in output**: if your prompt marker also appears in command output, detection can latch onto the wrong line. Use a regex or a custom finder.
|
||||
|
||||
---
|
||||
|
||||
## Origin
|
||||
|
||||
Extracted from [**Codeman**](https://github.com/Ark0N/Codeman), mission control for AI coding agents: multi-session management, live agent visualization, autonomous respawn loops, and a mobile-first web UI for Claude Code, OpenCode, Codex and Gemini.
|
||||
|
||||
The local echo system was built to make phone and remote access feel instant, ran in production for thousands of hours, then survived three deep code audits before being pulled out into this standalone library. The demo above is a real Codeman session on two phones.
|
||||
|
||||
## License
|
||||
|
||||
MIT — [Codeman](https://github.com/Ark0N/Codeman) Contributors
|
||||
MIT, [Codeman](https://github.com/Ark0N/Codeman) Contributors
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xterm-zerolag-input",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"description": "Instant keystroke feedback overlay for xterm.js — eliminates perceived input latency over high-RTT connections",
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -50,7 +50,7 @@ async function newCtx(browser) {
|
||||
try {
|
||||
localStorage.setItem('codeman:skin', skin);
|
||||
localStorage.setItem('codeman-font-size', String(font));
|
||||
const blob = { skin, showFileBrowser: false, showProjectInsights: false };
|
||||
const blob = { skin, showFileBrowser: false, showProjectInsights: false, showTokenCount: 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;
|
||||
|
||||
@@ -79,6 +79,7 @@ const main = async () => {
|
||||
showMonitor: false,
|
||||
showSubagents: false,
|
||||
showProjectInsights: false,
|
||||
showTokenCount: false,
|
||||
};
|
||||
if (planUsage) blob.showPlanUsageLimits = true;
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify(blob));
|
||||
|
||||
@@ -126,6 +126,7 @@ async function capture() {
|
||||
showMonitor: false,
|
||||
showProjectInsights: false,
|
||||
showFileBrowser: false,
|
||||
showTokenCount: false,
|
||||
});
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify(existing));
|
||||
});
|
||||
@@ -230,6 +231,7 @@ async function capture() {
|
||||
showMonitor: false,
|
||||
showProjectInsights: false,
|
||||
showFileBrowser: false,
|
||||
showTokenCount: false,
|
||||
});
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify(existing));
|
||||
});
|
||||
|
||||
@@ -221,6 +221,7 @@ async function configureSettings(page) {
|
||||
subagentTrackingEnabled: true,
|
||||
subagentActiveTabOnly: false, // Show all subagents regardless of active tab
|
||||
showMonitor: true,
|
||||
showTokenCount: false,
|
||||
};
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify(settings));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Limits and timeouts for web tabs (dashboards embedded as Codeman tabs).
|
||||
*
|
||||
* Every value here bounds something an untrusted-ish upstream controls: how many
|
||||
* dashboards can be saved, how long the server will wait on one, how much of a
|
||||
* response it will buffer before rewriting HTML, and how many sockets a single
|
||||
* dashboard may hold open. Env-overridable in the same style as the other config
|
||||
* modules.
|
||||
*/
|
||||
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const parsed = parseInt(process.env[name] || '', 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** Max saved webviews (per owner in multi-user mode). */
|
||||
export const MAX_WEBVIEWS = envInt('CODEMAN_MAX_WEBVIEWS', 50);
|
||||
|
||||
/**
|
||||
* Max iframes kept mounted at once. Switching tabs must not reload a dashboard,
|
||||
* so frames stay alive while hidden; past this many, the least-recently-viewed
|
||||
* frame is evicted. Consumed by the frontend via `GET /api/webviews`.
|
||||
*/
|
||||
export const MAX_LIVE_WEBVIEW_FRAMES = envInt('CODEMAN_MAX_LIVE_WEBVIEW_FRAMES', 6);
|
||||
|
||||
/** How long a minted proxy capability stays valid (rolling, refreshed on use). */
|
||||
export const WEBVIEW_CAPABILITY_TTL_MS = envInt('CODEMAN_WEBVIEW_CAPABILITY_TTL_MS', 12 * 60 * 60 * 1000);
|
||||
|
||||
/** Max concurrent capabilities held in memory before the oldest are dropped. */
|
||||
export const MAX_WEBVIEW_CAPABILITIES = 200;
|
||||
|
||||
/** Upstream request timeout for a proxied HTTP request. */
|
||||
export const WEBVIEW_UPSTREAM_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_TIMEOUT_MS', 30_000);
|
||||
|
||||
/** Shorter timeout for the editor's "Test" probe, which a human is waiting on. */
|
||||
export const WEBVIEW_PROBE_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_PROBE_TIMEOUT_MS', 8_000);
|
||||
|
||||
/**
|
||||
* Max bytes of an HTML response buffered for `<base>` injection and link
|
||||
* rewriting. Larger HTML documents stream through untouched: the rewrite is a
|
||||
* convenience, and buffering an unbounded upstream body is a memory hazard.
|
||||
*/
|
||||
export const MAX_WEBVIEW_HTML_REWRITE_BYTES = envInt('CODEMAN_MAX_WEBVIEW_HTML_BYTES', 8 * 1024 * 1024);
|
||||
|
||||
/** Max concurrent proxied WebSockets per webview (mirrors MAX_WS_PER_SESSION). */
|
||||
export const MAX_WEBVIEW_SOCKETS = envInt('CODEMAN_MAX_WEBVIEW_SOCKETS', 8);
|
||||
|
||||
/** URL path prefix the proxy is mounted at. Single source of truth. */
|
||||
export const WEBVIEW_PROXY_PREFIX = '/webview';
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
RemoteCase,
|
||||
RemoteCommandMode,
|
||||
RemoteHost,
|
||||
RemoteSessionInfo,
|
||||
RemoteSshOptions,
|
||||
SessionMode,
|
||||
SessionRemote,
|
||||
@@ -173,6 +174,14 @@ export interface RemoteTmuxCheckResult {
|
||||
export async function checkRemoteTmuxAvailable(
|
||||
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
|
||||
): Promise<RemoteTmuxCheckResult> {
|
||||
// Under vitest, never open a real ssh connection — mirrors TmuxManager's
|
||||
// no-op-shell-under-VITEST (IS_TEST_MODE). Without this, remote-case
|
||||
// create-path tests hit a real ~10s ssh timeout. The command construction is
|
||||
// covered by buildRemoteTmuxCheckCommand unit tests; only the live probe is
|
||||
// short-circuited here.
|
||||
if (process.env.VITEST) {
|
||||
return { ok: true, tmuxPath: '(test-mode)' };
|
||||
}
|
||||
const command = buildRemoteTmuxCheckCommand(host);
|
||||
try {
|
||||
const { stdout } = await execAsync(command, { timeout: 15_000 });
|
||||
@@ -202,6 +211,109 @@ export async function checkRemoteTmuxAvailable(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — build the SSH command that lists `codeman-*` tmux sessions on a
|
||||
* remote host's canonical `-L codeman` socket.
|
||||
*
|
||||
* `list-sessions` exits NON-ZERO with empty output when no sessions exist (and
|
||||
* the server isn't running), so `2>/dev/null` swallows tmux's "no server
|
||||
* running" stderr; the caller treats a non-zero exit / empty output as "no
|
||||
* sessions" rather than an error.
|
||||
*
|
||||
* COD-107 — connection options come from the shared `buildSshConnectionArgs`, so
|
||||
* discovery connects with the SAME port/identity/proxy/jump-host as the launch
|
||||
* and the tmux prereq probe.
|
||||
*/
|
||||
export function buildRemoteListSessionsCommand(
|
||||
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
|
||||
): string {
|
||||
const [ssh, ...connectionArgs] = buildSshConnectionArgs(host);
|
||||
const parts = [ssh, connectionArgs[0], '-o ConnectTimeout=10', ...connectionArgs.slice(1)];
|
||||
// The tmux list-sessions invocation is passed as ONE shell-quoted argument so
|
||||
// the remote login shell runs it verbatim. The `-F` format uses literal `\t`
|
||||
// separators (tmux expands them); `2>/dev/null` is inside the quoted command.
|
||||
const remoteCmd =
|
||||
'tmux -L codeman list-sessions -F "#{session_name}\\t#{session_attached}\\t#{session_created}\\t#{session_windows}" 2>/dev/null';
|
||||
parts.push(remoteSshTarget(host), shellescape(remoteCmd));
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — pure parser for the `tmux list-sessions -F` output emitted by
|
||||
* `buildRemoteListSessionsCommand`. Factored out so the parse is unit-testable
|
||||
* without opening a real ssh connection.
|
||||
*
|
||||
* - Splits each non-empty line into [name, attached, created, windows] on the
|
||||
* field separator. IMPORTANT: the remote tmux's `-F "…\t…"` format does NOT
|
||||
* expand `\t` to a real tab — it emits the LITERAL two-character sequence
|
||||
* `\t` (verified on aa-desktop / tmux next-3.7). So we split on the literal
|
||||
* backslash-t sequence; we also tolerate a real tab in case a tmux build
|
||||
* does expand it. (A real TAB is the regex `\t`; a literal backslash-t is the
|
||||
* regex `\\t`.)
|
||||
* - Keeps ONLY sessions whose name starts with `codeman-` (ignores foreign tmux
|
||||
* sessions that happen to share the socket).
|
||||
* - Coerces: `attached` → boolean (`'1'`), `created`/`windows` → finite ints.
|
||||
* - Skips malformed lines (wrong column count or non-numeric created/windows)
|
||||
* rather than emitting garbage.
|
||||
*/
|
||||
export function parseRemoteSessionList(stdout: string): RemoteSessionInfo[] {
|
||||
const out: RemoteSessionInfo[] = [];
|
||||
for (const rawLine of stdout.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
// Split on a literal `\t` (backslash + t, what the remote tmux emits) OR a
|
||||
// real tab character. `/\\t|\t/` = the two-char sequence, or a TAB.
|
||||
const cols = line.split(/\\t|\t/);
|
||||
if (cols.length !== 4) continue;
|
||||
const [name, attachedStr, createdStr, windowsStr] = cols;
|
||||
if (!name.startsWith('codeman-')) continue;
|
||||
const created = Number(createdStr);
|
||||
const windows = Number(windowsStr);
|
||||
if (!Number.isFinite(created) || !Number.isFinite(windows)) continue;
|
||||
// COD-106 — `session_attached` is the CLIENT COUNT (not a 0/1 flag); >1 = shared.
|
||||
const attachedNum = Number(attachedStr.trim());
|
||||
const attachedClients = Number.isFinite(attachedNum) ? Math.max(0, Math.trunc(attachedNum)) : 0;
|
||||
out.push({
|
||||
name,
|
||||
attached: attachedClients > 0,
|
||||
attachedClients,
|
||||
created: Math.trunc(created),
|
||||
windows: Math.trunc(windows),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — discover `codeman-*` tmux sessions already running on a remote host
|
||||
* (created by the remote's own Codeman, another instance, or this one), so the
|
||||
* operator can attach to one this Codeman didn't launch.
|
||||
*
|
||||
* NEVER throws: returns `[]` on unreachable host / no tmux / no sessions
|
||||
* (`list-sessions` exits non-zero with empty output when there are none).
|
||||
*
|
||||
* VITEST guard — like `checkRemoteTmuxAvailable`, returns `[]` under test so a
|
||||
* real ssh never runs in a request path (which would make route tests hit a
|
||||
* ~10s timeout). The command construction is covered by
|
||||
* `buildRemoteListSessionsCommand` and the parse by `parseRemoteSessionList`.
|
||||
*/
|
||||
export async function listRemoteCodemanSessions(
|
||||
remote: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
|
||||
): Promise<RemoteSessionInfo[]> {
|
||||
if (process.env.VITEST) {
|
||||
return [];
|
||||
}
|
||||
const command = buildRemoteListSessionsCommand(remote);
|
||||
try {
|
||||
const { stdout } = await execAsync(command, { timeout: 15_000 });
|
||||
return parseRemoteSessionList(stdout);
|
||||
} catch {
|
||||
// Unreachable host, no tmux server, or no sessions (non-zero exit). All map
|
||||
// to "nothing to attach to" — never surface as an error to the caller.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function remoteDisplayPath(
|
||||
remote: Pick<SessionRemote, 'username' | 'host' | 'remotePath'> | { username: string; host: string; path: string }
|
||||
): string {
|
||||
@@ -218,6 +330,10 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi
|
||||
port: host.port,
|
||||
remotePath: remoteCase.remotePath,
|
||||
commands: host.commands,
|
||||
// COD-105 — the COD-104 launch path creates the remote session, so we own it
|
||||
// (an explicit kill may propagate a remote kill-session). Discovered+attached
|
||||
// sessions go through `toAttachedSessionRemote` with `owned: false`.
|
||||
owned: true,
|
||||
// COD-107 — carry the advanced SSH options from host config into the session
|
||||
// so the launch/prereq commands connect the same way the operator configured.
|
||||
identityFile: host.identityFile,
|
||||
@@ -226,3 +342,38 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi
|
||||
extraSshOptions: host.extraSshOptions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — build a NON-owned `SessionRemote` for ATTACHING to a `codeman-*`
|
||||
* session already running on a remote host (discovered via
|
||||
* `listRemoteCodemanSessions`). The resulting session's pane runs
|
||||
* `tmux -L codeman attach -t <remoteSessionName>` (see
|
||||
* `buildRemoteAttachCommand`), and because we did NOT create the remote session,
|
||||
* `owned: false` means closing the tab DETACHES rather than killing it.
|
||||
*
|
||||
* `remotePath` is informational here (the attached remote session keeps its own
|
||||
* cwd); we record the host's nominal path so display helpers still show
|
||||
* `user@host:path`.
|
||||
*/
|
||||
export function toAttachedSessionRemote(
|
||||
host: RemoteHost,
|
||||
remoteSessionName: string,
|
||||
remotePath: string
|
||||
): SessionRemote {
|
||||
return {
|
||||
hostId: host.id,
|
||||
label: host.label,
|
||||
host: host.host,
|
||||
username: host.username,
|
||||
port: host.port,
|
||||
remotePath,
|
||||
commands: host.commands,
|
||||
// Discovered + attached — another Codeman created it. Detach-not-kill.
|
||||
owned: false,
|
||||
remoteSessionName,
|
||||
identityFile: host.identityFile,
|
||||
socksProxy: host.socksProxy,
|
||||
jumpHost: host.jumpHost,
|
||||
extraSshOptions: host.extraSshOptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -28,9 +28,15 @@ export type UnifiedSessionItem = {
|
||||
lastActivityAt?: number;
|
||||
claudeSessionId?: string;
|
||||
firstPrompt?: string;
|
||||
/** Most recent user prompt from the transcript (COD-145), parallel to firstPrompt. */
|
||||
lastPrompt?: string;
|
||||
sizeBytes?: number;
|
||||
projectKey?: string;
|
||||
remote?: boolean;
|
||||
/** Pinned to the top of the session manager list (COD-139). */
|
||||
pinned?: boolean;
|
||||
/** When the session was pinned (epoch ms) — orders the pinned group desc. */
|
||||
pinnedAt?: number;
|
||||
sources: string[];
|
||||
stats?: { memoryMB: number; cpuPercent: number };
|
||||
};
|
||||
@@ -46,6 +52,8 @@ export type LiveSessionInput = {
|
||||
createdAt?: number;
|
||||
lastActivityAt?: number;
|
||||
claudeSessionId?: string;
|
||||
pinned?: boolean;
|
||||
pinnedAt?: number;
|
||||
};
|
||||
|
||||
/** Persisted session view (subset of `SessionState`). */
|
||||
@@ -59,6 +67,8 @@ export type PersistedSessionInput = {
|
||||
lastActivityAt?: number;
|
||||
/** Claude conversation ID this session resumes (`SessionState.resumeSessionId`). */
|
||||
claudeSessionId?: string;
|
||||
pinned?: boolean;
|
||||
pinnedAt?: number;
|
||||
};
|
||||
|
||||
/** Lifecycle audit-log view. Entries are expected NEWEST-first (the order `SessionLifecycleLog.query()` returns). */
|
||||
@@ -77,6 +87,8 @@ export type HistoryInput = {
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
/** Most recent user prompt from the transcript (COD-145). */
|
||||
lastPrompt?: string;
|
||||
projectKey?: string;
|
||||
};
|
||||
|
||||
@@ -149,6 +161,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
overwrite(item, 'workingDir', h.workingDir);
|
||||
overwrite(item, 'sizeBytes', h.sizeBytes);
|
||||
overwrite(item, 'firstPrompt', h.firstPrompt);
|
||||
overwrite(item, 'lastPrompt', h.lastPrompt);
|
||||
overwrite(item, 'projectKey', h.projectKey);
|
||||
const ms = Date.parse(h.lastModified);
|
||||
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
|
||||
@@ -175,6 +188,8 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
overwrite(item, 'workingDir', p.workingDir);
|
||||
overwrite(item, 'createdAt', p.createdAt);
|
||||
overwrite(item, 'lastActivityAt', p.lastActivityAt);
|
||||
overwrite(item, 'pinned', p.pinned);
|
||||
overwrite(item, 'pinnedAt', p.pinnedAt);
|
||||
}
|
||||
|
||||
// 4) live (highest precedence)
|
||||
@@ -189,6 +204,8 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
overwrite(item, 'createdAt', v.createdAt);
|
||||
overwrite(item, 'lastActivityAt', v.lastActivityAt);
|
||||
overwrite(item, 'claudeSessionId', v.claudeSessionId);
|
||||
overwrite(item, 'pinned', v.pinned);
|
||||
overwrite(item, 'pinnedAt', v.pinnedAt);
|
||||
}
|
||||
|
||||
// 5) mux stats + remote flag (create item if mux-only)
|
||||
@@ -200,6 +217,64 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
if (m.remote !== undefined) item.remote = m.remote;
|
||||
}
|
||||
|
||||
// firstPrompt backfill (COD-140): the only source that sets firstPrompt is the
|
||||
// transcript-history view, keyed by the Claude transcript file's UUID. A live/persisted
|
||||
// row keyed by its Codeman id only inherits firstPrompt when that id happens to equal an
|
||||
// on-disk transcript UUID. When it doesn't (stale/wrong claudeSessionId, post-/clear new
|
||||
// uuid, resumed/attached/worktree session, transcript not yet flushed), the row shows
|
||||
// "(no prompt captured)" even though a real transcript for that working dir exists under a
|
||||
// different UUID. Backfill from the already-passed history: first try the claudeSessionId
|
||||
// join, then the newest transcript in the same workingDir. Never overwrite a non-empty
|
||||
// firstPrompt (so rows keyed to their own transcript are untouched).
|
||||
const firstPromptByUuid = new Map<string, string>();
|
||||
const firstPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
|
||||
// COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite).
|
||||
const lastPromptByUuid = new Map<string, string>();
|
||||
const lastPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
|
||||
for (const h of sources.history ?? []) {
|
||||
const ms = Date.parse(h.lastModified);
|
||||
const ts = Number.isNaN(ms) ? -Infinity : ms;
|
||||
if (h.firstPrompt) {
|
||||
firstPromptByUuid.set(h.sessionId, h.firstPrompt);
|
||||
if (h.workingDir) {
|
||||
const existing = firstPromptByWorkingDir.get(h.workingDir);
|
||||
if (!existing || ts > existing.ms) {
|
||||
firstPromptByWorkingDir.set(h.workingDir, { prompt: h.firstPrompt, ms: ts });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (h.lastPrompt) {
|
||||
lastPromptByUuid.set(h.sessionId, h.lastPrompt);
|
||||
if (h.workingDir) {
|
||||
const existing = lastPromptByWorkingDir.get(h.workingDir);
|
||||
if (!existing || ts > existing.ms) {
|
||||
lastPromptByWorkingDir.set(h.workingDir, { prompt: h.lastPrompt, ms: ts });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of map.values()) {
|
||||
if (!item.firstPrompt) {
|
||||
// never overwrite an existing non-empty prompt
|
||||
const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined;
|
||||
if (byUuid) {
|
||||
item.firstPrompt = byUuid;
|
||||
} else if (item.workingDir) {
|
||||
const byDir = firstPromptByWorkingDir.get(item.workingDir);
|
||||
if (byDir) item.firstPrompt = byDir.prompt;
|
||||
}
|
||||
}
|
||||
if (!item.lastPrompt) {
|
||||
const byUuid = item.claudeSessionId ? lastPromptByUuid.get(item.claudeSessionId) : undefined;
|
||||
if (byUuid) {
|
||||
item.lastPrompt = byUuid;
|
||||
} else if (item.workingDir) {
|
||||
const byDir = lastPromptByWorkingDir.get(item.workingDir);
|
||||
if (byDir) item.lastPrompt = byDir.prompt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.
|
||||
const kept: UnifiedSessionItem[] = [];
|
||||
for (const item of map.values()) {
|
||||
@@ -211,8 +286,24 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
if (isReal) kept.push(item);
|
||||
}
|
||||
|
||||
// Stable sort: lastActivityAt desc (undefined last), createdAt desc, sessionId asc.
|
||||
// Stable sort (COD-139): pinned group first (pinnedAt desc, most-recently-pinned
|
||||
// first), then unpinned by lastActivityAt desc (undefined last), createdAt desc,
|
||||
// sessionId asc.
|
||||
kept.sort((a, b) => {
|
||||
const pa = a.pinned === true;
|
||||
const pb = b.pinned === true;
|
||||
if (pa !== pb) return pa ? -1 : 1; // pinned floats above unpinned
|
||||
if (pa && pb) {
|
||||
// Both pinned: most-recently-pinned first (undefined pinnedAt sorts last).
|
||||
const ta = a.pinnedAt;
|
||||
const tb = b.pinnedAt;
|
||||
if (ta !== tb) {
|
||||
if (ta === undefined) return 1;
|
||||
if (tb === undefined) return -1;
|
||||
return tb - ta;
|
||||
}
|
||||
// tie-break falls through to the activity/createdAt/id rules below.
|
||||
}
|
||||
const la = a.lastActivityAt;
|
||||
const lb = b.lastActivityAt;
|
||||
if (la !== lb) {
|
||||
@@ -234,7 +325,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive substring filter (name + firstPrompt + workingDir + sessionId)
|
||||
* Case-insensitive substring filter (name + firstPrompt + lastPrompt + workingDir + sessionId)
|
||||
* with offset/limit paging. `total` is the filtered count BEFORE paging.
|
||||
*/
|
||||
export function filterAndPaginate(
|
||||
@@ -244,7 +335,7 @@ export function filterAndPaginate(
|
||||
const q = (opts.q ?? '').trim().toLowerCase();
|
||||
const filtered = q
|
||||
? items.filter((it) => {
|
||||
const hay = [it.name, it.firstPrompt, it.workingDir, it.sessionId]
|
||||
const hay = [it.name, it.firstPrompt, it.lastPrompt, it.workingDir, it.sessionId]
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -342,6 +342,11 @@ export class Session extends EventEmitter {
|
||||
// Image watcher setting (per-session toggle)
|
||||
private _imageWatcherEnabled: boolean = false;
|
||||
|
||||
// Pin state (COD-139) — pinned sessions float to the top of the session
|
||||
// manager list, ordered by pinnedAt descending (most-recently-pinned first).
|
||||
private _pinned: boolean = false;
|
||||
private _pinnedAt: number | null = null;
|
||||
|
||||
// Flicker filter setting (per-session toggle, applied on frontend)
|
||||
private _flickerFilterEnabled: boolean = false;
|
||||
|
||||
@@ -992,6 +997,26 @@ export class Session extends EventEmitter {
|
||||
this._imageWatcherEnabled = enabled;
|
||||
}
|
||||
|
||||
/** Whether this session is pinned to the top of the session manager (COD-139). */
|
||||
get pinned(): boolean {
|
||||
return this._pinned;
|
||||
}
|
||||
|
||||
/** When the session was pinned (epoch ms), or null when unpinned. */
|
||||
get pinnedAt(): number | null {
|
||||
return this._pinnedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set pin state (COD-139). Pinning stamps pinnedAt with now so the pinned
|
||||
* group orders most-recently-pinned first; unpinning clears it. Idempotent:
|
||||
* re-pinning an already-pinned session refreshes its pinnedAt.
|
||||
*/
|
||||
setPinned(pinned: boolean): void {
|
||||
this._pinned = pinned;
|
||||
this._pinnedAt = pinned ? Date.now() : null;
|
||||
}
|
||||
|
||||
get flickerFilterEnabled(): boolean {
|
||||
return this._flickerFilterEnabled;
|
||||
}
|
||||
@@ -1058,6 +1083,8 @@ export class Session extends EventEmitter {
|
||||
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
||||
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
||||
imageWatcherEnabled: this._imageWatcherEnabled,
|
||||
pinned: this._pinned || undefined,
|
||||
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
|
||||
totalCost: this._totalCost,
|
||||
inputTokens: this._totalInputTokens,
|
||||
outputTokens: this._totalOutputTokens,
|
||||
@@ -1248,6 +1275,70 @@ export class Session extends EventEmitter {
|
||||
return { isRestored };
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-108 — re-establish a dropped REMOTE session. Triggered by the
|
||||
* `TmuxManager` remote-reconnect watcher (via `remoteSessionDropped`): the
|
||||
* watcher detects a dead remote pane, the session owner reassembles the SAME
|
||||
* `RespawnPaneOptions` used for Claude-idle respawns and calls
|
||||
* `respawnPane()` directly. For a remote session that re-runs
|
||||
* `buildRemoteSessionCommand` (owned → `new-session -A`, non-owned →
|
||||
* `attach`), which idempotently REATTACHES the still-running durable remote
|
||||
* tmux session — scrollback + agent intact (proven COD-104/105).
|
||||
*
|
||||
* Deliberately does NOT route through the Claude-idle respawn-controller —
|
||||
* this is a transport re-establish, not a `/clear`/`/compact` cycle.
|
||||
*
|
||||
* @returns true if the pane was respawned (reattach issued), false otherwise.
|
||||
*/
|
||||
async reattachRemote(): Promise<boolean> {
|
||||
if (!this._remote) return false; // not a remote session
|
||||
if (!this._useMux || !this._mux || !this._muxSession) return false;
|
||||
const mux = this._mux;
|
||||
|
||||
// If tmux lost the whole session (not just a dead pane), there is nothing to
|
||||
// respawn into — a genuine death, leave it for normal recovery/reconcile.
|
||||
if (!mux.muxSessionExists(this._muxSession.muxName)) {
|
||||
console.log('[Session] reattachRemote: mux session gone, skipping:', this._muxSession.muxName);
|
||||
return false;
|
||||
}
|
||||
|
||||
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
|
||||
if (!newPid) {
|
||||
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
|
||||
return false;
|
||||
}
|
||||
console.log('[Session] reattachRemote: reattached remote session', this._muxSession.muxName, 'pid', newPid);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the {@link RespawnPaneOptions} for this session. Single source of
|
||||
* truth shared by interactive start, shell start (via their inline copies),
|
||||
* and {@link reattachRemote} so the remote reattach path can never drift from
|
||||
* the spawn path.
|
||||
*/
|
||||
private _buildRespawnPaneOptions(): import('./mux-interface.js').RespawnPaneOptions {
|
||||
return {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
niceConfig: this._niceConfig,
|
||||
model: this._model,
|
||||
claudeMode: this._claudeMode,
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
geminiConfig: this._geminiConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
};
|
||||
}
|
||||
|
||||
private _handleTerminalOutput(data: string): void {
|
||||
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
|
||||
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
|
||||
@@ -1396,25 +1487,8 @@ export class Session extends EventEmitter {
|
||||
if (this._useMux && this._mux) {
|
||||
try {
|
||||
const { isRestored } = await this._setupOrAttachMuxSession({
|
||||
respawnPaneOptions: {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
niceConfig: this._niceConfig,
|
||||
model: this._model,
|
||||
claudeMode: this._claudeMode,
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
geminiConfig: this._geminiConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
},
|
||||
// Single source of truth shared with reattachRemote() (COD-108).
|
||||
respawnPaneOptions: this._buildRespawnPaneOptions(),
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
|
||||
@@ -278,6 +278,9 @@ export class StateStore {
|
||||
if (this.state.cronJobRuns) {
|
||||
parts.push(`"cronJobRuns":${JSON.stringify(this.state.cronJobRuns)}`);
|
||||
}
|
||||
if (this.state.sessionOrder) {
|
||||
parts.push(`"sessionOrder":${JSON.stringify(this.state.sessionOrder)}`);
|
||||
}
|
||||
|
||||
return `{${parts.join(',')}}`;
|
||||
}
|
||||
@@ -485,6 +488,25 @@ export class StateStore {
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-142: Remove a session's persisted record on kill UNLESS it is pinned.
|
||||
* A pinned session is demoted to a lightweight `stopped` record (pin retained)
|
||||
* so it stays visible in the session-manager pinned group and survives restart.
|
||||
* Unpinned sessions are fully removed (unchanged behavior).
|
||||
* @returns 'preserved' if demoted to stopped+pinned, 'removed' if deleted, 'absent' if no record existed.
|
||||
*/
|
||||
demoteOrRemoveSession(id: string): 'preserved' | 'removed' | 'absent' {
|
||||
const existing = this.state.sessions[id];
|
||||
if (!existing) return 'absent';
|
||||
if (existing.pinned === true) {
|
||||
// Demote in place: keep identity/resume fields + pin, mark stopped, clear live runtime.
|
||||
this.setSession(id, { ...existing, status: 'stopped', pid: null });
|
||||
return 'preserved';
|
||||
}
|
||||
this.removeSession(id);
|
||||
return 'removed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up stale sessions from state that don't have corresponding active sessions.
|
||||
* @param activeSessionIds - Set of currently active session IDs
|
||||
@@ -499,6 +521,7 @@ export class StateStore {
|
||||
|
||||
for (const sessionId of allSessionIds) {
|
||||
if (!activeSessionIds.has(sessionId)) {
|
||||
if (this.state.sessions[sessionId]?.pinned === true) continue; // COD-142: pinned records persist even with no live session
|
||||
const name = this.state.sessions[sessionId]?.name;
|
||||
cleaned.push({ id: sessionId, name });
|
||||
delete this.state.sessions[sessionId];
|
||||
@@ -630,6 +653,17 @@ export class StateStore {
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Returns the global tab order (ordered sessionIds), [] if unset. COD-131. */
|
||||
getSessionOrder(): string[] {
|
||||
return this.state.sessionOrder ?? [];
|
||||
}
|
||||
|
||||
/** Persists the global tab order (ordered sessionIds) and triggers a debounced save. COD-131. */
|
||||
setSessionOrder(order: string[]): void {
|
||||
this.state.sessionOrder = order;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Resets all state to initial values and saves immediately. */
|
||||
reset(): void {
|
||||
this.state = createInitialState();
|
||||
|
||||
@@ -78,6 +78,13 @@ import type {
|
||||
RespawnPaneOptions,
|
||||
PaneCaptureOptions,
|
||||
} from './mux-interface.js';
|
||||
import {
|
||||
decideReconnect,
|
||||
advanceBackoff,
|
||||
freshReconnectState,
|
||||
resetReconnectState,
|
||||
type RemoteReconnectState,
|
||||
} from './remote-reconnect.js';
|
||||
|
||||
// ============================================================================
|
||||
// Timing Constants
|
||||
@@ -110,6 +117,9 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
||||
/** Default stats collection interval (2 seconds) */
|
||||
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||||
|
||||
/** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */
|
||||
const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000;
|
||||
|
||||
/** Stable cwd for tmux server/pane launch; actual session cwd is reached inside the pane. */
|
||||
const TMUX_LAUNCH_CWD = '/tmp';
|
||||
|
||||
@@ -134,6 +144,20 @@ const IS_TEST_MODE = !!process.env.VITEST;
|
||||
/** Path to persisted mux session metadata */
|
||||
const MUX_SESSIONS_FILE = dataPath('mux-sessions.json');
|
||||
|
||||
/**
|
||||
* COD-108 kill-switch: `remoteAutoReconnect` app setting (default ON). Read at
|
||||
* call time (like headroom routing) so a settings change takes effect without a
|
||||
* restart. Absent/non-boolean ⇒ true (feature on).
|
||||
*/
|
||||
function isRemoteAutoReconnectEnabled(): boolean {
|
||||
try {
|
||||
const s = JSON.parse(readFileSync(dataPath('settings.json'), 'utf8')) as Record<string, unknown>;
|
||||
return typeof s.remoteAutoReconnect === 'boolean' ? s.remoteAutoReconnect : true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Regex to validate tmux session names (only allow safe characters) */
|
||||
const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/;
|
||||
|
||||
@@ -812,6 +836,13 @@ export function buildRemoteLaunchCommand(options: {
|
||||
`set -t ${remoteName} mouse off`,
|
||||
`set -t ${remoteName} prefix C-q`,
|
||||
'set -s escape-time 0',
|
||||
// COD-106 — shared/collaborative sessions: tmux defaults to sizing a window
|
||||
// to the SMALLEST attached client, so two Codemans at different viewports
|
||||
// would fight (clamp to the smaller). `window-size latest` sizes to the
|
||||
// most-recently-active client instead, so concurrent clients coexist.
|
||||
// Per-session scoped (`set -t <name>`, matching #145's hardening) so a shared
|
||||
// remote tmux server's other sessions keep their own sizing behavior.
|
||||
`set -t ${remoteName} window-size latest`,
|
||||
].join(' \\; ');
|
||||
|
||||
// ssh runs its trailing args through the remote login shell, so the entire
|
||||
@@ -1132,6 +1163,56 @@ export function resolveDockerLaunchOptions(
|
||||
return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies };
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — build the SSH command that ATTACHES to an EXISTING `codeman-*` tmux
|
||||
* session on the remote host (one this Codeman didn't create — discovered via
|
||||
* `listRemoteCodemanSessions`). Sibling of `buildRemoteLaunchCommand`.
|
||||
*
|
||||
* Emits:
|
||||
* ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
|
||||
* 'tmux -L codeman attach -t <session>'
|
||||
*
|
||||
* - `attach` (NOT `new-session -A`) so we only join an existing session; the
|
||||
* remote session keeps running independent of us, which is exactly why the
|
||||
* resulting Codeman session is NON-OWNED (see `SessionRemote.owned`): closing
|
||||
* the local tab must detach, never `kill-session` the remote.
|
||||
* - The remote session name is shell-escaped so a value with metachars stays a
|
||||
* single token inside the quoted tmux invocation.
|
||||
* - COD-107 — connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
|
||||
* arbitrary `-o`) come from the shared `buildSshConnectionArgs`, so attach
|
||||
* connects identically to launch / discovery / the prereq probe. `-t` sits
|
||||
* right after `ssh -o BatchMode=yes` (a PTY is required for interactive tmux).
|
||||
*/
|
||||
export function buildRemoteAttachCommand(remote: SessionRemote, remoteSessionName: string): string {
|
||||
const tmuxInvocation = `tmux -L codeman attach -t ${shellescape(remoteSessionName)}`;
|
||||
const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote);
|
||||
const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)];
|
||||
return sshParts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — choose the right remote ssh command for a session's ownership:
|
||||
* - NON-owned (`remote.owned === false`): ATTACH to a discovered remote tmux
|
||||
* session by its EXISTING name (`remote.remoteSessionName`, falling back to
|
||||
* this session's deterministic name). We only join — never create.
|
||||
* - owned (default): LAUNCH/attach-or-create via `buildRemoteLaunchCommand`
|
||||
* (COD-104), which we then own and may explicitly kill.
|
||||
*/
|
||||
function buildRemoteSessionCommand(options: {
|
||||
mode: SessionMode;
|
||||
remote: SessionRemote;
|
||||
sessionId: string;
|
||||
claudeMode?: ClaudeMode;
|
||||
allowedTools?: string;
|
||||
}): string {
|
||||
const { remote, sessionId } = options;
|
||||
if (remote.owned === false) {
|
||||
const target = remote.remoteSessionName || remoteTmuxSessionName(sessionId);
|
||||
return buildRemoteAttachCommand(remote, target);
|
||||
}
|
||||
return buildRemoteLaunchCommand(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sensitive environment variables on a tmux session via setenv.
|
||||
* These are inherited by panes but not visible in ps output or tmux history.
|
||||
@@ -1285,6 +1366,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
|
||||
private lastPaneCount: Map<string, number> = new Map();
|
||||
|
||||
// ── COD-108 remote-reconnect watcher state ────────────────────────────────
|
||||
/** Periodic watcher that re-establishes dropped remote sessions. */
|
||||
private remoteReconnectInterval: NodeJS.Timeout | null = null;
|
||||
/** Per-session backoff/attempt bookkeeping (sessionId → state). */
|
||||
private reconnectState: Map<string, RemoteReconnectState> = new Map();
|
||||
/**
|
||||
* Sessions excluded from auto-reconnect because they are being intentionally
|
||||
* torn down (killed/detached/stopping). A guarded session is NEVER revived.
|
||||
*/
|
||||
private reconnectGuard: Set<string> = new Set();
|
||||
|
||||
private trueColorConfigured = false;
|
||||
|
||||
constructor() {
|
||||
@@ -1600,7 +1692,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const fullCmd = docker
|
||||
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
||||
: remote
|
||||
? buildRemoteLaunchCommand({ mode, remote, sessionId, claudeMode, allowedTools })
|
||||
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
|
||||
: localFullCmd;
|
||||
|
||||
// Create tmux session in three steps to handle cold-start (no server running)
|
||||
@@ -1855,7 +1947,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const fullCmd = docker
|
||||
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
||||
: remote
|
||||
? buildRemoteLaunchCommand({ mode, remote, sessionId, claudeMode, allowedTools })
|
||||
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
|
||||
: localFullCmd;
|
||||
|
||||
try {
|
||||
@@ -1971,9 +2063,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return false;
|
||||
}
|
||||
|
||||
// COD-108: an intentional kill/detach must NEVER be auto-revived by the
|
||||
// remote-reconnect watcher. Guard BEFORE any teardown so a tick that fires
|
||||
// mid-kill (especially the non-owned DETACH early-return below, where the
|
||||
// dead local pane would otherwise look reconnectable) sees the guard.
|
||||
this.guardRemoteReconnect(sessionId);
|
||||
|
||||
// TEST MODE: Remove from memory only — NEVER touch real tmux sessions
|
||||
if (IS_TEST_MODE) {
|
||||
this.sessions.delete(sessionId);
|
||||
this.clearRemoteReconnectState(sessionId);
|
||||
this.emit('sessionKilled', { sessionId });
|
||||
return true;
|
||||
}
|
||||
@@ -1985,6 +2084,40 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return false;
|
||||
}
|
||||
|
||||
// COD-105 — DETACH-NOT-KILL for NON-owned remote sessions.
|
||||
//
|
||||
// When this session was created by ATTACHING a remote tmux session another
|
||||
// Codeman owns (`remote.owned === false`), closing the tab must NOT propagate
|
||||
// a remote `tmux kill-session` — that would nuke work the remote's own
|
||||
// Codeman (or another instance) still relies on. We tear down ONLY the LOCAL
|
||||
// pane that holds the ssh client: killing the local ssh sends SIGHUP to its
|
||||
// remote `tmux attach`, which DETACHES (the durable remote session survives).
|
||||
//
|
||||
// This early return is the structural guarantee: no code below this point
|
||||
// (now or in future for owned sessions) can ever issue a remote kill-session
|
||||
// for a non-owned session. The only `kill-session` we run is on OUR LOCAL
|
||||
// socket (`this.tmux()` = `tmux -L codeman` on THIS host), which kills the
|
||||
// local pane — it does NOT reach the REMOTE socket.
|
||||
if (session.remote && session.remote.owned === false) {
|
||||
console.log(`[TmuxManager] DETACH (non-owned remote): tearing down local pane only for ${session.muxName}`);
|
||||
if (isValidMuxName(session.muxName)) {
|
||||
try {
|
||||
// Local socket only — detaches the remote session by killing the local ssh pane.
|
||||
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} catch {
|
||||
// Local pane may already be gone.
|
||||
}
|
||||
}
|
||||
this.lastPaneCount.delete(session.muxName);
|
||||
this.sessions.delete(sessionId);
|
||||
this.clearRemoteReconnectState(sessionId);
|
||||
this.saveSessions();
|
||||
this.emit('sessionKilled', { sessionId });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get current PID (may have changed)
|
||||
const currentPid = this.getPanePid(session.muxName) || session.pid;
|
||||
|
||||
@@ -2089,6 +2222,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
this.lastPaneCount.delete(session.muxName);
|
||||
this.sessions.delete(sessionId);
|
||||
this.clearRemoteReconnectState(sessionId);
|
||||
this.saveSessions();
|
||||
this.emit('sessionKilled', { sessionId });
|
||||
|
||||
@@ -2155,6 +2289,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
} else {
|
||||
dead.push(sessionId);
|
||||
this.sessions.delete(sessionId);
|
||||
this.clearRemoteReconnectState(sessionId);
|
||||
this.emit('sessionDied', { sessionId });
|
||||
}
|
||||
}
|
||||
@@ -2426,9 +2561,118 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this.lastPaneCount.clear();
|
||||
}
|
||||
|
||||
// ── COD-108 remote-session auto-reconnect watcher ─────────────────────────
|
||||
|
||||
/**
|
||||
* Start the remote-reconnect watcher (COD-108). Each tick, for every tracked
|
||||
* session with `session.remote` whose local pane is DEAD, not intentionally
|
||||
* guarded, and within its backoff budget, emit `remoteSessionDropped` so the
|
||||
* session owner reattaches (re-running the idempotent remote command rejoins
|
||||
* the durable remote tmux session). After the attempt cap, emit
|
||||
* `remoteReconnectExhausted` once and go quiet.
|
||||
*
|
||||
* No-op tick body under `IS_TEST_MODE` (mirrors `startMouseModeSync`): tests
|
||||
* drive the logic deterministically via {@link runRemoteReconnectTick}.
|
||||
*/
|
||||
startRemoteReconnectWatcher(intervalMs: number = DEFAULT_REMOTE_RECONNECT_INTERVAL_MS): void {
|
||||
if (this.remoteReconnectInterval) {
|
||||
clearInterval(this.remoteReconnectInterval);
|
||||
}
|
||||
this.remoteReconnectInterval = setInterval(() => {
|
||||
if (IS_TEST_MODE) return;
|
||||
try {
|
||||
this.runRemoteReconnectTick(Date.now(), isRemoteAutoReconnectEnabled());
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Remote reconnect watcher error:', err);
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
stopRemoteReconnectWatcher(): void {
|
||||
if (this.remoteReconnectInterval) {
|
||||
clearInterval(this.remoteReconnectInterval);
|
||||
this.remoteReconnectInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run ONE watcher tick. Extracted (and given an injected `now`/`enabled`) so
|
||||
* the reconnect logic is deterministically testable even though the live
|
||||
* `setInterval` body no-ops under test mode. For each remote session it
|
||||
* applies the pure {@link decideReconnect} decision and translates the result
|
||||
* into events + backoff/state transitions. Public for tests + the watcher.
|
||||
*/
|
||||
runRemoteReconnectTick(now: number, enabled: boolean): void {
|
||||
for (const session of this.sessions.values()) {
|
||||
if (!session.remote) continue;
|
||||
const sessionId = session.sessionId;
|
||||
const state = this.reconnectState.get(sessionId);
|
||||
const action = decideReconnect({
|
||||
session: {
|
||||
sessionId,
|
||||
isRemote: true,
|
||||
paneDead: this.isPaneDead(session.muxName),
|
||||
},
|
||||
state,
|
||||
guarded: this.reconnectGuard.has(sessionId),
|
||||
enabled,
|
||||
now,
|
||||
});
|
||||
|
||||
if (action.kind === 'emit') {
|
||||
const base = state ?? freshReconnectState();
|
||||
// Mark in-flight + advance backoff BEFORE emitting so a re-entrant tick
|
||||
// (or a synchronous listener) can never stack a second reconnect.
|
||||
this.reconnectState.set(sessionId, { ...advanceBackoff(base, now), inFlight: true });
|
||||
this.emit('remoteSessionDropped', { sessionId, attempt: action.attempt });
|
||||
} else if (action.kind === 'exhaust') {
|
||||
const base = state ?? freshReconnectState();
|
||||
if (!base.exhaustedEmitted) {
|
||||
this.reconnectState.set(sessionId, { ...base, exhausted: true, exhaustedEmitted: true });
|
||||
this.emit('remoteReconnectExhausted', { sessionId });
|
||||
}
|
||||
}
|
||||
// 'skip' → nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the watcher a reattach attempt for `sessionId` finished. On success,
|
||||
* reset the backoff so the session is healthy again; on failure, just clear
|
||||
* the in-flight flag so the next due tick can retry under the existing
|
||||
* backoff schedule. Called by the session owner after `respawnPane`.
|
||||
*/
|
||||
noteRemoteReconnect(sessionId: string, success: boolean): void {
|
||||
if (success) {
|
||||
this.reconnectState.set(sessionId, resetReconnectState());
|
||||
return;
|
||||
}
|
||||
const state = this.reconnectState.get(sessionId);
|
||||
if (state) this.reconnectState.set(sessionId, { ...state, inFlight: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude a session from auto-reconnect (intentional teardown). Adds it to the
|
||||
* guard set and drops any backoff state so a closed/killed tab — especially a
|
||||
* non-owned remote DETACH — is never auto-revived. Idempotent.
|
||||
*/
|
||||
guardRemoteReconnect(sessionId: string): void {
|
||||
this.reconnectGuard.add(sessionId);
|
||||
this.reconnectState.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Clear all per-session reconnect + guard state (e.g. when a session is removed). */
|
||||
clearRemoteReconnectState(sessionId: string): void {
|
||||
this.reconnectState.delete(sessionId);
|
||||
this.reconnectGuard.delete(sessionId);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stopStatsCollection();
|
||||
this.stopMouseModeSync();
|
||||
this.stopRemoteReconnectWatcher();
|
||||
this.reconnectState.clear();
|
||||
this.reconnectGuard.clear();
|
||||
}
|
||||
|
||||
registerSession(session: MuxSession): void {
|
||||
|
||||
@@ -116,6 +116,8 @@ export interface AppState {
|
||||
cronJobs?: Record<string, CronJob>;
|
||||
/** Scheduled job run history, keyed by run ID. */
|
||||
cronJobRuns?: Record<string, CronJobRun>;
|
||||
/** Global tab order shared across devices (ordered list of sessionIds) — COD-131 */
|
||||
sessionOrder?: string[];
|
||||
}
|
||||
|
||||
// ========== Default Configuration ==========
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* - CleanupRegistration / CleanupResourceType — entries for the centralized CleanupManager
|
||||
* - NiceConfig / DEFAULT_NICE_CONFIG — process priority settings for `nice`/`ionice`
|
||||
* - ProcessStats — memory/CPU/child-count snapshot for resource monitoring
|
||||
* - FilesystemBrowseData — bounded path-picker directory listing returned to the web UI
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -68,6 +69,34 @@ export interface ProcessStats {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** A selectable entry returned by the filesystem path-picker API. */
|
||||
export type FilesystemPreviewKind = 'image' | 'text' | 'document';
|
||||
|
||||
export interface FilesystemBrowseEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
size?: number;
|
||||
symlink?: boolean;
|
||||
previewKind?: FilesystemPreviewKind;
|
||||
}
|
||||
|
||||
/** A named root the path picker may browse without escaping its allowlist. */
|
||||
export interface FilesystemBrowseRoot {
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Response payload for `GET /api/filesystem/browse`. */
|
||||
export interface FilesystemBrowseData {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
root: string;
|
||||
roots: FilesystemBrowseRoot[];
|
||||
entries: FilesystemBrowseEntry[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream';
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,3 +70,4 @@ export * from './update.js';
|
||||
export * from './workflow-run.js';
|
||||
export * from './search.js';
|
||||
export * from './user.js';
|
||||
export * from './webview.js';
|
||||
|
||||
@@ -99,6 +99,45 @@ export interface SessionRemote extends RemoteSshOptions {
|
||||
port?: number;
|
||||
remotePath: string;
|
||||
commands?: Partial<Record<RemoteCommandMode, string>>;
|
||||
/**
|
||||
* COD-105 — whether THIS Codeman created the remote tmux session.
|
||||
*
|
||||
* - `true` (default for COD-104 launched sessions): we own the remote session;
|
||||
* an explicit "kill" may propagate a remote `tmux kill-session`.
|
||||
* - `false` (discovered + attached an existing remote session another Codeman
|
||||
* created): closing the local tab must DETACH only — we must NEVER issue a
|
||||
* remote `kill-session`, or we'd nuke work the remote's own Codeman (or
|
||||
* another instance) still relies on. See `killSession()` gate.
|
||||
*
|
||||
* Absent is treated as owned (legacy/COD-104 sessions persisted before this
|
||||
* field existed were all launched by us).
|
||||
*/
|
||||
owned?: boolean;
|
||||
/**
|
||||
* COD-105 — for a NON-owned (discovered + attached) session, the EXISTING
|
||||
* remote tmux session name to `attach -t` (e.g. `codeman-disco1`). It differs
|
||||
* from this Codeman's deterministic `codeman-<id>` name because the remote
|
||||
* session was created elsewhere. Only meaningful when `owned === false`.
|
||||
*/
|
||||
remoteSessionName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-105 — a `codeman-*` tmux session discovered on a remote host's
|
||||
* `tmux -L codeman` socket (may have been created by the remote's own Codeman,
|
||||
* another instance, or this one). Returned by `listRemoteCodemanSessions`.
|
||||
*/
|
||||
export interface RemoteSessionInfo {
|
||||
/** tmux session name (always starts `codeman-`). */
|
||||
name: string;
|
||||
/** Whether at least one client is currently attached to the remote session. */
|
||||
attached: boolean;
|
||||
/** COD-106 — number of clients attached (tmux `session_attached`); >1 = shared. */
|
||||
attachedClients: number;
|
||||
/** tmux `session_created` epoch seconds. */
|
||||
created: number;
|
||||
/** Number of windows in the remote session. */
|
||||
windows: number;
|
||||
}
|
||||
|
||||
// ========== Docker cases (COD-Docker) ==========
|
||||
@@ -366,6 +405,10 @@ export interface SessionState {
|
||||
autoResumeEnabled?: boolean;
|
||||
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
|
||||
autoResumeAt?: number;
|
||||
/** Pinned to the top of the session manager list (COD-139) */
|
||||
pinned?: boolean;
|
||||
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
|
||||
pinnedAt?: number;
|
||||
/** Image watcher enabled for this session */
|
||||
imageWatcherEnabled?: boolean;
|
||||
/** Total cost in USD */
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @fileoverview Web tab (dashboard) types.
|
||||
*
|
||||
* A "webview" is a saved URL that Codeman renders as a tab alongside agent
|
||||
* sessions: Grafana on :3000, a Uptime-Kuma on :4000, an internal status page.
|
||||
* It is deliberately NOT a sixth `SessionMode`, it has no PTY, no tmux, no
|
||||
* respawn and no idle detection. Same reasoning that keeps Docker and remote-SSH
|
||||
* as case overlays rather than modes.
|
||||
*
|
||||
* Key exports:
|
||||
* - Webview, the persisted record (`~/.codeman/webviews.json`).
|
||||
* - WebviewEmbedMode, 'proxy' (served through Codeman's origin) or 'direct'
|
||||
* (a plain cross-origin iframe, only viable for HTTPS targets that allow framing).
|
||||
* - WebviewProbe, the result of the server-side reachability/framing probe.
|
||||
* - WebviewOpenData, what `POST /api/webviews/:id/open` hands the browser.
|
||||
*
|
||||
* No I/O here. Persistence lives in `src/webview-store.ts`, capability minting in
|
||||
* `src/webview-capabilities.ts`, the proxy helpers in `src/web/webview-proxy.ts`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How the browser should embed a webview.
|
||||
*
|
||||
* - `proxy`: the iframe points at `/webview/<capability>/` on Codeman's own
|
||||
* origin and the server relays to the target. Required whenever the target is
|
||||
* plain HTTP (an HTTPS Codeman page cannot embed it: mixed content) or refuses
|
||||
* framing via `X-Frame-Options` / `frame-ancestors`.
|
||||
* - `direct`: the iframe points at the target URL itself. Cheaper, but only works
|
||||
* for HTTPS targets that permit framing, and needs the target origin added to
|
||||
* the page CSP's `frame-src`.
|
||||
*/
|
||||
export type WebviewEmbedMode = 'proxy' | 'direct';
|
||||
|
||||
/** A saved dashboard, persisted to `~/.codeman/webviews.json`. */
|
||||
export interface Webview {
|
||||
id: string;
|
||||
/** Display name shown on the tab. */
|
||||
name: string;
|
||||
/** Absolute target URL. `http:` / `https:` only, never with embedded credentials. */
|
||||
url: string;
|
||||
/** Optional single-glyph tab icon (emoji or letter). */
|
||||
icon?: string;
|
||||
/** Default embed strategy for this dashboard. */
|
||||
embedMode: WebviewEmbedMode;
|
||||
/**
|
||||
* When false (the default) the iframe is sandboxed WITHOUT `allow-same-origin`,
|
||||
* so a proxied page runs in an opaque origin and cannot read the Codeman page or
|
||||
* call its API. Setting this to true trades that isolation for the page's own
|
||||
* cookies/localStorage, only for dashboards the user fully trusts.
|
||||
*/
|
||||
trusted: boolean;
|
||||
/** Multi-user owner (username). Undefined in single-user mode. */
|
||||
owner?: string;
|
||||
createdAt: number;
|
||||
lastOpenedAt?: number;
|
||||
}
|
||||
|
||||
/** Result of the server-side probe used by the "Test" button in the editor. */
|
||||
export interface WebviewProbe {
|
||||
/** True when the server could complete an HTTP request to the target. */
|
||||
reachable: boolean;
|
||||
/** Upstream status code, when a response came back. */
|
||||
status?: number;
|
||||
/** Raw `X-Frame-Options` value, if the target sent one. */
|
||||
xFrameOptions?: string;
|
||||
/** The `frame-ancestors` directive extracted from the target's CSP, if any. */
|
||||
frameAncestors?: string;
|
||||
/** True when the target permits being framed cross-origin by this Codeman. */
|
||||
framable: boolean;
|
||||
/** Strategy the UI should default to for this URL. */
|
||||
recommendedMode: WebviewEmbedMode;
|
||||
/** Human-readable explanation of the recommendation (or the failure). */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Payload of `POST /api/webviews/:id/open`. */
|
||||
export interface WebviewOpenData {
|
||||
/** The webview being opened (echoed so the client can refresh its copy). */
|
||||
webview: Webview;
|
||||
/**
|
||||
* Same-origin path the iframe should load. Present for `proxy` mode only;
|
||||
* `direct` mode uses `webview.url` instead.
|
||||
*/
|
||||
embedUrl?: string;
|
||||
/** Epoch ms at which the capability behind `embedUrl` stops working. */
|
||||
expiresAt?: number;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
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 { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { capabilityFromProxyPath, capabilityFromReferer } from '../webview-proxy.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
|
||||
|
||||
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
|
||||
@@ -120,6 +122,85 @@ function isPasswordChangeExempt(req: FastifyRequest): boolean {
|
||||
return !url.startsWith('/api/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this request carries a VALID web-tab proxy capability.
|
||||
*
|
||||
* Requests under `/webview/<cap>/` cannot authenticate the normal way. The iframe
|
||||
* rendering a dashboard is sandboxed without `allow-same-origin`, so it runs in an
|
||||
* opaque origin: every request it makes is cross-site, meaning the `SameSite=lax`
|
||||
* `codeman_session` cookie is never attached, and non-GET requests and WebSocket
|
||||
* upgrades arrive with `Origin: null`. Both the cookie check and the CSRF Origin
|
||||
* guard would therefore reject a perfectly legitimate dashboard asset load.
|
||||
*
|
||||
* The capability in the path is the credential instead: 192 bits of entropy, held
|
||||
* in memory only (a restart invalidates it), rolling TTL, bound to the user who
|
||||
* minted it through an already-authenticated `POST /api/webviews/:id/open`, and
|
||||
* granting nothing but "relay bytes to this one saved URL".
|
||||
*
|
||||
* The exemption is deliberately narrow: it requires the capability to RESOLVE, so
|
||||
* a bare `/webview/anything` reaches nothing, and a `/webviewfoo` path does not
|
||||
* match the prefix at all. The Host allowlist is NOT bypassed, so DNS-rebinding
|
||||
* protection still applies to these requests.
|
||||
*/
|
||||
function hasValidWebviewCapability(req: FastifyRequest): boolean {
|
||||
const url = (req.url ?? '').split('?')[0];
|
||||
|
||||
const fromPath = capabilityFromProxyPath(url);
|
||||
if (fromPath) return webviewCapabilities.resolve(fromPath) !== undefined;
|
||||
|
||||
// Referer form: a dashboard subresource requested with a ROOT-ABSOLUTE URL, which
|
||||
// lands on Codeman's root and is relayed by the 404 fallback. Without this the
|
||||
// asset would be rejected here, before the fallback ever runs.
|
||||
//
|
||||
// This is the only exemption decided by a header the request itself supplies, so
|
||||
// it is fenced in hard: safe methods only, and never for Codeman's own functional
|
||||
// surfaces. Without those fences a page could present a webview Referer and skip
|
||||
// auth on /api. It is not a privilege escalation even so, holding a live
|
||||
// capability already implies an authenticated `POST /api/webviews/:id/open`, but
|
||||
// the exemption should stay no wider than the problem it solves.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
||||
if (url.startsWith('/ws/') || url.startsWith('/q/')) return false;
|
||||
// Anything that resolves to a REAL Codeman route is refused, which is the fence
|
||||
// that keeps this from being an auth bypass. `/api/` used to be refused by prefix
|
||||
// instead, but dashboards legitimately serve assets from their own `/api/...`
|
||||
// namespace (`<img src="/api/hero?slug=x">`), and those requests were the one
|
||||
// class the 404 relay could never rescue. See matchesRegisteredRoute.
|
||||
if (matchesRegisteredRoute(req, url)) return false;
|
||||
|
||||
const fromReferer = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
|
||||
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `url` resolves to a route Codeman actually registered.
|
||||
*
|
||||
* `hasRoute()` is the wrong tool: it matches the registered PATTERN literally, so
|
||||
* `/api/sessions/abc` reports false against a registered `/api/sessions/:id` and
|
||||
* would hand out an exemption on a live API route. `findRoute()` performs the real
|
||||
* radix-tree lookup and fills in `params`, which is what this needs.
|
||||
*
|
||||
* The one complication is `@fastify/static`, mounted at `/`, which registers a
|
||||
* root-level catch-all that matches EVERY path. A match on that means "no real
|
||||
* route, this is heading for the 404 handler", and it is distinguishable because a
|
||||
* root catch-all is the only route whose `*` param comes back equal to the entire
|
||||
* request path. `test/webview-auth-exemption.test.ts` pins both halves of that.
|
||||
*
|
||||
* Fails CLOSED: anything unexpected counts as a real route, which merely denies the
|
||||
* exemption and restores the previous behavior.
|
||||
*/
|
||||
function matchesRegisteredRoute(req: FastifyRequest, url: string): boolean {
|
||||
try {
|
||||
const found = req.server.findRoute({ method: req.method as 'GET' | 'HEAD', url });
|
||||
if (!found) return false;
|
||||
const params = found.params ?? {};
|
||||
const keys = Object.keys(params);
|
||||
const isRootCatchAll = keys.length === 1 && keys[0] === '*' && `/${params['*']}` === url;
|
||||
return !isRootCatchAll;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
|
||||
* Only active when CODEMAN_PASSWORD is set.
|
||||
@@ -211,6 +292,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
return;
|
||||
}
|
||||
|
||||
// Web-tab proxy, authenticated by the capability in the path, not the cookie.
|
||||
if (hasValidWebviewCapability(req)) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Check session cookie first (avoids re-sending credentials on every request)
|
||||
@@ -341,6 +428,12 @@ function registerMultiUserAuthHook(
|
||||
// QR redemption path — handled by the route itself.
|
||||
if (req.url?.startsWith('/q/')) return;
|
||||
|
||||
// Web-tab proxy, authenticated by the capability in the path, not the cookie.
|
||||
// `req.authUser` stays undefined here on purpose: the proxy handler enforces
|
||||
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
|
||||
// than re-deriving it from a request that carries no credentials.
|
||||
if (hasValidWebviewCapability(req)) return;
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// 1. Cookie session (carries identity + mustChangePassword snapshot).
|
||||
@@ -466,7 +559,17 @@ export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPol
|
||||
reply.code(403).send('Forbidden: host not allowed');
|
||||
return;
|
||||
}
|
||||
if (!SAFE_HTTP_METHODS.has(req.method) && !isAllowedRequestOrigin(req.headers.origin, policy)) {
|
||||
// The Host allowlist above is NEVER bypassed. The Origin (CSRF) check is,
|
||||
// but only for a request carrying a valid web-tab capability: a sandboxed
|
||||
// dashboard is opaque-origin, so its form posts and uploads arrive with
|
||||
// `Origin: null`, which this guard rejects by design. The capability is the
|
||||
// credential in that case, and it is unguessable, see
|
||||
// hasValidWebviewCapability.
|
||||
if (
|
||||
!SAFE_HTTP_METHODS.has(req.method) &&
|
||||
!isAllowedRequestOrigin(req.headers.origin, policy) &&
|
||||
!hasValidWebviewCapability(req)
|
||||
) {
|
||||
reply.code(403).send('Forbidden: cross-site request blocked');
|
||||
return;
|
||||
}
|
||||
@@ -521,8 +624,17 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean): v
|
||||
}
|
||||
}
|
||||
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
// Handle CORS preflight.
|
||||
//
|
||||
// EXCEPT for the web-tab proxy, which must answer its own preflight. A
|
||||
// sandboxed dashboard iframe is opaque-origin, so it sends `Origin: null`;
|
||||
// the CORS block above only emits headers for localhost origins, so a bare
|
||||
// 204 from here carries no `Access-Control-Allow-Origin` and the browser
|
||||
// rejects the preflight. Every dashboard fetch then fails with an opaque
|
||||
// net::ERR_FAILED while the page itself renders fine (script/css/img loads
|
||||
// are not CORS-checked). Falling through lets the proxy route reply with the
|
||||
// right headers.
|
||||
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req)) {
|
||||
reply.code(204).send();
|
||||
done();
|
||||
return;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/**
|
||||
* @fileoverview Multi-user frontend: identity boot, admin Users panel, and the
|
||||
* change-password flow. Self-contained (builds its own DOM) so it needs no
|
||||
* index.html surgery beyond the script tag and integrates with the existing App
|
||||
* Settings modal by injecting a "Users" tab (admins in multi-user mode only).
|
||||
* @fileoverview Multi-user frontend: identity boot, admin Users panel, the
|
||||
* change-password flow, and the full Admin Panel modal (user CRUD, per-user
|
||||
* permissions, case-folder management) opened by the header Admin Panel button
|
||||
* (#adminPanelBtn, revealed for admins in multi-user mode). Self-contained
|
||||
* (builds its own DOM) so it needs no index.html surgery beyond the script tag
|
||||
* and button; integrates with the existing App Settings modal by injecting a
|
||||
* "Users" tab (admins in multi-user mode only). Live-refreshes on the SSE
|
||||
* admin:usersChanged event (wired in app.js → window.codemanAdmin.onUsersChanged).
|
||||
*
|
||||
* @dependency app.js (window.app), settings-ui.js (App Settings modal + tab switch)
|
||||
* @loadorder after settings-ui.js / ultracode-panel.js, before session-ui.js
|
||||
@@ -123,7 +127,10 @@
|
||||
content.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<strong>Users</strong>
|
||||
<button class="btn btn-sm" id="adminAddUser">+ Add user</button>
|
||||
<span>
|
||||
<button class="btn btn-sm" id="adminOpenPanel">Open Admin Panel</button>
|
||||
<button class="btn btn-sm" id="adminAddUser">+ Add user</button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="form-hint">Users share the host account; this separates workspaces, it does not sandbox
|
||||
users from each other. Pair with Docker cases for isolation.</p>
|
||||
@@ -133,6 +140,7 @@
|
||||
// Render whenever the tab is shown (the shared switchSettingsTab toggles it).
|
||||
btn.addEventListener('click', renderUsers);
|
||||
content.querySelector('#adminAddUser').onclick = addUserFlow;
|
||||
content.querySelector('#adminOpenPanel').onclick = openAdminPanel;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
@@ -233,6 +241,292 @@
|
||||
renderUsers();
|
||||
}
|
||||
|
||||
// ── Admin Panel (big header-button modal) ─────────────────────────────────
|
||||
let apModal = null;
|
||||
let apUsersCache = [];
|
||||
const apOpenDrawers = new Set(); // usernames with an expanded case-folder drawer
|
||||
|
||||
function fmtDate(ts) {
|
||||
return ts ? new Date(ts).toLocaleString() : 'never';
|
||||
}
|
||||
function cssEsc(s) {
|
||||
return window.CSS && window.CSS.escape ? window.CSS.escape(s) : String(s).replace(/"/g, '\\"');
|
||||
}
|
||||
function apSetMsg(t) {
|
||||
const m = document.getElementById('apMsg');
|
||||
if (m) m.textContent = t || '';
|
||||
}
|
||||
|
||||
function buildAdminPanel() {
|
||||
if (apModal) return apModal;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'modal';
|
||||
el.id = 'adminPanelModal';
|
||||
el.style.zIndex = '3000';
|
||||
el.innerHTML = `
|
||||
<div class="modal-content" style="max-width:940px;width:min(96vw,940px)">
|
||||
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;gap:12px">
|
||||
<h2 style="display:flex;align-items:center;gap:8px;margin:0">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
Admin Panel</h2>
|
||||
<span id="apIdentity" style="color:var(--text-muted,#888);font-size:.85em"></span>
|
||||
</div>
|
||||
<div class="modal-body" style="max-height:70vh;overflow-y:auto">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<strong>Users</strong>
|
||||
<button class="btn btn-sm btn-primary" id="apAddToggle">+ Add user</button>
|
||||
</div>
|
||||
<div id="apAddForm" style="display:none;border:1px solid var(--border,#333);border-radius:8px;padding:10px;margin-bottom:10px">
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
|
||||
<div class="form-row" style="margin:0"><label>Username</label>
|
||||
<input id="apNewName" class="form-input" placeholder="lowercase a-z 0-9 _ -" style="width:170px"></div>
|
||||
<div class="form-row" style="margin:0"><label>Role</label>
|
||||
<select id="apNewRole" class="form-input" style="width:110px">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select></div>
|
||||
<div class="form-row" style="margin:0"><label>Password (optional)</label>
|
||||
<input id="apNewPw" type="password" class="form-input" placeholder="blank = one-time pw"
|
||||
style="width:170px" autocomplete="new-password"></div>
|
||||
<label style="display:flex;align-items:center;gap:5px;white-space:nowrap;margin-bottom:6px">
|
||||
<input type="checkbox" id="apNewBypass"> allow bypass permissions</label>
|
||||
<button class="btn btn-sm btn-primary" id="apCreateUser" style="margin-bottom:2px">Create</button>
|
||||
</div>
|
||||
<p class="form-hint" style="margin:6px 0 0">Without a password a one-time password is generated and shown
|
||||
once; the user must change it on first login. "Bypass" allows shell sessions, cron launch commands, and
|
||||
skip-permissions agents.</p>
|
||||
</div>
|
||||
<div id="apOtp" style="display:none;border:1px solid var(--accent,#38b6f0);border-radius:8px;padding:10px;margin-bottom:10px"></div>
|
||||
<div id="apTable">Loading…</div>
|
||||
<p class="form-hint" style="margin-top:10px">Users share the host OS account: this separates workspaces, it
|
||||
does not sandbox users from each other. Pair with Docker cases for isolation.</p>
|
||||
<p id="apMsg" style="min-height:1.2em;color:var(--text-muted,#888)"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" id="apClose">Close</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(el);
|
||||
el.querySelector('#apClose').onclick = () => (el.style.display = 'none');
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target === el) el.style.display = 'none';
|
||||
});
|
||||
el.querySelector('#apAddToggle').onclick = () => {
|
||||
const f = el.querySelector('#apAddForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
if (f.style.display === '') f.querySelector('#apNewName').focus();
|
||||
};
|
||||
el.querySelector('#apCreateUser').onclick = createUserFromForm;
|
||||
apModal = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
function showOneTimePassword(username, otp) {
|
||||
const box = document.getElementById('apOtp');
|
||||
if (!box) return;
|
||||
box.style.display = '';
|
||||
box.innerHTML = `One-time password for <strong>${esc(username)}</strong> (shown once, copy it now):
|
||||
<code style="user-select:all;font-size:1.05em;margin:0 8px">${esc(otp)}</code>
|
||||
<button class="btn btn-xs" id="apOtpCopy">Copy</button>
|
||||
<button class="btn btn-xs" id="apOtpDismiss">Dismiss</button>`;
|
||||
box.querySelector('#apOtpCopy').onclick = () => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(otp).then(() => apSetMsg('Password copied to clipboard.'));
|
||||
}
|
||||
};
|
||||
box.querySelector('#apOtpDismiss').onclick = () => {
|
||||
box.style.display = 'none';
|
||||
box.innerHTML = '';
|
||||
};
|
||||
}
|
||||
|
||||
async function createUserFromForm() {
|
||||
const name = (document.getElementById('apNewName').value || '').trim().toLowerCase();
|
||||
const role = document.getElementById('apNewRole').value;
|
||||
const pw = document.getElementById('apNewPw').value;
|
||||
const bypass = document.getElementById('apNewBypass').checked;
|
||||
if (!name) return apSetMsg('Enter a username.');
|
||||
const body = { username: name, role };
|
||||
if (pw) body.password = pw;
|
||||
if (bypass) body.canBypassPermissions = true;
|
||||
const r = await apiSend('POST', '/api/admin/users', body);
|
||||
if (!r.ok) return apSetMsg((r.body && r.body.error) || 'Create failed.');
|
||||
document.getElementById('apNewName').value = '';
|
||||
document.getElementById('apNewPw').value = '';
|
||||
document.getElementById('apNewBypass').checked = false;
|
||||
apSetMsg(`Created ${name}.`);
|
||||
if (r.data && r.data.oneTimePassword) showOneTimePassword(name, r.data.oneTimePassword);
|
||||
renderPanel();
|
||||
}
|
||||
|
||||
async function renderPanel() {
|
||||
const table = document.getElementById('apTable');
|
||||
if (!table) return;
|
||||
let users;
|
||||
try {
|
||||
users = await apiGet('/api/admin/users');
|
||||
} catch {
|
||||
table.innerHTML = 'Failed to load users.';
|
||||
return;
|
||||
}
|
||||
apUsersCache = users;
|
||||
const meName = (window.__codemanUser || {}).username;
|
||||
const rows = users
|
||||
.map((u) => {
|
||||
const st = u.stats || {};
|
||||
const you = u.username === meName ? ' <span style="color:var(--accent,#38b6f0)">(you)</span>' : '';
|
||||
const role = `<span style="font-weight:600;color:${
|
||||
u.role === 'admin' ? 'var(--accent,#38b6f0)' : 'var(--text-muted,#888)'
|
||||
}">${u.role}</span>`;
|
||||
const status = u.disabled
|
||||
? '<span style="color:var(--red,#c33)">disabled</span>'
|
||||
: '<span style="color:var(--accent-soft,#4b9)">enabled</span>';
|
||||
const pwFlag = u.mustChangePassword ? ' · must-change-pw' : '';
|
||||
return `<tr data-u="${esc(u.username)}">
|
||||
<td><strong>${esc(u.username)}</strong>${you}</td>
|
||||
<td>${role}</td>
|
||||
<td>${status}${pwFlag}</td>
|
||||
<td>${u.canBypassPermissions ? 'yes' : 'no'}</td>
|
||||
<td style="white-space:nowrap">${st.liveSessions ?? 0} live · ${st.activeSessions ?? 0} logins ·
|
||||
<button class="btn btn-xs" data-act="cases">${st.caseCount ?? 0} cases</button></td>
|
||||
<td style="font-size:.85em;color:var(--text-muted,#888)">${fmtDate(u.lastLoginAt)}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="btn btn-xs" data-act="role">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
|
||||
<button class="btn btn-xs" data-act="disabled">${u.disabled ? 'Enable' : 'Disable'}</button>
|
||||
<button class="btn btn-xs" data-act="bypass">${u.canBypassPermissions ? 'Revoke bypass' : 'Grant bypass'}</button>
|
||||
<button class="btn btn-xs" data-act="reset">Reset pw</button>
|
||||
<button class="btn btn-xs" data-act="logout">Logout</button>
|
||||
<button class="btn btn-xs" data-act="delete" style="color:var(--red,#c33)">Delete</button>
|
||||
</td></tr>
|
||||
<tr data-drawer="${esc(u.username)}" style="display:none"><td colspan="7"></td></tr>`;
|
||||
})
|
||||
.join('');
|
||||
table.innerHTML = `<table style="width:100%;border-collapse:collapse" class="admin-users">
|
||||
<thead><tr>
|
||||
<th align="left">User</th><th align="left">Role</th><th align="left">Status</th>
|
||||
<th align="left">Bypass</th><th align="left">Activity</th><th align="left">Last login</th><th></th>
|
||||
</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
table.querySelectorAll('button[data-act]').forEach((b) => {
|
||||
const username = b.closest('tr').dataset.u;
|
||||
b.onclick = () => {
|
||||
if (b.dataset.act === 'cases') return toggleCaseDrawer(username);
|
||||
return panelAction(
|
||||
username,
|
||||
b.dataset.act,
|
||||
apUsersCache.find((x) => x.username === username)
|
||||
);
|
||||
};
|
||||
});
|
||||
// Re-open drawers that were expanded before this refresh.
|
||||
for (const name of [...apOpenDrawers]) {
|
||||
if (users.some((u) => u.username === name)) void renderCaseDrawer(name);
|
||||
else apOpenDrawers.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
async function panelAction(username, act, u) {
|
||||
const path = `/api/admin/users/${encodeURIComponent(username)}`;
|
||||
if (act === 'role') {
|
||||
const r = await apiSend('PATCH', path, { role: u.role === 'admin' ? 'user' : 'admin' });
|
||||
apSetMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'disabled') {
|
||||
const r = await apiSend('PATCH', path, { disabled: !u.disabled });
|
||||
apSetMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'bypass') {
|
||||
const r = await apiSend('PATCH', path, { canBypassPermissions: !u.canBypassPermissions });
|
||||
apSetMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'reset') {
|
||||
if (!window.confirm(`Reset ${username}'s password? They must set a new one on next login.`)) return;
|
||||
const r = await apiSend('POST', `${path}/reset-password`);
|
||||
if (r.ok && r.data && r.data.oneTimePassword) showOneTimePassword(username, r.data.oneTimePassword);
|
||||
else if (!r.ok) apSetMsg((r.body && r.body.error) || 'Reset failed.');
|
||||
} else if (act === 'logout') {
|
||||
const r = await apiSend('POST', `${path}/logout`);
|
||||
apSetMsg(r.ok ? `Revoked ${(r.data && r.data.revoked) || 0} login session(s) for ${username}.` : 'Failed.');
|
||||
} else if (act === 'delete') {
|
||||
if (!window.confirm(`Delete user "${username}"? Their live sessions are killed and logins revoked.`)) return;
|
||||
const deleteSpace = window.confirm(
|
||||
`Also delete ${username}'s files (their cases/workspace folder)?\nOK = delete files too, Cancel = keep files on disk.`
|
||||
);
|
||||
const r = await apiSend('DELETE', path, { deleteSpace });
|
||||
apSetMsg(r.ok ? `Deleted ${username}.` : (r.body && r.body.error) || 'Delete failed.');
|
||||
}
|
||||
renderPanel();
|
||||
}
|
||||
|
||||
async function toggleCaseDrawer(username) {
|
||||
if (apOpenDrawers.has(username)) {
|
||||
apOpenDrawers.delete(username);
|
||||
const row = apModal && apModal.querySelector(`tr[data-drawer="${cssEsc(username)}"]`);
|
||||
if (row) row.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
apOpenDrawers.add(username);
|
||||
await renderCaseDrawer(username);
|
||||
}
|
||||
|
||||
async function renderCaseDrawer(username) {
|
||||
const row = apModal && apModal.querySelector(`tr[data-drawer="${cssEsc(username)}"]`);
|
||||
if (!row) return;
|
||||
row.style.display = '';
|
||||
const cell = row.firstElementChild;
|
||||
cell.innerHTML = 'Loading folders…';
|
||||
let data;
|
||||
try {
|
||||
data = await apiGet(`/api/admin/users/${encodeURIComponent(username)}/cases`);
|
||||
} catch {
|
||||
cell.innerHTML = 'Failed to load case folders.';
|
||||
return;
|
||||
}
|
||||
const items = (data.cases || [])
|
||||
.map(
|
||||
(c) => `
|
||||
<li style="display:flex;gap:10px;align-items:center;padding:2px 0">
|
||||
<code>${esc(c.name)}</code>
|
||||
<span style="color:var(--text-muted,#888);font-size:.85em">${fmtDate(c.modifiedAt)}</span>
|
||||
${c.liveSessions ? `<span style="color:var(--yellow,#ca0)">${c.liveSessions} live session(s)</span>` : ''}
|
||||
<button class="btn btn-xs" data-case="${esc(c.name)}"
|
||||
${c.liveSessions ? 'disabled title="In use by a live session"' : ''}>Delete</button>
|
||||
</li>`
|
||||
)
|
||||
.join('');
|
||||
cell.innerHTML = `<div style="padding:6px 4px 6px 16px">
|
||||
<div style="color:var(--text-muted,#888);font-size:.85em;margin-bottom:4px">${esc(data.dir || '')}</div>
|
||||
${items ? `<ul style="list-style:none;margin:0;padding:0">${items}</ul>` : 'No case folders yet.'}
|
||||
</div>`;
|
||||
cell.querySelectorAll('button[data-case]').forEach((b) => {
|
||||
b.onclick = async () => {
|
||||
const name = b.dataset.case;
|
||||
if (!window.confirm(`Permanently delete ${username}'s case folder "${name}" and ALL files in it?`)) return;
|
||||
const r = await apiSend(
|
||||
'DELETE',
|
||||
`/api/admin/users/${encodeURIComponent(username)}/cases/${encodeURIComponent(name)}`
|
||||
);
|
||||
apSetMsg(r.ok ? `Deleted folder ${name}.` : (r.body && r.body.error) || 'Delete failed.');
|
||||
renderPanel();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function openAdminPanel() {
|
||||
const me = window.__codemanUser || {};
|
||||
if (!me.multiUser || me.role !== 'admin') return;
|
||||
const el = buildAdminPanel();
|
||||
el.querySelector('#apIdentity').textContent = `signed in as ${me.username} (admin)`;
|
||||
apSetMsg('');
|
||||
el.style.display = 'flex';
|
||||
renderPanel();
|
||||
}
|
||||
|
||||
/** SSE admin:usersChanged: live-refresh whichever admin views are visible. */
|
||||
function onUsersChanged() {
|
||||
if (apModal && apModal.style.display === 'flex') renderPanel();
|
||||
const tab = document.getElementById('settings-users');
|
||||
if (tab && !tab.classList.contains('hidden')) renderUsers();
|
||||
}
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────────────────────
|
||||
async function boot() {
|
||||
installInterceptor();
|
||||
@@ -247,6 +541,9 @@
|
||||
if (window.__codemanUser.mustChangePassword) openChangePassword(true);
|
||||
if (window.__codemanUser.multiUser && window.__codemanUser.role === 'admin') {
|
||||
injectUsersTab();
|
||||
// Reveal the big header Admin Panel button (template ships it hidden).
|
||||
const btn = document.getElementById('adminPanelBtn');
|
||||
if (btn) btn.classList.remove('btn-admin-panel--hidden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,5 +553,5 @@
|
||||
boot();
|
||||
}
|
||||
|
||||
window.codemanAdmin = { openChangePassword, renderUsers };
|
||||
window.codemanAdmin = { openChangePassword, renderUsers, openAdminPanel, onUsersChanged };
|
||||
})();
|
||||
|
||||
@@ -216,6 +216,10 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.MUX_DIED, '_onMuxDied'],
|
||||
[SSE_EVENTS.MUX_STATS_UPDATED, '_onMuxStatsUpdated'],
|
||||
|
||||
// Remote auto-reconnect (COD-108)
|
||||
[SSE_EVENTS.REMOTE_SESSION_RECONNECTED, '_onRemoteSessionReconnected'],
|
||||
[SSE_EVENTS.REMOTE_RECONNECT_EXHAUSTED, '_onRemoteReconnectExhausted'],
|
||||
|
||||
// Ralph
|
||||
[SSE_EVENTS.SESSION_RALPH_LOOP_UPDATE, '_onRalphLoopUpdate'],
|
||||
[SSE_EVENTS.SESSION_RALPH_TODO_UPDATE, '_onRalphTodoUpdate'],
|
||||
@@ -287,6 +291,12 @@ const _SSE_HANDLER_MAP = [
|
||||
|
||||
// Clipboard
|
||||
[SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'],
|
||||
|
||||
// Session order (global tab order sync, COD-131)
|
||||
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
|
||||
|
||||
// Web tabs (dashboard URLs)
|
||||
[SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'],
|
||||
];
|
||||
|
||||
|
||||
@@ -787,6 +797,7 @@ class CodemanApp {
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.restorePlanUsageChip();
|
||||
this.applySkin();
|
||||
this.applyLocalization();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
// Remove mobile-init class now that JS has applied visibility settings.
|
||||
@@ -813,6 +824,7 @@ class CodemanApp {
|
||||
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null).catch(() => null);
|
||||
this.loadQuickStartCases(null, settingsPromise);
|
||||
this._initRunMode();
|
||||
this.initWebviews?.();
|
||||
this.setupEventListeners();
|
||||
// Mobile: ensure button taps register even when keyboard is visible.
|
||||
// On mobile, tapping a button while the soft keyboard is up causes the
|
||||
@@ -845,6 +857,7 @@ class CodemanApp {
|
||||
this.loadAppSettingsFromServer(settingsPromise).then(() => {
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.applySkin();
|
||||
this.applyLocalization();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
// ultracodeFloatingWindows syncs from the server (non-display key), but on a
|
||||
@@ -998,9 +1011,18 @@ class CodemanApp {
|
||||
const digitMatch = code.match(/^Digit([1-9])$/);
|
||||
if (digitMatch) {
|
||||
const idx = parseInt(digitMatch[1], 10) - 1;
|
||||
// Sessions occupy 1..N and web tabs continue from N+1, matching the
|
||||
// numbers actually painted on the tabs.
|
||||
if (idx < this.sessionOrder.length) {
|
||||
e.preventDefault();
|
||||
this.selectSession(this.sessionOrder[idx]);
|
||||
} else {
|
||||
const webIdx = idx - this.sessionOrder.length;
|
||||
const webId = (this.webviewOrder || [])[webIdx];
|
||||
if (webId) {
|
||||
e.preventDefault();
|
||||
this.openWebview(webId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1300,7 +1322,7 @@ class CodemanApp {
|
||||
if (titleEl) { titleEl.textContent = name; titleEl.style.display = ''; }
|
||||
const redock = document.getElementById('soloRedockBtn');
|
||||
if (redock) redock.style.display = '';
|
||||
document.title = name + ' — Codeman';
|
||||
document.title = name + ' — ' + (window.CodemanI18n?.displayName || 'Codeman');
|
||||
if (this.notificationManager) this.notificationManager.originalTitle = document.title;
|
||||
// Neutralize the dashboard-only brand click in a solo window.
|
||||
const logo = document.querySelector('.header-brand .logo');
|
||||
@@ -1318,7 +1340,8 @@ class CodemanApp {
|
||||
+ '<p>This session has ended or is no longer available.</p>'
|
||||
+ '<button class="btn-primary" onclick="window.close()">Close window</button>';
|
||||
document.body.appendChild(el);
|
||||
document.title = 'Session ended — Codeman';
|
||||
document.title = (window.codemanT?.('Session ended') || 'Session ended')
|
||||
+ ' — ' + (window.CodemanI18n?.displayName || 'Codeman');
|
||||
}
|
||||
|
||||
connectSSE() {
|
||||
@@ -1472,6 +1495,10 @@ class CodemanApp {
|
||||
console.error('[SSE] docker container recreated:', err);
|
||||
}
|
||||
});
|
||||
// Multi-user admin: live-refresh whichever admin views (panel/Users tab) are open.
|
||||
addListener(SSE_EVENTS.ADMIN_USERS_CHANGED, () => {
|
||||
window.codemanAdmin?.onUsersChanged?.();
|
||||
});
|
||||
// Base image auto-build on first Docker case (build-on-first-use). A single
|
||||
// multi-minute event; surface start/finish so the Run spinner is explained.
|
||||
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_STARTED, () => {
|
||||
@@ -1496,6 +1523,26 @@ class CodemanApp {
|
||||
console.error('[SSE] docker image build failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// COD-139: a session:pinned event updates the local live-session pin flag (so
|
||||
// a subsequent render is consistent) and re-sorts the open session manager /
|
||||
// welcome list so pinned sessions float to the top.
|
||||
addListener(SSE_EVENTS.SESSION_PINNED, (e) => {
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(e.data);
|
||||
} catch {
|
||||
/* ignore malformed payload */
|
||||
}
|
||||
if (data && data.id) {
|
||||
const live = this.sessions.get(data.id);
|
||||
if (live) {
|
||||
live.pinned = data.pinned === true;
|
||||
live.pinnedAt = data.pinned ? data.pinnedAt : undefined;
|
||||
}
|
||||
}
|
||||
this._onSessionListMaybeChanged();
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1888,7 +1935,9 @@ class CodemanApp {
|
||||
body.innerHTML = this._renderMarkdown(lastResponse);
|
||||
this._bindResponseViewerInteractions(body);
|
||||
} else {
|
||||
body.textContent = 'No response yet — send a message in this session first.';
|
||||
body.textContent =
|
||||
window.codemanT?.('No response yet — send a message in this session first.') ||
|
||||
'No response yet — send a message in this session first.';
|
||||
}
|
||||
|
||||
// Reset state for fresh open
|
||||
@@ -3019,6 +3068,13 @@ class CodemanApp {
|
||||
// on another device).
|
||||
try { localStorage.removeItem('codeman-tab-meta'); } catch {}
|
||||
|
||||
// COD-131: server is authoritative for global tab order. Seed localStorage
|
||||
// from the server snapshot (if present) so syncSessionOrder() reconciles
|
||||
// against the cross-device order rather than this device's stale local copy.
|
||||
if (Array.isArray(data.sessionOrder) && data.sessionOrder.length) {
|
||||
try { localStorage.setItem('codeman-session-order', JSON.stringify(data.sessionOrder)); } catch {}
|
||||
}
|
||||
|
||||
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
|
||||
this.syncSessionOrder();
|
||||
|
||||
@@ -3229,9 +3285,21 @@ class CodemanApp {
|
||||
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
|
||||
const currentIds = new Set(this.sessions.keys());
|
||||
|
||||
// Check if we can do incremental update (same session IDs)
|
||||
// Web tabs live in the same strip but are not in this.sessions, so they need
|
||||
// their own change check. Without it, the session-only comparison below is
|
||||
// vacuously "unchanged" whenever session count is stable — most visibly with
|
||||
// ZERO sessions (0 === 0), where opening a dashboard would never draw its tab.
|
||||
const existingWebIds = [...container.querySelectorAll('.session-tab[data-webview-id]')].map(
|
||||
t => t.dataset.webviewId
|
||||
);
|
||||
const wantedWebIds = (this.webviewOrder || []).filter(id => this.webviews?.has(id));
|
||||
const webTabsUnchanged =
|
||||
existingWebIds.length === wantedWebIds.length && existingWebIds.every((id, i) => id === wantedWebIds[i]);
|
||||
|
||||
// Check if we can do incremental update (same session IDs and same web tabs)
|
||||
const canIncremental = existingIds.size === currentIds.size &&
|
||||
[...existingIds].every(id => currentIds.has(id));
|
||||
[...existingIds].every(id => currentIds.has(id)) &&
|
||||
webTabsUnchanged;
|
||||
|
||||
if (canIncremental) {
|
||||
// Incremental update - only modify changed properties
|
||||
@@ -3239,7 +3307,12 @@ class CodemanApp {
|
||||
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||
if (!tab) continue;
|
||||
|
||||
const isActive = id === this.activeSessionId;
|
||||
// A web tab owns the active state while one is open. activeSessionId stays
|
||||
// set (the terminal keeps streaming underneath, and switching back is
|
||||
// instant): only the highlight moves. Without this the debounced render
|
||||
// re-marks the session tab active moments after a web tab was selected,
|
||||
// leaving two tabs lit at once.
|
||||
const isActive = id === this.activeSessionId && !this.activeWebviewId;
|
||||
const status = session.status || 'idle';
|
||||
const name = this.getSessionName(session);
|
||||
const taskStats = session.taskStats || { running: 0, total: 0 };
|
||||
@@ -3426,7 +3499,9 @@ class CodemanApp {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) continue; // Skip if session was removed
|
||||
|
||||
const isActive = id === this.activeSessionId;
|
||||
// See the note in the incremental path: a web tab owns the active highlight
|
||||
// while one is open, even though activeSessionId stays set.
|
||||
const isActive = id === this.activeSessionId && !this.activeWebviewId;
|
||||
const status = session.status || 'idle';
|
||||
const name = this.getSessionName(session);
|
||||
const mode = session.mode || 'claude';
|
||||
@@ -3473,6 +3548,11 @@ class CodemanApp {
|
||||
_tabIdx++;
|
||||
}
|
||||
|
||||
// Web tabs (dashboard URLs) render after the session tabs, continuing the
|
||||
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
|
||||
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
|
||||
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
|
||||
// Set up drag-and-drop handlers for tab reordering
|
||||
@@ -3583,13 +3663,42 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Save session order to localStorage
|
||||
// Save session order to localStorage and (debounced) sync to the server so it
|
||||
// follows the user across devices (COD-131). localStorage stays the offline
|
||||
// fallback; the server is authoritative and echoes back via SSE.
|
||||
saveSessionOrder() {
|
||||
try {
|
||||
localStorage.setItem('codeman-session-order', JSON.stringify(this.sessionOrder));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
const order = [...this.sessionOrder];
|
||||
this._debouncedCall('saveSessionOrderServer', () => {
|
||||
fetch('/api/session-order', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order }),
|
||||
}).catch(() => {});
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// COD-131: another device (or our own debounced push) reordered tabs. Adopt the
|
||||
// server order as the new base and reconcile to our currently-open sessions.
|
||||
// Guard against no-op churn so an echo of our own push doesn't flicker the tabs.
|
||||
_onSessionOrderChanged(data) {
|
||||
if (!data || !Array.isArray(data.order)) return;
|
||||
try {
|
||||
localStorage.setItem('codeman-session-order', JSON.stringify(data.order));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
const before = JSON.stringify(this.sessionOrder);
|
||||
this.syncSessionOrder();
|
||||
// Only re-render when the reconciled order actually changed (avoids flicker
|
||||
// when the broadcast is just an echo of the order we already have).
|
||||
if (JSON.stringify(this.sessionOrder) !== before) {
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
}
|
||||
|
||||
// Set up drag-and-drop handlers on tab elements
|
||||
@@ -3978,6 +4087,9 @@ class CodemanApp {
|
||||
return; // newer tab switch won
|
||||
}
|
||||
|
||||
// A session tab takes the stage back from any active web tab.
|
||||
this._hideWebviewLayer?.();
|
||||
|
||||
this._cleanupPreviousSession(sessionId);
|
||||
this.activeSessionId = sessionId;
|
||||
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
|
||||
|
||||
@@ -322,6 +322,7 @@ const SSE_EVENTS = {
|
||||
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
|
||||
SESSION_RESPAWN_BREAKER_TRIPPED: 'session:respawnBreakerTripped',
|
||||
SESSION_CLI_INFO: 'session:cliInfo',
|
||||
SESSION_PINNED: 'session:pinned',
|
||||
SESSION_MESSAGE: 'session:message',
|
||||
SESSION_INTERACTIVE: 'session:interactive',
|
||||
SESSION_RUNNING: 'session:running',
|
||||
@@ -379,6 +380,11 @@ const SSE_EVENTS = {
|
||||
MUX_DIED: 'mux:died',
|
||||
MUX_STATS_UPDATED: 'mux:statsUpdated',
|
||||
|
||||
// Remote auto-reconnect (COD-108)
|
||||
REMOTE_SESSION_DROPPED: 'remote:sessionDropped',
|
||||
REMOTE_SESSION_RECONNECTED: 'remote:sessionReconnected',
|
||||
REMOTE_RECONNECT_EXHAUSTED: 'remote:reconnectExhausted',
|
||||
|
||||
// Ralph
|
||||
SESSION_RALPH_LOOP_UPDATE: 'session:ralphLoopUpdate',
|
||||
SESSION_RALPH_TODO_UPDATE: 'session:ralphTodoUpdate',
|
||||
@@ -485,6 +491,12 @@ const SSE_EVENTS = {
|
||||
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',
|
||||
|
||||
// Web tabs (dashboard URLs)
|
||||
WEBVIEW_CHANGED: 'webview:changed',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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);
|
||||
@@ -45,16 +45,20 @@
|
||||
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
|
||||
<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>
|
||||
<script>try{var s=localStorage.getItem('codeman:skin'),a=['og','daylight-green','daylight-blue','paper-gray','solarized-light','catppuccin-latte','rose-pine-dawn'];if(a.indexOf(s)<0)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}
|
||||
.skeleton-header{height:40px;background:rgba(31,38,48,0.85);border-bottom:1px solid rgba(255,255,255,0.08);display:flex;align-items:center;padding:0 12px}
|
||||
.skeleton-brand{color:#38b6f0;font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
.skeleton-header{height:40px;background:var(--glass-bg,rgba(31,38,48,0.85));border-bottom:1px solid var(--glass-border,rgba(255,255,255,0.08));display:flex;align-items:center;padding:0 12px}
|
||||
.skeleton-brand{color:var(--accent,#38b6f0);font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
|
||||
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
|
||||
.skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px}
|
||||
.skeleton-terminal{flex:1;background:#161b23}
|
||||
.skeleton-toolbar{height:42px;background:rgba(31,38,48,0.85);border-top:1px solid rgba(255,255,255,0.08)}
|
||||
.skeleton-tab{width:80px;height:24px;background:var(--control-bg,rgba(255,255,255,0.04));border-radius:6px}
|
||||
.skeleton-terminal{flex:1;background:var(--term-bg,#161b23)}
|
||||
.skeleton-toolbar{height:42px;background:var(--glass-bg,rgba(31,38,48,0.85));border-top:1px solid var(--glass-border,rgba(255,255,255,0.08))}
|
||||
.app-loaded .loading-skeleton{display:none}
|
||||
</style>
|
||||
</head>
|
||||
@@ -76,7 +80,9 @@
|
||||
<!-- Compact Header with Session Tabs -->
|
||||
<header class="header">
|
||||
<div class="header-brand">
|
||||
<span class="logo" onclick="app.goHome()" title="Go to main page">Codeman</span>
|
||||
<span class="logo" onclick="app.goHome()" title="Go to main page"
|
||||
><span class="logo-text">Codeman</span><span class="logo-compact" aria-hidden="true">C</span></span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Session Tabs -->
|
||||
@@ -87,6 +93,10 @@
|
||||
<div class="solo-session-title" id="soloSessionTitle" style="display: none;" aria-live="polite"></div>
|
||||
|
||||
<div class="header-right" id="headerRight">
|
||||
<button class="btn-admin-panel btn-admin-panel--hidden" id="adminPanelBtn" onclick="window.codemanAdmin.openAdminPanel()" title="Admin Panel (multi-user administration)" aria-label="Open admin panel">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
<span>Admin Panel</span>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">⊞</button>
|
||||
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
||||
<span class="tunnel-dot"></span>
|
||||
@@ -124,7 +134,7 @@
|
||||
<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-file-viewer" 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>
|
||||
@@ -132,9 +142,9 @@
|
||||
<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="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-lifecycle-log" onclick="app.openLifecycleLog()" title="Session Lifecycle Log" aria-label="Open session lifecycle log"><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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></button>
|
||||
<button class="btn-icon-header btn-lifecycle-log" style="display: none" onclick="app.openLifecycleLog()" title="Session Lifecycle Log" aria-label="Open session lifecycle log"><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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></button>
|
||||
<button class="btn-icon-header btn-settings" onclick="app.openAppSettings()" title="App Settings" aria-label="Open app settings"><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="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||
<div class="header-tokens" id="headerTokens" title="Total tokens across all sessions">0 tokens</div>
|
||||
<div class="header-tokens" id="headerTokens" style="display: none" title="Total tokens across all sessions">0 tokens</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -290,6 +300,11 @@
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Web tab layer: one iframe per open dashboard, shown in place of the
|
||||
terminal while a web tab is active. Frames stay mounted while hidden so
|
||||
switching tabs does not reload (and re-authenticate) a dashboard. -->
|
||||
<div class="webview-layer" id="webviewLayer"></div>
|
||||
|
||||
<!-- Welcome Overlay (shown when no session active) -->
|
||||
<div class="welcome-overlay" id="welcomeOverlay">
|
||||
<div class="welcome-content">
|
||||
@@ -450,6 +465,18 @@
|
||||
<span class="run-mode-dot gemini"></span>Gemini
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
|
||||
<span class="run-mode-dot shell"></span>Terminal / Shell
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<!-- Web tabs: dashboards open as tabs beside agent sessions. These do NOT
|
||||
set runMode: the Run button always means "start an agent". -->
|
||||
<div class="run-mode-header">Web / URL</div>
|
||||
<div class="run-mode-webviews" id="runModeWebviews"></div>
|
||||
<button class="run-mode-option run-mode-option--add" onclick="app.showWebviewModal()">
|
||||
<span class="run-mode-dot web"></span>Add URL…
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<div class="run-mode-header">Recent Sessions</div>
|
||||
<div class="run-mode-history" id="runModeHistory"></div>
|
||||
</div>
|
||||
@@ -465,6 +492,12 @@
|
||||
<button class="btn-toolbar btn-shell" onclick="app.runShell()" title="Run Shell">
|
||||
Run Shell
|
||||
</button>
|
||||
<!-- Phone-only: replaces the Shell button on ≤430px (Shell moves into the Run
|
||||
dropdown there). Sends a bare Enter to the active session, the complement
|
||||
to the accessory bar's Esc. Hidden everywhere else — see styles.css. -->
|
||||
<button class="btn-toolbar btn-enter" onclick="app.sendEnterKey()" title="Send Enter">
|
||||
Enter
|
||||
</button>
|
||||
<div class="tab-count-group" title="Instance count">
|
||||
<button class="tab-count-btn" onclick="app.decrementShellCount()">−</button>
|
||||
<input type="number" id="shellCount" class="tab-count-input" value="1" min="1" max="20" readonly>
|
||||
@@ -558,7 +591,7 @@
|
||||
<div class="toolbar-right">
|
||||
<!-- Orchestrator button hidden until feature is ready -->
|
||||
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">⚙ Orchestrator</button> -->
|
||||
<button class="btn-toolbar btn-sm btn-cron" onclick="app.openCron()" title="Cron Jobs">⏰ Cron</button>
|
||||
<button class="btn-toolbar btn-sm btn-cron btn-cron--hidden" onclick="app.openCron()" title="Cron Jobs">⏰ Cron</button>
|
||||
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -619,6 +652,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Web Tab (dashboard URL) editor -->
|
||||
<div class="modal" id="webviewModal">
|
||||
<div class="modal-backdrop" onclick="app.closeWebviewModal()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="webviewModalTitle">Add URL</h3>
|
||||
<button class="modal-close" onclick="app.closeWebviewModal()" aria-label="Close URL editor">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<label for="webviewName">Name</label>
|
||||
<input type="text" id="webviewName" placeholder="Grafana" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="webviewUrl">URL</label>
|
||||
<input type="text" id="webviewUrl" placeholder="http://100.70.56.18:4000/" autocomplete="off"
|
||||
autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">
|
||||
Reached from the Codeman server, so a tailnet or localhost address works even when
|
||||
this browser cannot see it. Plain HTTP is fine: the dashboard is proxied through
|
||||
Codeman, which is also what gets past dashboards that refuse to be embedded.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="webviewIcon">Icon</label>
|
||||
<!-- Click to pick; the field stays editable so any emoji still works. -->
|
||||
<div class="webview-icon-picker" id="webviewIconPicker" role="group" aria-label="Choose an icon"></div>
|
||||
<input type="text" id="webviewIcon" placeholder="Or paste any emoji" maxlength="8" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="webviewSandboxed" checked> Open sandboxed</label>
|
||||
<span class="form-hint">
|
||||
Recommended. A proxied dashboard is served from Codeman's own address, so unchecking
|
||||
this lets its JavaScript read this page and call the API that starts agents. Uncheck
|
||||
only for a dashboard you fully trust, or one whose own login needs cookies.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<button class="btn-secondary" onclick="app.testWebviewUrl()">Test</button>
|
||||
<span class="form-hint webview-probe-result" id="webviewProbeResult"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions webview-modal-actions">
|
||||
<button class="btn-danger" id="webviewDeleteBtn" onclick="app.deleteWebview()">Delete</button>
|
||||
<button class="btn-secondary" onclick="app.closeWebviewModal()">Cancel</button>
|
||||
<button class="btn-primary" onclick="app.saveWebview()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cron Jobs Modal -->
|
||||
<div class="modal" id="cronModal">
|
||||
<div class="modal-backdrop" onclick="app.closeCron()"></div>
|
||||
@@ -1161,14 +1244,36 @@
|
||||
<!-- Display Tab -->
|
||||
<div class="modal-tab-content" id="settings-display">
|
||||
<div class="settings-grid">
|
||||
<!-- Branding & Language Section -->
|
||||
<div class="settings-section-header">Branding & Language</div>
|
||||
<div class="settings-item" title="Name shown in the browser UI and window title. Supports Unicode, including Chinese.">
|
||||
<span class="settings-item-label">Display Name</span>
|
||||
<input type="text" id="appSettingsDisplayName" class="settings-inline-input" maxlength="40" placeholder="Codeman" autocomplete="off">
|
||||
</div>
|
||||
<div class="settings-item" title="Language for this device. Dynamic status messages and dialogs use the same language.">
|
||||
<span class="settings-item-label">Interface Language</span>
|
||||
<select id="appSettingsLanguage" class="form-select settings-inline-select">
|
||||
<option value="en">English</option>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Appearance Section -->
|
||||
<div class="settings-section-header">Appearance</div>
|
||||
<div class="settings-item settings-item-skin" title="Visual theme for this device (not synced)">
|
||||
<span class="settings-item-label">Skin</span>
|
||||
<select id="appSettingsSkin" class="form-select">
|
||||
<option value="daylight-blue">Daylight Blue</option>
|
||||
<option value="daylight-green">Daylight Green</option>
|
||||
<option value="og">OG Codeman</option>
|
||||
<optgroup label="Light">
|
||||
<option value="paper-gray">Paper Gray</option>
|
||||
<option value="solarized-light">Solarized Light</option>
|
||||
<option value="catppuccin-latte">Catppuccin Latte</option>
|
||||
<option value="rose-pine-dawn">Rosé Pine Dawn</option>
|
||||
</optgroup>
|
||||
<optgroup label="Dark">
|
||||
<option value="daylight-blue">Daylight Blue</option>
|
||||
<option value="daylight-green">Daylight Green</option>
|
||||
<option value="og">OG Codeman</option>
|
||||
</optgroup>
|
||||
</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.">
|
||||
@@ -1501,6 +1606,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">
|
||||
@@ -1930,8 +2043,11 @@
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Folder Path</label>
|
||||
<input type="text" id="linkCasePath" placeholder="/home/user/projects/my-project" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="linkCasePath" placeholder="/mnt/d/AI/my-project" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<button type="button" class="btn path-input-browse" onclick="app.openLinkCasePathPicker()">Browse…</button>
|
||||
</div>
|
||||
<span class="form-hint">Choose an existing folder from this computer or enter its absolute path</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Remote Tab -->
|
||||
@@ -1993,6 +2109,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<!-- COD-105 — discover + attach existing remote tmux sessions this Codeman didn't create. -->
|
||||
<details class="advanced-options" id="remoteDiscoverSection">
|
||||
<summary>Discover existing sessions</summary>
|
||||
<div class="advanced-options-content">
|
||||
<span class="form-hint">Find <code>codeman-*</code> tmux sessions already running on this host (started by the remote's own Codeman or another instance) and attach to one. Attaching shares the session; closing the tab detaches it — it is never killed.</span>
|
||||
<div class="form-row" style="margin-top: 8px;">
|
||||
<button type="button" class="btn-toolbar" id="remoteDiscoverBtn" onclick="app.discoverRemoteSessions()">Discover existing sessions</button>
|
||||
</div>
|
||||
<div id="remoteDiscoverResults" class="remote-discover-results"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<!-- Docker Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-docker">
|
||||
@@ -2479,6 +2606,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>
|
||||
@@ -2497,6 +2625,7 @@
|
||||
<script defer src="ultracode-panel.js"></script>
|
||||
<script defer src="admin-ui.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="webview-tabs.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
<script defer src="subagent-windows.js"></script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @fileoverview Mobile keyboard accessory bar and modal focus trap.
|
||||
*
|
||||
* Defines two exports:
|
||||
* Defines three exports:
|
||||
*
|
||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss.
|
||||
@@ -10,12 +10,15 @@
|
||||
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
* - PathPicker (singleton object) — Lazy server-side file/folder browser shared
|
||||
* by Link Existing and the extended mobile keyboard bar.
|
||||
*
|
||||
* - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element.
|
||||
* Saves and restores previously focused element on deactivate. Used by Ralph wizard
|
||||
* and other modal dialogs.
|
||||
*
|
||||
* @globals {object} KeyboardAccessoryBar
|
||||
* @globals {object} PathPicker
|
||||
* @globals {class} FocusTrap
|
||||
*
|
||||
* @dependency mobile-handlers.js (MobileDetection.isTouchDevice)
|
||||
@@ -26,6 +29,338 @@
|
||||
// Codeman — Keyboard accessory bar and focus trap for modals
|
||||
// Loaded after mobile-handlers.js, before app.js
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Shared Filesystem Path Picker
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const PathPicker = {
|
||||
overlay: null,
|
||||
_options: null,
|
||||
_selectedPath: '',
|
||||
_previousFocus: null,
|
||||
_keydownHandler: null,
|
||||
_loadSequence: 0,
|
||||
_previewOverlay: null,
|
||||
_previewRequestSequence: 0,
|
||||
_previewPreviousFocus: null,
|
||||
|
||||
/**
|
||||
* Open the lazy filesystem browser.
|
||||
* @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean,
|
||||
* title?: string, onSelect: (path: string) => void}} options
|
||||
*/
|
||||
open(options) {
|
||||
this.close(false);
|
||||
this._options = options;
|
||||
this._selectedPath = '';
|
||||
this._previousFocus = document.activeElement;
|
||||
this._previousFocus?.blur?.();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'path-picker-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', options.title || 'Select a path');
|
||||
overlay.innerHTML = `
|
||||
<div class="path-picker-dialog">
|
||||
<div class="path-picker-header">
|
||||
<strong class="path-picker-title"></strong>
|
||||
<button type="button" class="path-picker-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="path-picker-roots-row">
|
||||
<label for="pathPickerRoot">Location</label>
|
||||
<select id="pathPickerRoot" class="path-picker-roots"></select>
|
||||
</div>
|
||||
<div class="path-picker-nav">
|
||||
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">↑</button>
|
||||
<div class="path-picker-current" title="Current folder"></div>
|
||||
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">↻</button>
|
||||
</div>
|
||||
<div class="path-picker-status" aria-live="polite">Loading...</div>
|
||||
<div class="path-picker-list" role="listbox"></div>
|
||||
<div class="path-picker-selection">
|
||||
<span class="path-picker-selection-label">Selected</span>
|
||||
<span class="path-picker-selection-value">None</span>
|
||||
</div>
|
||||
<div class="path-picker-actions">
|
||||
<button type="button" class="path-picker-current-select">Select Current Folder</button>
|
||||
<span class="path-picker-action-spacer"></span>
|
||||
<button type="button" class="path-picker-cancel">Cancel</button>
|
||||
<button type="button" class="path-picker-confirm" disabled>Select</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
this.overlay = overlay;
|
||||
overlay.querySelector('.path-picker-title').textContent = options.title || 'Select a Path';
|
||||
overlay.querySelector('.path-picker-close').addEventListener('click', () => this.close(true));
|
||||
overlay.querySelector('.path-picker-cancel').addEventListener('click', () => this.close(true));
|
||||
overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm());
|
||||
overlay.querySelector('.path-picker-current-select').addEventListener('click', () => {
|
||||
const current = overlay.querySelector('.path-picker-current').textContent;
|
||||
if (current) this.select(current);
|
||||
});
|
||||
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
|
||||
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
|
||||
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
|
||||
if (parent) this.load(parent);
|
||||
});
|
||||
overlay.querySelector('.path-picker-roots').addEventListener('change', (event) => this.load(event.target.value));
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) this.close(true);
|
||||
});
|
||||
this._keydownHandler = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (this._previewOverlay) this.closePreview(true);
|
||||
else this.close(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', this._keydownHandler);
|
||||
document.body.appendChild(overlay);
|
||||
this.load(options.initialPath || '');
|
||||
},
|
||||
|
||||
async load(path) {
|
||||
if (!this.overlay || !this._options) return;
|
||||
const loadSequence = ++this._loadSequence;
|
||||
const list = this.overlay.querySelector('.path-picker-list');
|
||||
const status = this.overlay.querySelector('.path-picker-status');
|
||||
list.replaceChildren();
|
||||
status.textContent = 'Loading...';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (path) params.set('path', path);
|
||||
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
|
||||
try {
|
||||
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) throw new Error(result.error || 'Failed to browse this folder');
|
||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||
this.render(result.data);
|
||||
} catch (error) {
|
||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||
if (path) {
|
||||
this.load('');
|
||||
return;
|
||||
}
|
||||
status.textContent = error.message || 'Failed to browse this folder';
|
||||
status.classList.add('error');
|
||||
}
|
||||
},
|
||||
|
||||
render(data) {
|
||||
const rootSelect = this.overlay.querySelector('.path-picker-roots');
|
||||
rootSelect.replaceChildren();
|
||||
for (const root of data.roots) {
|
||||
const option = document.createElement('option');
|
||||
option.value = root.path;
|
||||
option.textContent = `${root.label} — ${root.path}`;
|
||||
option.selected = data.path === root.path || data.root === root.path;
|
||||
rootSelect.appendChild(option);
|
||||
}
|
||||
|
||||
this.overlay.querySelector('.path-picker-current').textContent = data.path;
|
||||
const up = this.overlay.querySelector('.path-picker-up');
|
||||
up.dataset.parent = data.parent || '';
|
||||
up.disabled = !data.parent;
|
||||
const status = this.overlay.querySelector('.path-picker-status');
|
||||
status.classList.remove('error');
|
||||
status.textContent = data.entries.length === 0
|
||||
? 'This folder is empty'
|
||||
: `${data.entries.length} item${data.entries.length === 1 ? '' : 's'}${data.truncated ? ' (first 500)' : ''}`;
|
||||
|
||||
const list = this.overlay.querySelector('.path-picker-list');
|
||||
list.replaceChildren();
|
||||
for (const entry of data.entries) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'path-picker-item';
|
||||
if (entry.type === 'file' && this._options.directoriesOnly && !entry.previewKind) {
|
||||
row.classList.add('not-selectable');
|
||||
}
|
||||
row.dataset.path = entry.path;
|
||||
row.dataset.type = entry.type;
|
||||
row.setAttribute('role', 'option');
|
||||
|
||||
const open = document.createElement('button');
|
||||
open.type = 'button';
|
||||
open.className = 'path-picker-item-main';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'path-picker-item-icon';
|
||||
icon.textContent = entry.type === 'directory' ? '\uD83D\uDCC1' : '\uD83D\uDCC4';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'path-picker-item-name';
|
||||
name.textContent = entry.name;
|
||||
open.append(icon, name);
|
||||
if (entry.symlink) {
|
||||
const link = document.createElement('span');
|
||||
link.className = 'path-picker-item-link';
|
||||
link.textContent = '\u2197';
|
||||
open.appendChild(link);
|
||||
}
|
||||
if (entry.type === 'directory') {
|
||||
const chevron = document.createElement('span');
|
||||
chevron.className = 'path-picker-item-chevron';
|
||||
chevron.textContent = '\u203A';
|
||||
open.appendChild(chevron);
|
||||
open.addEventListener('click', () => this.load(entry.path));
|
||||
} else if (entry.previewKind) {
|
||||
const preview = document.createElement('span');
|
||||
preview.className = 'path-picker-item-preview';
|
||||
preview.textContent = '\uD83D\uDC41';
|
||||
open.appendChild(preview);
|
||||
open.title = `Preview ${entry.name}`;
|
||||
open.setAttribute('aria-label', `Preview ${entry.name}`);
|
||||
open.addEventListener('click', () => this.openPreview(entry));
|
||||
} else if (!this._options.directoriesOnly) {
|
||||
open.addEventListener('click', () => this.select(entry.path));
|
||||
} else {
|
||||
open.disabled = true;
|
||||
}
|
||||
row.appendChild(open);
|
||||
|
||||
if (entry.type === 'directory' || !this._options.directoriesOnly) {
|
||||
const choose = document.createElement('button');
|
||||
choose.type = 'button';
|
||||
choose.className = 'path-picker-item-select';
|
||||
choose.textContent = 'Choose';
|
||||
choose.addEventListener('click', () => this.select(entry.path));
|
||||
row.appendChild(choose);
|
||||
}
|
||||
list.appendChild(row);
|
||||
}
|
||||
},
|
||||
|
||||
select(path) {
|
||||
if (!this.overlay) return;
|
||||
this._selectedPath = path;
|
||||
this.overlay.querySelector('.path-picker-selection-value').textContent = path;
|
||||
this.overlay.querySelector('.path-picker-confirm').disabled = false;
|
||||
this.overlay.querySelectorAll('.path-picker-item').forEach((row) => {
|
||||
const selected = row.dataset.path === path;
|
||||
row.classList.toggle('selected', selected);
|
||||
row.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
});
|
||||
},
|
||||
|
||||
openPreview(entry) {
|
||||
this.closePreview(false);
|
||||
this._previewPreviousFocus = document.activeElement;
|
||||
const requestSequence = ++this._previewRequestSequence;
|
||||
const params = new URLSearchParams({ path: entry.path });
|
||||
if (this._options?.sessionId) params.set('sessionId', this._options.sessionId);
|
||||
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'path-preview-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', `Preview ${entry.name}`);
|
||||
overlay.innerHTML = `
|
||||
<div class="path-preview-dialog">
|
||||
<div class="path-preview-header">
|
||||
<div class="path-preview-heading">
|
||||
<strong class="path-preview-title"></strong>
|
||||
<span class="path-preview-path"></span>
|
||||
</div>
|
||||
<a class="path-preview-open" target="_blank" rel="noopener noreferrer">Open</a>
|
||||
<button type="button" class="path-preview-close" aria-label="Close preview">×</button>
|
||||
</div>
|
||||
<div class="path-preview-body"><div class="path-preview-loading">Loading preview...</div></div>
|
||||
</div>`;
|
||||
overlay.querySelector('.path-preview-title').textContent = entry.name;
|
||||
overlay.querySelector('.path-preview-path').textContent = entry.path;
|
||||
overlay.querySelector('.path-preview-open').href = previewUrl;
|
||||
overlay.querySelector('.path-preview-close').addEventListener('click', () => this.closePreview(true));
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) this.closePreview(true);
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
this._previewOverlay = overlay;
|
||||
|
||||
const body = overlay.querySelector('.path-preview-body');
|
||||
if (entry.previewKind === 'image') {
|
||||
const image = document.createElement('img');
|
||||
image.className = 'path-preview-image';
|
||||
image.alt = entry.name;
|
||||
image.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
|
||||
image.addEventListener('error', () => this.showPreviewError('Image preview failed to load'));
|
||||
image.src = previewUrl;
|
||||
body.appendChild(image);
|
||||
} else if (entry.previewKind === 'text') {
|
||||
fetch(previewUrl)
|
||||
.then(async (response) => {
|
||||
const content = await response.text();
|
||||
if (!response.ok) {
|
||||
let message = 'Text preview failed to load';
|
||||
try {
|
||||
message = JSON.parse(content).error || message;
|
||||
} catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
return content;
|
||||
})
|
||||
.then((content) => {
|
||||
if (!this._previewOverlay || requestSequence !== this._previewRequestSequence) return;
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'path-preview-text';
|
||||
pre.textContent = content;
|
||||
body.replaceChildren(pre);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (requestSequence === this._previewRequestSequence) this.showPreviewError(error.message);
|
||||
});
|
||||
} else {
|
||||
const frame = document.createElement('iframe');
|
||||
frame.className = 'path-preview-frame';
|
||||
frame.title = entry.name;
|
||||
frame.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
|
||||
frame.src = previewUrl;
|
||||
body.appendChild(frame);
|
||||
}
|
||||
overlay.querySelector('.path-preview-close').focus();
|
||||
},
|
||||
|
||||
showPreviewError(message) {
|
||||
const body = this._previewOverlay?.querySelector('.path-preview-body');
|
||||
if (!body) return;
|
||||
const error = document.createElement('div');
|
||||
error.className = 'path-preview-error';
|
||||
error.textContent = message || 'Preview failed to load';
|
||||
body.replaceChildren(error);
|
||||
},
|
||||
|
||||
closePreview(restoreFocus = true) {
|
||||
this._previewRequestSequence += 1;
|
||||
this._previewOverlay?.remove();
|
||||
this._previewOverlay = null;
|
||||
const previousFocus = this._previewPreviousFocus;
|
||||
this._previewPreviousFocus = null;
|
||||
if (restoreFocus) previousFocus?.focus?.();
|
||||
},
|
||||
|
||||
confirm() {
|
||||
if (!this._selectedPath || !this._options) return;
|
||||
const selectedPath = this._selectedPath;
|
||||
const onSelect = this._options.onSelect;
|
||||
this.close(false);
|
||||
onSelect(selectedPath);
|
||||
},
|
||||
|
||||
close(restoreFocus = true) {
|
||||
if (this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler);
|
||||
this._keydownHandler = null;
|
||||
this._loadSequence += 1;
|
||||
this.closePreview(false);
|
||||
this.overlay?.remove();
|
||||
this.overlay = null;
|
||||
const previousFocus = this._previousFocus;
|
||||
this._previousFocus = null;
|
||||
this._options = null;
|
||||
this._selectedPath = '';
|
||||
if (restoreFocus) previousFocus?.focus?.();
|
||||
},
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Mobile Keyboard Accessory Bar
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -92,6 +427,8 @@ const KeyboardAccessoryBar = {
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">📁 Path</button>
|
||||
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">⌫ All</button>
|
||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
|
||||
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
|
||||
@@ -128,7 +465,7 @@ const KeyboardAccessoryBar = {
|
||||
this.handleAction(action, btn);
|
||||
|
||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
|
||||
if (refocusActions.has(action) ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
@@ -207,6 +544,12 @@ const KeyboardAccessoryBar = {
|
||||
case 'paste':
|
||||
this.pasteFromClipboard();
|
||||
break;
|
||||
case 'pick-path':
|
||||
this.pickPath();
|
||||
break;
|
||||
case 'clear-input':
|
||||
app.clearTerminalInput?.();
|
||||
break;
|
||||
case 'dismiss':
|
||||
// Blur active element to dismiss keyboard
|
||||
document.activeElement?.blur();
|
||||
@@ -265,6 +608,22 @@ const KeyboardAccessoryBar = {
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
/** Browse the active session's workspace and insert a selected path without Enter. */
|
||||
pickPath() {
|
||||
if (!app.activeSessionId) return;
|
||||
const session = app.sessions?.get(app.activeSessionId);
|
||||
PathPicker.open({
|
||||
title: 'Insert File or Folder Path',
|
||||
sessionId: app.activeSessionId,
|
||||
initialPath: session?.workingDir || '',
|
||||
directoriesOnly: false,
|
||||
onSelect: (path) => {
|
||||
app.insertTerminalText?.(path);
|
||||
setTimeout(() => app.terminal?.focus(), 100);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** Show a paste overlay for iOS compatibility.
|
||||
* Handles three input paths from one dialog:
|
||||
* - Text: long-press the textarea → Paste → Send (unchanged).
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -350,7 +350,8 @@ html.mobile-init .file-browser-panel {
|
||||
Phone Breakpoint (<430px)
|
||||
============================================================================ */
|
||||
@media (max-width: 430px) {
|
||||
/* Compact header brand on phones — acts as home button */
|
||||
/* Phone brand collapses to a single "C" home button: hide the wordmark,
|
||||
keep the tap target */
|
||||
.header-brand {
|
||||
padding-right: 0.25rem;
|
||||
margin-right: 0.2rem;
|
||||
@@ -358,7 +359,15 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
.header-brand .logo {
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.header-brand .logo .logo-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-brand .logo .logo-compact {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Font controls - compact on phones, visibility controlled by JS */
|
||||
@@ -474,6 +483,13 @@ html.mobile-init .file-browser-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* The big labeled Admin Panel button is desktop-only (admin-gated, revealed by
|
||||
admin-ui.js). On phones admins still reach user management via App Settings →
|
||||
Users, so the cramped header stays minimal. */
|
||||
.btn-admin-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Mobile voice input button in toolbar — initially hidden via inline style,
|
||||
shown by VoiceInput._showButtons() clearing the inline display */
|
||||
.btn-voice-mobile {
|
||||
@@ -839,6 +855,17 @@ html.mobile-init .file-browser-panel {
|
||||
-webkit-tap-highlight-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Per-URL edit/delete in the Web / URL list need a real touch target, and they
|
||||
sit next to the row's own tap area, so they get sized up rather than relying
|
||||
on the 24px desktop hit box. */
|
||||
.run-mode-row-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 1rem;
|
||||
-webkit-tap-highlight-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.run-mode-webview-delete { font-size: 1.2rem; }
|
||||
|
||||
.run-mode-history {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: manipulation;
|
||||
@@ -859,19 +886,44 @@ html.mobile-init .file-browser-panel {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Secondary action - Run Shell - right side */
|
||||
/* Shell is NOT a toolbar button on phones — it moved into the Run dropdown
|
||||
(Terminal / Shell), freeing this slot for Enter. Starting a shell is a rare,
|
||||
deliberate act; sending Enter is a constant one, so the scarce phone real
|
||||
estate goes to Enter. */
|
||||
.btn-toolbar.btn-shell {
|
||||
flex: 0 0 auto;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #9ca3af;
|
||||
order: 4; /* Right position */
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-shell:hover,
|
||||
.btn-toolbar.btn-shell:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
/* Secondary action - Enter - right side. Takes the slot (and the order) the
|
||||
Shell button used to hold, so the toolbar rhythm is unchanged. */
|
||||
.btn-toolbar.btn-enter {
|
||||
display: flex !important;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 54px;
|
||||
width: 54px;
|
||||
white-space: nowrap;
|
||||
padding: 0 8px !important;
|
||||
overflow: hidden;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
/* !important is REQUIRED here, not defensive habit: styles.css nests its skin
|
||||
overrides inside `html:not([data-skin="og"]) { … }`, so a plain `.btn-toolbar`
|
||||
in that block resolves to (0,2,1) and outranks this (0,2,0) rule. Without
|
||||
!important the button silently renders in generic toolbar grey. */
|
||||
background: rgba(30, 58, 95, 0.85) !important;
|
||||
border: 1px solid rgba(59, 130, 246, 0.45) !important;
|
||||
color: #dbeafe !important;
|
||||
order: 4; /* Right position — same slot Shell used to occupy */
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-enter:hover,
|
||||
.btn-toolbar.btn-enter:active {
|
||||
background: rgba(37, 74, 122, 0.95) !important;
|
||||
border-color: rgba(59, 130, 246, 0.7) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Hide case selector on mobile - simplified toolbar */
|
||||
@@ -879,27 +931,12 @@ html.mobile-init .file-browser-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Simplified toolbar layout — Run, Shell, and Case */
|
||||
/* Simplified toolbar layout — Run, Enter, and Case */
|
||||
.toolbar-left .toolbar-group:first-child {
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-shell {
|
||||
flex: 0 0 auto;
|
||||
min-width: 54px;
|
||||
width: 54px;
|
||||
white-space: nowrap;
|
||||
padding: 0 8px !important;
|
||||
overflow: hidden;
|
||||
font-size: 0 !important;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-shell::after {
|
||||
content: "Shell";
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
/* Mobile case button - visible on mobile */
|
||||
.btn-toolbar.btn-case-mobile {
|
||||
display: flex !important;
|
||||
@@ -2210,6 +2247,68 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
}
|
||||
|
||||
/* Light-skin compatibility for mobile-only chrome. These components predate
|
||||
the shared skin system and intentionally retain their original dark values
|
||||
for the three dark skins above. */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.header, .toolbar, .keyboard-accessory-bar) {
|
||||
background: var(--glass-bg);
|
||||
border-color: var(--glass-border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .toolbar .btn-case-add, .accessory-btn) {
|
||||
background: var(--control-bg);
|
||||
border-color: var(--control-border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .btn-case-add:hover, .btn-case-add:active, .accessory-btn:active) {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-claude, .btn-toolbar.btn-run-gear.mode-claude) {
|
||||
background: linear-gradient(135deg, var(--accent-grad-a), var(--accent-grad-b));
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-opencode, .btn-toolbar.btn-run-gear.mode-opencode) {
|
||||
background: linear-gradient(135deg, var(--accent-d), var(--accent-grad-b));
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-gemini, .btn-toolbar.btn-run-gear.mode-gemini) {
|
||||
background: linear-gradient(135deg, #174ea6, #4f46e5);
|
||||
border-color: #315fc3;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
|
||||
border-left-color: var(--control-border-hover) !important;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.case-settings-popover-mobile, .mobile-case-picker-sheet) {
|
||||
background: var(--floating-bg);
|
||||
border-color: var(--control-border);
|
||||
color: var(--text);
|
||||
box-shadow: var(--elevated-shadow);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.case-settings-popover-mobile .checkbox-inline, #createCaseModal .form-row label) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .case-settings-popover-mobile .form-hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .mobile-case-picker .modal-backdrop {
|
||||
background: var(--modal-backdrop);
|
||||
}
|
||||
|
||||
|
||||
/* Keyboard accessory bar + paste overlay base styles moved to styles.css
|
||||
(always loaded — covers iPad landscape where mobile.css doesn't load).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -82,6 +82,33 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// Remote auto-reconnect (COD-108)
|
||||
_onRemoteSessionReconnected(data) {
|
||||
const id = this.getShortId(data.sessionId);
|
||||
this.showToast(`Remote session ${id} reconnected`, 'success');
|
||||
},
|
||||
|
||||
_onRemoteReconnectExhausted(data) {
|
||||
const sessionId = data.sessionId;
|
||||
const id = this.getShortId(sessionId);
|
||||
// Auto-reconnect gave up after the bounded backoff. Surface a manual
|
||||
// "Reconnect" affordance that re-triggers the attach path (force-reload the
|
||||
// session, which re-runs the create/attach flow against the durable remote).
|
||||
this.showToast(`Remote session ${id} dropped — auto-reconnect gave up`, 'error', {
|
||||
duration: 15000,
|
||||
action: {
|
||||
label: 'Reconnect',
|
||||
onClick: () => {
|
||||
if (this.sessions && this.sessions.has(sessionId)) {
|
||||
this.selectSession(sessionId, { forceReload: true });
|
||||
} else {
|
||||
this.showToast('Session no longer available', 'warning');
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
// Bash tools
|
||||
_onBashToolStart(data) {
|
||||
@@ -2240,6 +2267,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const terminal = new Terminal({
|
||||
theme: { ...window.codemanCurrentXtermTheme() },
|
||||
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.2,
|
||||
|
||||
@@ -350,19 +350,63 @@ Object.assign(CodemanApp.prototype, {
|
||||
return this.run();
|
||||
},
|
||||
|
||||
/** Ensure a newly-created session is visible without waiting for the SSE event.
|
||||
* The POST response and session:created can arrive in either order, so the
|
||||
* normal idempotent SSE handler remains the single state-upsert path. */
|
||||
async _ensureCreatedSessionVisible(sessionId, sessionSnapshot) {
|
||||
if (!sessionId) return;
|
||||
|
||||
let session = sessionSnapshot;
|
||||
if (!session && !this.sessions?.has(sessionId)) {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`);
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to load the new session');
|
||||
session = data.data?.session || data.data;
|
||||
}
|
||||
|
||||
if (session?.id) this._onSessionCreated(session);
|
||||
// session:created normally uses the debounced renderer. The direct POST path
|
||||
// needs the tab in the DOM before selectSession() marks it active.
|
||||
this._renderSessionTabsImmediate?.();
|
||||
},
|
||||
|
||||
/** Run using the selected mode (Claude Code, OpenCode, Codex, or Gemini) */
|
||||
async run() {
|
||||
const mode = this._runMode || 'claude';
|
||||
if (mode === 'opencode') {
|
||||
return this.runOpenCode();
|
||||
if (this._runInFlight) return;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const minLockMs = Number.isFinite(this._runMinLockMs) ? this._runMinLockMs : 500;
|
||||
const runBtn = document.getElementById('runBtn');
|
||||
this._runInFlight = true;
|
||||
if (runBtn) {
|
||||
runBtn.disabled = true;
|
||||
runBtn.setAttribute('aria-busy', 'true');
|
||||
}
|
||||
if (mode === 'codex') {
|
||||
return this.runCodex();
|
||||
|
||||
try {
|
||||
const mode = this._runMode || 'claude';
|
||||
if (mode === 'opencode') {
|
||||
return await this.runOpenCode();
|
||||
}
|
||||
if (mode === 'codex') {
|
||||
return await this.runCodex();
|
||||
}
|
||||
if (mode === 'gemini') {
|
||||
return await this.runGemini();
|
||||
}
|
||||
if (mode === 'shell') {
|
||||
return await this.runShell();
|
||||
}
|
||||
return await this.runClaude();
|
||||
} finally {
|
||||
const remaining = minLockMs - (Date.now() - startedAt);
|
||||
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
|
||||
this._runInFlight = false;
|
||||
if (runBtn) {
|
||||
runBtn.disabled = false;
|
||||
runBtn.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
if (mode === 'gemini') {
|
||||
return this.runGemini();
|
||||
}
|
||||
return this.runClaude();
|
||||
},
|
||||
|
||||
// Note: `runMode` is an accessor defined via Object.defineProperty at the bottom of
|
||||
@@ -438,7 +482,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
btn.append(dirSpan, metaSpan);
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir);
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir, s.name);
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
@@ -459,10 +503,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
||||
}
|
||||
if (label) {
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : 'Run';
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'shell' ? 'Run SH' : 'Run';
|
||||
}
|
||||
},
|
||||
|
||||
/** Send Enter to the active session (phone toolbar button).
|
||||
*
|
||||
* MUST go through xterm's onData path, NOT straight to sendInput()/the API.
|
||||
* With local echo on (the mobile default) the characters you typed are still
|
||||
* buffered in the LocalEchoOverlay and have NEVER reached the PTY. The onData
|
||||
* Enter branch (terminal-ui.js) is what flushes that pending text and only
|
||||
* then sends \r. Send a bare \r instead and you submit an empty line while the
|
||||
* typed text stays stranded on screen — which reads as "the button does
|
||||
* nothing". triggerDataEvent replays it exactly as if the key were pressed,
|
||||
* so overlay flush, flushed-offset cleanup and ordering are all reused. */
|
||||
sendEnterKey() {
|
||||
if (!this.activeSessionId) return;
|
||||
const coreService = this.terminal?._core?.coreService;
|
||||
if (coreService && typeof coreService.triggerDataEvent === 'function') {
|
||||
coreService.triggerDataEvent('\r', true);
|
||||
return;
|
||||
}
|
||||
// Fallback only if xterm's private core API moves: correct when local echo
|
||||
// is off, and still better than doing nothing.
|
||||
this.sendInput('\r');
|
||||
},
|
||||
|
||||
_initRunMode() {
|
||||
try { this._runMode = localStorage.getItem('codeman_runMode') || 'claude'; } catch { this._runMode = 'claude'; }
|
||||
this._applyRunMode();
|
||||
@@ -596,6 +662,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
this.terminal.writeln(`\x1b[90m All ${tabCount} remote session(s) ready\x1b[0m`);
|
||||
@@ -659,6 +726,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sessionIds = [];
|
||||
for (const result of createResults) {
|
||||
if (!result.success) throw new Error(result.error);
|
||||
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
|
||||
sessionIds.push(result.data.session.id);
|
||||
}
|
||||
firstSessionId = sessionIds[0];
|
||||
@@ -774,6 +842,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
if (remoteIds[0]) {
|
||||
@@ -807,6 +876,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sessionIds = [];
|
||||
for (const result of createResults) {
|
||||
if (!result.success) throw new Error(result.error);
|
||||
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
|
||||
sessionIds.push(result.data.session.id);
|
||||
}
|
||||
|
||||
@@ -884,6 +954,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start OpenCode');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
|
||||
// Switch to the new session (don't pre-set activeSessionId — selectSession
|
||||
// early-returns when IDs match, skipping buffer load and sendResize)
|
||||
@@ -940,6 +1011,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start Codex');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
|
||||
// Switch to the new session (don't pre-set activeSessionId — selectSession
|
||||
// early-returns when IDs match, skipping buffer load and sendResize)
|
||||
@@ -992,6 +1064,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start Gemini');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
|
||||
if (data.data.sessionId) {
|
||||
await this.selectSession(data.data.sessionId);
|
||||
@@ -1815,6 +1888,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
openLinkCasePathPicker() {
|
||||
const pathInput = document.getElementById('linkCasePath');
|
||||
PathPicker.open({
|
||||
title: 'Select Existing Project Folder',
|
||||
initialPath: pathInput.value.trim(),
|
||||
directoriesOnly: true,
|
||||
onSelect: (path) => {
|
||||
pathInput.value = path;
|
||||
const nameInput = document.getElementById('linkCaseName');
|
||||
if (!nameInput.value.trim()) {
|
||||
const folderName = path.split('/').filter(Boolean).pop() || '';
|
||||
if (/^[\p{L}\p{N}_-]+$/u.test(folderName)) nameInput.value = folderName;
|
||||
}
|
||||
pathInput.focus();
|
||||
pathInput.setSelectionRange(path.length, path.length);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async linkRemoteCase() {
|
||||
const name = document.getElementById('remoteCaseName').value.trim();
|
||||
const remotePath = document.getElementById('remoteCasePath').value.trim();
|
||||
@@ -2068,6 +2160,156 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// COD-105 — Discover + attach existing remote tmux sessions
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Read the remote-host fields from the remote-case form into a host payload. */
|
||||
_readRemoteHostFromForm() {
|
||||
const hostId = document.getElementById('remoteHostId').value.trim();
|
||||
const host = document.getElementById('remoteHostAddress').value.trim();
|
||||
const username = document.getElementById('remoteHostUsername').value.trim();
|
||||
const portRaw = document.getElementById('remoteHostPort').value.trim();
|
||||
const identityFile = document.getElementById('remoteHostIdentityFile').value.trim();
|
||||
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
|
||||
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
|
||||
const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
|
||||
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
let port;
|
||||
if (portRaw) {
|
||||
const n = Number(portRaw);
|
||||
if (Number.isInteger(n) && n >= 1 && n <= 65535) port = n;
|
||||
}
|
||||
return {
|
||||
id: hostId,
|
||||
label: hostId,
|
||||
host,
|
||||
username,
|
||||
...(port ? { port } : {}),
|
||||
...(identityFile ? { identityFile } : {}),
|
||||
...(socksProxy ? { socksProxy } : {}),
|
||||
...(jumpHost ? { jumpHost } : {}),
|
||||
...(extraSshOptions.length ? { extraSshOptions } : {}),
|
||||
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Explicit Discover action (Decision A — never auto-runs on host select).
|
||||
* Saves the host config (idempotent), then queries the host for `codeman-*`
|
||||
* tmux sessions it didn't create and renders an Attach action per session.
|
||||
*/
|
||||
async discoverRemoteSessions() {
|
||||
const results = document.getElementById('remoteDiscoverResults');
|
||||
const btn = document.getElementById('remoteDiscoverBtn');
|
||||
const hostPayload = this._readRemoteHostFromForm();
|
||||
if (!hostPayload.id || !hostPayload.host || !hostPayload.username) {
|
||||
this.showToast('Fill in Host ID, address, and username first', 'error');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(hostPayload.id)) {
|
||||
this.showToast('Invalid Host ID. Use letters, numbers, hyphens, underscores.', 'error');
|
||||
return;
|
||||
}
|
||||
if (btn) btn.disabled = true;
|
||||
if (results) results.innerHTML = '<div class="form-hint">Discovering…</div>';
|
||||
try {
|
||||
// Persist the host so the discovery endpoint can resolve it by id (idempotent).
|
||||
const hostRes = await fetch('/api/remote-hosts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(hostPayload)
|
||||
});
|
||||
const hostData = await hostRes.json();
|
||||
if (!hostData.success && hostData.errorCode !== 'ALREADY_EXISTS') {
|
||||
throw new Error(hostData.error || 'Failed to save remote host');
|
||||
}
|
||||
const res = await fetch(`/api/remote-hosts/${encodeURIComponent(hostPayload.id)}/sessions`);
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Discovery failed');
|
||||
this._renderDiscoveredSessions(hostPayload.id, data.data.sessions || []);
|
||||
} catch (err) {
|
||||
console.error('Discover remote sessions failed:', err);
|
||||
if (results) results.innerHTML = `<div class="form-hint" style="color: var(--error, #e06c75);">${escapeHtml(err.message)}</div>`;
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Render the discovered remote sessions with an Attach action each. */
|
||||
_renderDiscoveredSessions(hostId, sessions) {
|
||||
const results = document.getElementById('remoteDiscoverResults');
|
||||
if (!results) return;
|
||||
if (!sessions.length) {
|
||||
results.innerHTML = '<div class="form-hint">No <code>codeman-*</code> sessions running on this host (or it is unreachable).</div>';
|
||||
return;
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const rows = sessions.map(s => {
|
||||
const ageSecs = Math.max(0, now - (s.created || 0));
|
||||
const age = ageSecs < 3600 ? `${Math.floor(ageSecs / 60)}m` : ageSecs < 86400 ? `${Math.floor(ageSecs / 3600)}h` : `${Math.floor(ageSecs / 86400)}d`;
|
||||
// COD-106 — show "shared · N clients" when more than one client is attached
|
||||
// (genuinely collaborative), else a plain "attached" badge for a single client.
|
||||
const clients = s.attachedClients != null ? s.attachedClients : s.attached ? 1 : 0;
|
||||
const attachedBadge =
|
||||
clients > 1
|
||||
? `<span class="case-location-badge" style="background: var(--warning, #e5c07b); color: #000;">shared · ${clients} clients</span>`
|
||||
: clients === 1
|
||||
? '<span class="case-location-badge" style="background: var(--accent, #61afef);">attached</span>'
|
||||
: '';
|
||||
return `
|
||||
<div class="remote-discover-item">
|
||||
<div class="remote-discover-info">
|
||||
<span class="remote-discover-name">${escapeHtml(s.name)} ${attachedBadge}</span>
|
||||
<span class="form-hint">age ${age} · ${s.windows || 1} window(s)</span>
|
||||
</div>
|
||||
<button type="button" class="btn-toolbar" onclick="app.attachDiscoveredSession('${escapeHtml(hostId)}', '${escapeHtml(s.name)}')">Attach</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
results.innerHTML = rows;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a NON-owned session that attaches to a discovered remote tmux session.
|
||||
* Closing this tab detaches — it never kills the remote session.
|
||||
*/
|
||||
async attachDiscoveredSession(hostId, remoteSessionName) {
|
||||
try {
|
||||
const createRes = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mode: 'shell',
|
||||
name: remoteSessionName,
|
||||
attachRemoteSession: { hostId, remoteSessionName },
|
||||
})
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
if (!createData.success) throw new Error(createData.error || 'Failed to create session');
|
||||
const id = createData.data.session.id;
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
const dims = this.getTerminalDimensions();
|
||||
if (dims) {
|
||||
await fetch(`/api/sessions/${id}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dims)
|
||||
});
|
||||
}
|
||||
this.closeCreateCaseModal();
|
||||
this.showToast(`Attached to ${remoteSessionName} (detach on close)`, 'success');
|
||||
this.activeSessionId = id;
|
||||
await this.selectSession(id);
|
||||
if (this.terminal && typeof this.terminal.focus === 'function') this.terminal.focus();
|
||||
} catch (err) {
|
||||
console.error('Attach discovered session failed:', err);
|
||||
this.showToast('Failed to attach: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Case Management (reorder + delete)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -297,6 +297,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
openAppSettings() {
|
||||
// Load current settings
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
document.getElementById('appSettingsDisplayName').value = settings.displayName || 'Codeman';
|
||||
document.getElementById('appSettingsLanguage').value = settings.language === 'zh-CN' ? 'zh-CN' : 'en';
|
||||
document.getElementById('appSettingsClaudeMdPath').value = settings.defaultClaudeMdPath || '';
|
||||
document.getElementById('appSettingsDefaultDir').value = settings.defaultWorkingDir || '';
|
||||
// Use device-aware defaults for display settings (mobile has different defaults)
|
||||
@@ -305,9 +307,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Header visibility settings
|
||||
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
|
||||
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
|
||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
|
||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false;
|
||||
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? false;
|
||||
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
|
||||
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
|
||||
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
|
||||
// WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide
|
||||
@@ -328,7 +330,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Session Manager + Away Digest buttons default OFF; Cron button defaults ON.
|
||||
document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false;
|
||||
document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false;
|
||||
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? true;
|
||||
document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||
@@ -364,6 +366,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||
document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? '';
|
||||
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
|
||||
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
|
||||
document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? '';
|
||||
// CPU Priority settings
|
||||
const niceSettings = settings.nice || {};
|
||||
@@ -1419,6 +1422,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
// as "previously off" — used below to detect a real OFF→ON flip.
|
||||
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
|
||||
const settings = {
|
||||
displayName: window.CodemanI18n?.normalizeDisplayName(
|
||||
document.getElementById('appSettingsDisplayName').value
|
||||
) || 'Codeman',
|
||||
language: window.CodemanI18n?.normalizeLanguage(
|
||||
document.getElementById('appSettingsLanguage').value
|
||||
) || 'en',
|
||||
defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(),
|
||||
defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(),
|
||||
ralphTrackerEnabled: document.getElementById('appSettingsRalphEnabled').checked,
|
||||
@@ -1462,6 +1471,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
claudeModel: document.getElementById('appSettingsClaudeModel').value,
|
||||
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
||||
remoteAutoReconnect: document.getElementById('appSettingsRemoteAutoReconnect').checked,
|
||||
thinkingEffort: document.getElementById('appSettingsThinkingEffort').value,
|
||||
// CPU Priority settings
|
||||
nice: {
|
||||
@@ -1590,6 +1600,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Apply header visibility immediately
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.applySkin();
|
||||
this.applyLocalization();
|
||||
this.applyTabWrapSettings();
|
||||
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
|
||||
this.applyMonitorVisibility();
|
||||
@@ -1618,6 +1629,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
cjkInputEnabled: _cjk,
|
||||
extendedKeyboardBar: _ekb,
|
||||
skin: _skin,
|
||||
language: _language,
|
||||
showPlanUsageLimits: _pul,
|
||||
showAttachmentsButton: _ahb,
|
||||
showFileViewerButton: _fvb,
|
||||
@@ -1756,17 +1768,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
return settings.ralphTrackerEnabled ?? false;
|
||||
},
|
||||
|
||||
// Get the settings storage key based on device type (mobile vs desktop)
|
||||
// Keep the settings namespace stable across foldable posture changes. Layout
|
||||
// still follows viewport width, but an unfolded phone remains the same
|
||||
// handheld device and must not silently switch to desktop preferences.
|
||||
getSettingsStorageKey() {
|
||||
const isMobile = MobileDetection.getDeviceType() === 'mobile';
|
||||
return isMobile ? 'codeman-app-settings-mobile' : 'codeman-app-settings';
|
||||
const isHandheld =
|
||||
MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile';
|
||||
return isHandheld ? 'codeman-app-settings-mobile' : 'codeman-app-settings';
|
||||
},
|
||||
|
||||
// Get default settings based on device type
|
||||
// Note: Notification prefs are handled separately by NotificationManager
|
||||
getDefaultSettings() {
|
||||
const isMobile = MobileDetection.getDeviceType() === 'mobile';
|
||||
if (isMobile) {
|
||||
const isHandheld =
|
||||
MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile';
|
||||
if (isHandheld) {
|
||||
// Mobile defaults: minimal UI for small screens
|
||||
return {
|
||||
// Header visibility - hide everything on mobile
|
||||
@@ -1788,7 +1804,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
showRedrawButton: false,
|
||||
showSessionButton: false,
|
||||
showAwayDigestButton: false,
|
||||
showCronButton: true,
|
||||
showCronButton: false,
|
||||
// Remote auto-reconnect (COD-108) — on by default
|
||||
remoteAutoReconnect: true,
|
||||
// Input
|
||||
gestureControlEnabled: false,
|
||||
// Feature toggles - keep tracking on even on mobile
|
||||
@@ -1848,6 +1866,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const skin = settings.skin ?? defaults.skin ?? 'daylight-blue';
|
||||
document.documentElement.setAttribute('data-skin', skin);
|
||||
window.__codemanSkin = skin;
|
||||
const themeColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-dark').trim();
|
||||
if (themeColor) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', themeColor);
|
||||
try {
|
||||
localStorage.setItem('codeman:skin', skin);
|
||||
} catch (_e) {
|
||||
@@ -1856,13 +1876,28 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (typeof this.applyTerminalSkin === 'function') this.applyTerminalSkin(skin);
|
||||
},
|
||||
|
||||
// Apply the per-device language and the synced user-facing product name.
|
||||
// The i18n layer updates both existing static nodes and future dynamic DOM.
|
||||
applyLocalization() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const result = window.CodemanI18n?.configure({
|
||||
language: settings.language,
|
||||
displayName: settings.displayName,
|
||||
});
|
||||
if (result && this.notificationManager) {
|
||||
this.notificationManager.originalTitle = document.title;
|
||||
}
|
||||
},
|
||||
|
||||
applyHeaderVisibilitySettings() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
|
||||
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
|
||||
const showSystemStats = compactHeader ? false : (settings.showSystemStats ?? defaults.showSystemStats ?? true);
|
||||
const showTokenCount = compactHeader ? false : (settings.showTokenCount ?? defaults.showTokenCount ?? true);
|
||||
// Default OFF: the header stays gear + usage chips + files button unless a
|
||||
// stored preference explicitly re-enables the token chip (no UI toggle exists).
|
||||
const showTokenCount = compactHeader ? false : (settings.showTokenCount ?? defaults.showTokenCount ?? false);
|
||||
|
||||
const fontControlsEl = document.querySelector('.header-font-controls');
|
||||
const systemStatsEl = document.getElementById('headerSystemStats');
|
||||
@@ -1879,7 +1914,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Hide lifecycle log button when setting is disabled
|
||||
const showLifecycleLog = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
|
||||
// Default OFF: the lifecycle-log document icon is opt-in; the default header
|
||||
// keeps only WS/CPU/MEM, the file-viewer folder, usage chips, and the gear.
|
||||
const showLifecycleLog = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false;
|
||||
const lifecycleBtn = document.querySelector('.btn-lifecycle-log');
|
||||
if (lifecycleBtn) {
|
||||
lifecycleBtn.style.display = showLifecycleLog ? '' : 'none';
|
||||
@@ -1903,7 +1940,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// File Viewer header button — opt-in, default OFF. Marker class (base is
|
||||
// display:inline-flex !important); clicking it toggles the file browser panel.
|
||||
const showFileViewerButton = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? false;
|
||||
// Default ON (desktop): the folder button is part of the standard header now;
|
||||
// phones still hide it via mobile.css (btn-file-viewer in the phone-hidden set).
|
||||
const showFileViewerButton = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
|
||||
const fileViewerBtn = document.querySelector('.btn-file-viewer');
|
||||
if (fileViewerBtn) {
|
||||
fileViewerBtn.classList.toggle('btn-file-viewer--hidden', !showFileViewerButton);
|
||||
@@ -1960,8 +1999,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
awayDigestBtn.classList.toggle('btn-away-digest--hidden', !showAwayDigestButton);
|
||||
}
|
||||
|
||||
// Cron button (footer toolbar) — shown by default; hide when disabled.
|
||||
const showCronButton = settings.showCronButton ?? defaults.showCronButton ?? true;
|
||||
// Cron button (footer toolbar) — opt-in, hidden by default. Same marker pattern.
|
||||
const showCronButton = settings.showCronButton ?? defaults.showCronButton ?? false;
|
||||
const cronBtn = document.querySelector('.btn-cron');
|
||||
if (cronBtn) {
|
||||
cronBtn.classList.toggle('btn-cron--hidden', !showCronButton);
|
||||
@@ -2176,7 +2215,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// so mobile defaults to OFF; the desktop blob is untouched and keeps its value.
|
||||
try {
|
||||
if (
|
||||
MobileDetection.getDeviceType() === 'mobile' &&
|
||||
(MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile') &&
|
||||
!localStorage.getItem('codeman:planUsagePerDeviceMigrated')
|
||||
) {
|
||||
const s = this.loadAppSettingsFromStorage();
|
||||
@@ -2204,6 +2243,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
]);
|
||||
|
||||
@@ -40,11 +40,22 @@
|
||||
og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selection: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' },
|
||||
'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selection: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
|
||||
'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selection: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
|
||||
'paper-gray': { background: '#f6f8fa', foreground: '#1f2328', cursor: '#0969da', cursorAccent: '#ffffff', selection: 'rgba(9,105,218,0.2)', black: '#24292f', red: '#cf222e', green: '#1a7f37', yellow: '#9a6700', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#59636e', brightBlack: '#6e7781', brightRed: '#a40e26', brightGreen: '#116329', brightYellow: '#7d4e00', brightBlue: '#0550ae', brightMagenta: '#6639ba', brightCyan: '#116b75', brightWhite: '#1f2328' },
|
||||
'solarized-light': { background: '#fdf6e3', foreground: '#586e75', cursor: '#147ba3', cursorAccent: '#fdf6e3', selection: 'rgba(38,139,210,0.2)', black: '#eee8d5', red: '#dc322f', green: '#758600', yellow: '#9b7800', blue: '#147ba3', magenta: '#d33682', cyan: '#2a9189', white: '#073642', brightBlack: '#93a1a1', brightRed: '#cb4b16', brightGreen: '#657b83', brightYellow: '#586e75', brightBlue: '#268bd2', brightMagenta: '#6c71c4', brightCyan: '#2aa198', brightWhite: '#002b36' },
|
||||
'catppuccin-latte': { background: '#eff1f5', foreground: '#4c4f69', cursor: '#1e66f5', cursorAccent: '#ffffff', selection: 'rgba(30,102,245,0.18)', black: '#5c5f77', red: '#d20f39', green: '#3b8f2b', yellow: '#a86605', blue: '#1e66f5', magenta: '#8839ef', cyan: '#177f86', white: '#6c6f85', brightBlack: '#7c7f93', brightRed: '#b50930', brightGreen: '#2f7622', brightYellow: '#8b5604', brightBlue: '#174fbf', brightMagenta: '#6f2bc5', brightCyan: '#116b71', brightWhite: '#4c4f69' },
|
||||
'rose-pine-dawn': { background: '#faf4ed', foreground: '#575279', cursor: '#286983', cursorAccent: '#fffaf3', selection: 'rgba(40,105,131,0.2)', black: '#575279', red: '#b4637a', green: '#286983', yellow: '#96681f', blue: '#477f91', magenta: '#907aa9', cyan: '#3f7f8b', white: '#6e6a86', brightBlack: '#797593', brightRed: '#984d66', brightGreen: '#1f5266', brightYellow: '#7d5417', brightBlue: '#386b7c', brightMagenta: '#765f90', brightCyan: '#326b76', brightWhite: '#575279' },
|
||||
};
|
||||
const CODEMAN_LIGHT_SKINS = new Set(['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn']);
|
||||
function currentSkin() {
|
||||
return (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue';
|
||||
}
|
||||
function currentXtermTheme() {
|
||||
const skin = (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue';
|
||||
const skin = currentSkin();
|
||||
return CODEMAN_XTERM_THEMES[skin] || CODEMAN_XTERM_THEMES['daylight-blue'];
|
||||
}
|
||||
function currentSkinIsLight(skin = currentSkin()) {
|
||||
return CODEMAN_LIGHT_SKINS.has(skin);
|
||||
}
|
||||
|
||||
global.CodemanTerminalInput = {
|
||||
isTerminalQueryResponse,
|
||||
@@ -54,6 +65,7 @@
|
||||
};
|
||||
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
||||
global.codemanCurrentXtermTheme = currentXtermTheme;
|
||||
global.codemanCurrentSkinIsLight = currentSkinIsLight;
|
||||
})(window);
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
@@ -75,6 +87,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'block',
|
||||
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||
scrollback: scrollback,
|
||||
allowTransparency: true,
|
||||
allowProposedApi: true,
|
||||
@@ -969,8 +982,66 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get line text - translateToString handles wrapped lines
|
||||
const lineText = line.translateToString(true);
|
||||
// Stitch the LOGICAL line back together.
|
||||
//
|
||||
// xterm invokes this provider per visible ROW, and translateToString returns
|
||||
// that row alone (the old comment here claimed otherwise). A URL or path
|
||||
// longer than the terminal is wide therefore matched only as far as the row
|
||||
// boundary, and the link opened a PREFIX of the real target. Walk out to both
|
||||
// ends of the continuation, match against the joined text, and map offsets
|
||||
// back to (x, y) so a link can span rows.
|
||||
//
|
||||
// Two different kinds of continuation, and handling only the first is not
|
||||
// enough:
|
||||
// 1. SOFT wrap: the emulator ran out of columns and flags the next row
|
||||
// `isWrapped`.
|
||||
// 2. HARD wrap: the program did its own wrapping and emitted a real
|
||||
// newline, so nothing is flagged. Ink does this, which is why Claude
|
||||
// Code's own `/login` URL was cut at the window edge, and why the
|
||||
// clickable part grew when the window was widened.
|
||||
// A row that fills the full width is treated as continuing into the next:
|
||||
// that is the signal a hard wrap leaves behind, and a line that genuinely
|
||||
// ended would stop short of the last column.
|
||||
const cols = self.terminal.cols;
|
||||
const rowAt = (r) => buffer.getLine(r - 1);
|
||||
const continuesPrevious = (r) => {
|
||||
if (r <= 1) return false;
|
||||
if (rowAt(r)?.isWrapped) return true;
|
||||
const prev = rowAt(r - 1);
|
||||
return !!prev && prev.translateToString(true).length >= cols;
|
||||
};
|
||||
|
||||
// Bounded so a screenful of full-width output (wide tables, box drawing)
|
||||
// cannot make every hover stitch and re-scan the entire viewport.
|
||||
const MAX_STITCHED_ROWS = 12;
|
||||
let startRow = bufferLineNumber;
|
||||
while (startRow > 1 && bufferLineNumber - startRow < MAX_STITCHED_ROWS && continuesPrevious(startRow)) {
|
||||
startRow--;
|
||||
}
|
||||
let endRow = bufferLineNumber;
|
||||
while (endRow < buffer.length && endRow - startRow < MAX_STITCHED_ROWS && continuesPrevious(endRow + 1)) {
|
||||
endRow++;
|
||||
}
|
||||
|
||||
const rowTexts = [];
|
||||
for (let r = startRow; r <= endRow; r++) {
|
||||
const row = rowAt(r);
|
||||
if (!row) break;
|
||||
// Only the final row may be trimmed. Continuation rows fill the width by
|
||||
// definition, and trimming one would shift every later offset.
|
||||
rowTexts.push(row.translateToString(r === endRow));
|
||||
}
|
||||
const lineText = rowTexts.join('');
|
||||
|
||||
/** Map an offset in the stitched text back to a 1-based terminal cell. */
|
||||
const coordAt = (index) => {
|
||||
let rest = index;
|
||||
for (let i = 0; i < rowTexts.length - 1; i++) {
|
||||
if (rest < rowTexts[i].length) return { x: rest + 1, y: startRow + i };
|
||||
rest -= rowTexts[i].length;
|
||||
}
|
||||
return { x: rest + 1, y: startRow + rowTexts.length - 1 };
|
||||
};
|
||||
|
||||
if (!lineText || !lineText.includes('/')) {
|
||||
callback(undefined);
|
||||
@@ -980,22 +1051,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
const links = [];
|
||||
|
||||
// Pattern 0: URLs (https://, http://) — matched first so they take priority
|
||||
const urlPattern = /https?:\/\/[^\s"'<>|;&)\]\x00-\x1f]+/g;
|
||||
//
|
||||
// A single `&` is PART of the URL: it separates query parameters, so excluding
|
||||
// it truncated every real query string (`?post=1479&action=edit` linked only
|
||||
// through `1479`, landing on the wrong page). `&&` is still a boundary, since
|
||||
// that is the shell operator and never appears inside a URL. A lone trailing
|
||||
// `&` is trimmed below with the other trailing punctuation.
|
||||
const urlPattern = /https?:\/\/(?:[^\s"'<>|;&)\]\x00-\x1f]|&(?!&))+/g;
|
||||
|
||||
const addUrlLink = (url, matchIndex) => {
|
||||
// Strip trailing punctuation that's likely not part of the URL
|
||||
const cleaned = url.replace(/[.,;:!?)]+$/, '');
|
||||
const cleaned = url.replace(/[.,;:!?)&]+$/, '');
|
||||
const startCol = lineText.indexOf(cleaned, matchIndex);
|
||||
if (startCol === -1) return;
|
||||
|
||||
if (links.some((l) => l.range.start.x === startCol + 1)) return;
|
||||
const start = coordAt(startCol);
|
||||
const end = coordAt(startCol + cleaned.length);
|
||||
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
|
||||
|
||||
links.push({
|
||||
text: cleaned,
|
||||
range: {
|
||||
start: { x: startCol + 1, y: bufferLineNumber },
|
||||
end: { x: startCol + cleaned.length + 1, y: bufferLineNumber },
|
||||
},
|
||||
range: { start, end },
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
activate(_event, text) {
|
||||
window.open(text, '_blank', 'noopener,noreferrer');
|
||||
@@ -1017,31 +1093,43 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the whole tab on hover. Non-empty token + bounded reps is O(n).
|
||||
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||||
|
||||
// Pattern 2: Paths with common extensions
|
||||
// Pattern 2: Paths with common extensions.
|
||||
// Image/PDF extensions are included so pasted-attachment paths
|
||||
// (`.claude-images/paste-*.png`) are clickable; they open the file preview
|
||||
// rather than the log viewer (see addLink).
|
||||
const extPattern =
|
||||
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||||
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js|png|jpe?g|gif|webp|bmp|svg|pdf))\b/g;
|
||||
|
||||
// Pattern 3: Bash() tool output
|
||||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||
|
||||
/** Extensions that should open the image/document preview, not the log viewer. */
|
||||
const PREVIEW_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'pdf']);
|
||||
|
||||
const addLink = (filePath, matchIndex) => {
|
||||
const startCol = lineText.indexOf(filePath, matchIndex);
|
||||
if (startCol === -1) return;
|
||||
|
||||
const start = coordAt(startCol);
|
||||
const end = coordAt(startCol + filePath.length);
|
||||
// Skip if already have link at this position
|
||||
if (links.some((l) => l.range.start.x === startCol + 1)) return;
|
||||
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
|
||||
|
||||
links.push({
|
||||
text: filePath,
|
||||
range: {
|
||||
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
|
||||
end: { x: startCol + filePath.length + 1, y: bufferLineNumber },
|
||||
},
|
||||
range: { start, end }, // 1-based, may span wrapped rows
|
||||
decorations: {
|
||||
pointerCursor: true,
|
||||
underline: true,
|
||||
},
|
||||
activate(event, text) {
|
||||
// Tailing a PNG in the log viewer shows binary noise; the file preview
|
||||
// already renders images and PDFs inline.
|
||||
const ext = (text.split('.').pop() || '').toLowerCase();
|
||||
if (PREVIEW_EXTS.has(ext)) {
|
||||
self.openFilePreview(text, self.activeSessionId);
|
||||
return;
|
||||
}
|
||||
self.openLogViewerWindow(text, self.activeSessionId);
|
||||
},
|
||||
hover() {
|
||||
@@ -1239,8 +1327,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const isLive = Array.isArray(s.sources) && s.sources.includes('live');
|
||||
|
||||
const isPinned = s.pinned === true;
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'history-item';
|
||||
item.className = 'history-item' + (isPinned ? ' is-pinned' : '');
|
||||
item.title = s.workingDir || '';
|
||||
|
||||
// Main row: clickable surface. A caller-supplied onActivate wins (the
|
||||
@@ -1258,7 +1348,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (isLive && this.sessions.has(s.sessionId)) {
|
||||
this.selectSession(s.sessionId);
|
||||
} else {
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '');
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -1268,7 +1358,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'history-item-title';
|
||||
titleSpan.textContent = s.name || s.firstPrompt || shortDir;
|
||||
if (isPinned) {
|
||||
// Filled pin glyph indicating the session is pinned to the top (COD-139).
|
||||
const pin = document.createElement('span');
|
||||
pin.className = 'history-item-pin';
|
||||
pin.textContent = '📌';
|
||||
pin.setAttribute('aria-label', 'Pinned');
|
||||
pin.title = 'Pinned';
|
||||
titleSpan.appendChild(pin);
|
||||
}
|
||||
titleSpan.appendChild(document.createTextNode(s.name || s.firstPrompt || shortDir));
|
||||
|
||||
// Badge row: mode (claude/codex/opencode/gemini/shell) + a LIVE pill.
|
||||
const badgeRow = document.createElement('div');
|
||||
@@ -1327,6 +1426,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
promptText.textContent = s.firstPrompt || '(no prompt captured)';
|
||||
promptRow.append(promptLabel, promptText);
|
||||
|
||||
// COD-145: show the most recent user prompt too, but collapse single-prompt
|
||||
// sessions (omit when there's no last prompt or it duplicates the first).
|
||||
let lastPromptRow = null;
|
||||
if (s.lastPrompt && s.lastPrompt !== s.firstPrompt) {
|
||||
lastPromptRow = document.createElement('div');
|
||||
lastPromptRow.className = 'history-detail-row';
|
||||
const lastPromptLabel = document.createElement('span');
|
||||
lastPromptLabel.className = 'history-detail-label';
|
||||
lastPromptLabel.textContent = 'Last prompt';
|
||||
const lastPromptText = document.createElement('span');
|
||||
lastPromptText.className = 'history-detail-value history-detail-prompt';
|
||||
lastPromptText.textContent = s.lastPrompt;
|
||||
lastPromptRow.append(lastPromptLabel, lastPromptText);
|
||||
}
|
||||
|
||||
const pathRow = document.createElement('div');
|
||||
pathRow.className = 'history-detail-row';
|
||||
const pathLabel = document.createElement('span');
|
||||
@@ -1345,7 +1459,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
metaParts.push(s.sessionId.slice(0, 8));
|
||||
metaRow.textContent = metaParts.join(' · ');
|
||||
|
||||
detail.append(promptRow, pathRow, metaRow);
|
||||
detail.append(promptRow);
|
||||
if (lastPromptRow) detail.append(lastPromptRow);
|
||||
detail.append(pathRow, metaRow);
|
||||
|
||||
if (showViewAll && s.projectKey) {
|
||||
const actionRow = document.createElement('div');
|
||||
@@ -1463,13 +1579,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
} else {
|
||||
// Resume by the Claude conversation UUID when present (resumed sessions
|
||||
// carry theirs separately from their Codeman id).
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '');
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name);
|
||||
}
|
||||
this.closeSessionManager?.();
|
||||
closeMenu();
|
||||
}
|
||||
);
|
||||
|
||||
// Pin / Unpin (COD-139) — floats the session to the top of the list.
|
||||
const isPinned = s.pinned === true;
|
||||
addItem(isPinned ? 'Unpin session' : 'Pin to top', async () => {
|
||||
const ok = await this._setSessionPinned(s.sessionId, !isPinned);
|
||||
if (ok) {
|
||||
// Optimistic local flip so a re-render before the SSE event is consistent.
|
||||
s.pinned = !isPinned;
|
||||
this.showToast(!isPinned ? 'Pinned to top' : 'Unpinned', 'success');
|
||||
} else {
|
||||
this.showToast('Pin failed', 'error');
|
||||
}
|
||||
closeMenu();
|
||||
});
|
||||
|
||||
// Open folder (only for a live+open session — file browser is session-scoped).
|
||||
if (isLiveOpen) {
|
||||
addItem('Open folder', () => {
|
||||
@@ -1536,6 +1666,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._openRowMenuClose = closeMenu;
|
||||
},
|
||||
|
||||
/**
|
||||
* COD-139: Toggle a session's pin via POST /api/sessions/:id/pin.
|
||||
* Pinned sessions float to the top of the session manager list. Returns true
|
||||
* on success. The live re-sort happens when the session:pinned SSE event
|
||||
* fires (handled in app.js), so callers don't need to re-render themselves.
|
||||
* @param {string} sessionId
|
||||
* @param {boolean} pinned explicit desired pin state (idempotent)
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async _setSessionPinned(sessionId, pinned) {
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/pin`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ pinned }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
return data?.success === true;
|
||||
} catch (err) {
|
||||
console.error('[_setSessionPinned]', err);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Number of history items shown before "Show More" */
|
||||
_HISTORY_INITIAL_COUNT: 4,
|
||||
|
||||
@@ -1721,7 +1877,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._folderHistoryState = null;
|
||||
},
|
||||
|
||||
async resumeHistorySession(sessionId, workingDir) {
|
||||
// Choose the name for a resumed session: keep the session's own name when it
|
||||
// has one, otherwise synthesize a fresh w<N>-<dir> name (next free w-number
|
||||
// across open sessions). COD-143 — resume used to always generate a new name.
|
||||
_resolveResumeName(existingName, workingDir) {
|
||||
if (typeof existingName === 'string' && existingName.trim()) return existingName;
|
||||
const dirName = (workingDir || '').split('/').pop() || 'session';
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^w(\d+)-/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
return `w${startNumber}-${dirName}`;
|
||||
},
|
||||
|
||||
async resumeHistorySession(sessionId, workingDir, existingName) {
|
||||
// Close the run mode menu if open
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
// Close folder history modal if open
|
||||
@@ -1730,17 +1903,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
|
||||
|
||||
// Generate a session name from the working dir
|
||||
const dirName = workingDir.split('/').pop() || 'session';
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
const match = session.name && session.name.match(/^w(\d+)-/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1]);
|
||||
if (num >= startNumber) startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
const name = `w${startNumber}-${dirName}`;
|
||||
// Keep the session's own name when resuming; only synthesize a w<N>-<dir>
|
||||
// name when the source row had none (COD-143).
|
||||
const name = this._resolveResumeName(existingName, workingDir);
|
||||
|
||||
// Create session with resumeSessionId — include envOverrides so resumed
|
||||
// conversations inherit current UI settings (effort, agent teams, etc.).
|
||||
@@ -2341,6 +2506,50 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.clear();
|
||||
},
|
||||
|
||||
/** Insert editable text at the active prompt without pressing Enter. */
|
||||
insertTerminalText(text) {
|
||||
if (!this.activeSessionId || !text) return;
|
||||
if (this._localEchoEnabled && this._localEchoOverlay) {
|
||||
this._localEchoOverlay.appendText(text);
|
||||
} else {
|
||||
this.sendInput(text).catch(() => {});
|
||||
}
|
||||
this.terminal?.focus();
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear only the current editable prompt. This is intentionally distinct
|
||||
* from Ctrl+L (clear display) and the agent's destructive `/clear` command.
|
||||
*/
|
||||
clearTerminalInput() {
|
||||
if (!this.activeSessionId) return;
|
||||
|
||||
if (typeof CjkInput !== 'undefined') CjkInput.clear();
|
||||
if (this._inputFlushTimeout) {
|
||||
clearTimeout(this._inputFlushTimeout);
|
||||
this._inputFlushTimeout = null;
|
||||
}
|
||||
this._pendingInput = '';
|
||||
|
||||
if (this._localEchoEnabled && this._localEchoOverlay) {
|
||||
const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' };
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(this.activeSessionId);
|
||||
this._flushedTexts?.delete(this.activeSessionId);
|
||||
if (flushed.count > 0) {
|
||||
this.sendInput('\x7f'.repeat(flushed.count)).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
// In non-local-echo mode the TUI already owns the editable buffer. Ctrl+U
|
||||
// is the conventional kill-line key supported by shells and agent TUIs.
|
||||
this.sendInput('\x15').catch(() => {});
|
||||
}
|
||||
|
||||
this.showToast?.('Input cleared', 'success');
|
||||
this.terminal?.focus();
|
||||
},
|
||||
|
||||
/**
|
||||
* Restore terminal size to match web UI dimensions.
|
||||
* Use this after mobile screen attachment has squeezed the terminal.
|
||||
@@ -2769,8 +2978,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// DOM and WebGL renderers) plus a belt-and-suspenders refresh().
|
||||
applyTerminalSkin(skin) {
|
||||
const theme = { ...(window.CODEMAN_XTERM_THEMES[skin] || window.CODEMAN_XTERM_THEMES['daylight-blue']) };
|
||||
const minimumContrastRatio = window.codemanCurrentSkinIsLight(skin) ? 4.5 : 1;
|
||||
if (this.terminal) {
|
||||
this.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
this.terminal.options.theme = theme;
|
||||
// The zero-lag typing overlay caches the xterm foreground/background.
|
||||
// Refresh it on live skin changes so typed text never keeps the prior
|
||||
// theme's dark backing surface or foreground color.
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
try {
|
||||
this.terminal.refresh(0, this.terminal.rows - 1);
|
||||
} catch {}
|
||||
@@ -2778,6 +2993,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this.teammateTerminals) {
|
||||
for (const [, entry] of this.teammateTerminals) {
|
||||
if (entry && entry.terminal) {
|
||||
entry.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
entry.terminal.options.theme = { ...theme };
|
||||
try {
|
||||
entry.terminal.refresh(0, entry.terminal.rows - 1);
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* @fileoverview Web tabs: saved dashboard URLs rendered as tabs beside agent
|
||||
* sessions, so Codeman is one mission control instead of Codeman plus a pile of
|
||||
* browser tabs.
|
||||
*
|
||||
* Each open dashboard is an <iframe> inside #webviewLayer, which covers the
|
||||
* terminal while a web tab is active. Frames stay MOUNTED while hidden, because a
|
||||
* dashboard that reloads and re-authenticates on every tab switch is worse than
|
||||
* the browser tab it replaced. `maxLiveFrames` (from the server) bounds that with
|
||||
* least-recently-viewed eviction.
|
||||
*
|
||||
* Sandboxing: a proxied dashboard is served from Codeman's own origin, so the
|
||||
* iframe deliberately omits `allow-same-origin` unless the dashboard is marked
|
||||
* trusted. Without that omission the page could read this document and call the
|
||||
* API that spawns agents.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js, api-client.js, constants.js (escapeHtml)
|
||||
* @loadorder 12.5 of 16, after session-ui.js (needs the tab strip), before api-client.js
|
||||
*/
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Load the saved list and restore which tabs were open. */
|
||||
async initWebviews() {
|
||||
this.webviews = this.webviews || new Map();
|
||||
this.webviewOrder = this.webviewOrder || [];
|
||||
this.activeWebviewId = this.activeWebviewId || null;
|
||||
this._webviewMaxFrames = this._webviewMaxFrames || 6;
|
||||
this._webviewFrameLru = this._webviewFrameLru || [];
|
||||
|
||||
await this.refreshWebviews();
|
||||
|
||||
// Restore the previously open web tabs (per device: which dashboards you keep
|
||||
// open is a workspace-layout choice, not something to sync across machines).
|
||||
let saved = [];
|
||||
try {
|
||||
saved = JSON.parse(localStorage.getItem('codeman-webview-order') || '[]');
|
||||
} catch {
|
||||
saved = [];
|
||||
}
|
||||
this.webviewOrder = saved.filter((id) => this.webviews.has(id));
|
||||
this.renderSessionTabs();
|
||||
},
|
||||
|
||||
async refreshWebviews() {
|
||||
const data = await this._apiJson('/api/webviews');
|
||||
if (!data) return;
|
||||
this.webviews = new Map((data.webviews || []).map((w) => [w.id, w]));
|
||||
if (typeof data.maxLiveFrames === 'number') this._webviewMaxFrames = data.maxLiveFrames;
|
||||
this.renderWebviewMenuItems();
|
||||
},
|
||||
|
||||
/** SSE: the saved list changed (possibly on another device). */
|
||||
async _onWebviewChanged(data) {
|
||||
await this.refreshWebviews();
|
||||
// A dashboard deleted elsewhere must not linger as a dead tab here.
|
||||
if (data && data.action === 'deleted' && data.id) this._removeWebviewTab(data.id);
|
||||
this.renderSessionTabs();
|
||||
},
|
||||
|
||||
_persistWebviewOrder() {
|
||||
try {
|
||||
localStorage.setItem('codeman-webview-order', JSON.stringify(this.webviewOrder || []));
|
||||
} catch {
|
||||
/* private mode / quota, order is a convenience, never fatal */
|
||||
}
|
||||
},
|
||||
|
||||
// ── Tab strip ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tab HTML for every OPEN web tab, appended by _fullRenderSessionTabs().
|
||||
* `startIndex` continues the Alt+N numbering after the session tabs.
|
||||
*/
|
||||
renderWebviewTabs(startIndex) {
|
||||
if (!this.webviewOrder || this.webviewOrder.length === 0) return '';
|
||||
const parts = [];
|
||||
let idx = startIndex;
|
||||
|
||||
for (const id of this.webviewOrder) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) continue;
|
||||
const isActive = id === this.activeWebviewId;
|
||||
const jsonId = escapeHtml(JSON.stringify(id));
|
||||
const icon = webview.icon ? escapeHtml(webview.icon) : '';
|
||||
|
||||
parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
|
||||
onclick="app.handleWebviewTabClick(event, ${jsonId})"
|
||||
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
|
||||
${idx < 9 ? '<span class="tab-number">' + (idx + 1) + '</span>' : ''}
|
||||
<span class="tab-web-icon" aria-hidden="true">${icon || this._webviewGlobeIcon()}</span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
<span class="tab-name">${escapeHtml(webview.name)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">⚙</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">×</span>
|
||||
</div>`);
|
||||
idx++;
|
||||
}
|
||||
return parts.join('');
|
||||
},
|
||||
|
||||
_webviewGlobeIcon() {
|
||||
return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20"/></svg>';
|
||||
},
|
||||
|
||||
handleWebviewTabClick(event, id) {
|
||||
event?.preventDefault?.();
|
||||
return this.openWebview(id);
|
||||
},
|
||||
|
||||
/** Mark exactly one tab active across BOTH tab kinds. */
|
||||
_updateActiveWebviewTab() {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
for (const tab of container.querySelectorAll('.session-tab[data-webview-id]')) {
|
||||
tab.classList.toggle('active', tab.dataset.webviewId === this.activeWebviewId);
|
||||
}
|
||||
if (this.activeWebviewId) {
|
||||
// A web tab is active, so no session tab may also look active.
|
||||
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Opening / closing ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open (or focus) a dashboard tab. Mints a fresh capability every time: they are
|
||||
* memory-only and expire, so a tab reopened after a server restart must not reuse
|
||||
* the dead URL from the previous run.
|
||||
*/
|
||||
async openWebview(id) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) return;
|
||||
|
||||
if (!this.webviewOrder.includes(id)) {
|
||||
this.webviewOrder.push(id);
|
||||
this._persistWebviewOrder();
|
||||
}
|
||||
|
||||
const data = await this._apiJson(`/api/webviews/${encodeURIComponent(id)}/open`, { method: 'POST' });
|
||||
if (!data) {
|
||||
this.showToast?.('Could not open URL', 'error');
|
||||
return;
|
||||
}
|
||||
if (data.webview) this.webviews.set(id, data.webview);
|
||||
|
||||
const src = data.embedUrl || data.webview?.url || webview.url;
|
||||
this._mountWebviewFrame(id, src, data.webview || webview);
|
||||
this.activeWebviewId = id;
|
||||
this.hideWelcome?.();
|
||||
document.querySelector('.main')?.classList.add('webview-active');
|
||||
this.renderSessionTabs();
|
||||
this._updateActiveWebviewTab();
|
||||
},
|
||||
|
||||
/** Create the frame if absent, then reveal it and hide its siblings. */
|
||||
_mountWebviewFrame(id, src, webview) {
|
||||
const layer = document.getElementById('webviewLayer');
|
||||
if (!layer) return;
|
||||
|
||||
let wrap = layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`);
|
||||
if (!wrap) {
|
||||
wrap = document.createElement('div');
|
||||
wrap.className = 'webview-frame';
|
||||
wrap.dataset.webviewId = id;
|
||||
|
||||
const frame = document.createElement('iframe');
|
||||
frame.className = 'webview-iframe';
|
||||
frame.setAttribute('title', webview.name);
|
||||
// No allow-same-origin unless explicitly trusted: a proxied page is served
|
||||
// from THIS origin, so granting it would let the dashboard read this document
|
||||
// and drive the Codeman API.
|
||||
const sandbox = ['allow-scripts', 'allow-forms', 'allow-popups', 'allow-downloads', 'allow-modals'];
|
||||
if (webview.trusted) sandbox.push('allow-same-origin');
|
||||
frame.setAttribute('sandbox', sandbox.join(' '));
|
||||
frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
|
||||
frame.src = src;
|
||||
|
||||
const failure = document.createElement('div');
|
||||
failure.className = 'webview-failure';
|
||||
failure.innerHTML = this._webviewFailureHtml(id);
|
||||
|
||||
wrap.appendChild(frame);
|
||||
wrap.appendChild(failure);
|
||||
layer.appendChild(wrap);
|
||||
|
||||
// A frame that never fires `load` is the normal symptom of a refused embed or
|
||||
// an unreachable host. Show an actionable panel instead of a blank rectangle.
|
||||
const timer = setTimeout(() => wrap.classList.add('webview-frame--failed'), 8000);
|
||||
frame.addEventListener('load', () => {
|
||||
clearTimeout(timer);
|
||||
wrap.classList.remove('webview-frame--failed');
|
||||
});
|
||||
}
|
||||
|
||||
this._touchWebviewFrame(id);
|
||||
for (const other of layer.querySelectorAll('.webview-frame')) {
|
||||
other.classList.toggle('active', other.dataset.webviewId === id);
|
||||
}
|
||||
// Chart libraries measure on resize; a frame revealed from display:none needs the nudge.
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
|
||||
},
|
||||
|
||||
_webviewFailureHtml(id) {
|
||||
const jsonId = escapeHtml(JSON.stringify(id));
|
||||
return `<div class="webview-failure-inner">
|
||||
<h3>This URL did not load</h3>
|
||||
<p>It may be unreachable from the Codeman server, or it may refuse to be embedded.</p>
|
||||
<div class="webview-failure-actions">
|
||||
<button class="btn-secondary" onclick="app.reloadWebview(${jsonId})">Reload</button>
|
||||
<button class="btn-secondary" onclick="app.openWebviewExternal(${jsonId})">Open in new tab</button>
|
||||
<button class="btn-secondary" onclick="app.showWebviewModal(${jsonId})">Edit</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
/** Least-recently-viewed eviction so N open dashboards cannot pin N live pages. */
|
||||
_touchWebviewFrame(id) {
|
||||
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== id);
|
||||
this._webviewFrameLru.push(id);
|
||||
const layer = document.getElementById('webviewLayer');
|
||||
if (!layer) return;
|
||||
while (this._webviewFrameLru.length > this._webviewMaxFrames) {
|
||||
const evict = this._webviewFrameLru.shift();
|
||||
if (evict === this.activeWebviewId) continue;
|
||||
layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(evict)}"]`)?.remove();
|
||||
}
|
||||
},
|
||||
|
||||
reloadWebview(id) {
|
||||
const target = id || this.activeWebviewId;
|
||||
if (!target) return;
|
||||
document
|
||||
.getElementById('webviewLayer')
|
||||
?.querySelector(`.webview-frame[data-webview-id="${CSS.escape(target)}"]`)
|
||||
?.remove();
|
||||
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== target);
|
||||
return this.openWebview(target);
|
||||
},
|
||||
|
||||
openWebviewExternal(id) {
|
||||
const webview = this.webviews.get(id || this.activeWebviewId);
|
||||
if (webview) window.open(webview.url, '_blank', 'noopener');
|
||||
},
|
||||
|
||||
closeWebviewTab(id) {
|
||||
this._removeWebviewTab(id);
|
||||
this.renderSessionTabs();
|
||||
},
|
||||
|
||||
_removeWebviewTab(id) {
|
||||
this.webviewOrder = (this.webviewOrder || []).filter((x) => x !== id);
|
||||
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== id);
|
||||
this._persistWebviewOrder();
|
||||
document
|
||||
.getElementById('webviewLayer')
|
||||
?.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`)
|
||||
?.remove();
|
||||
|
||||
if (this.activeWebviewId === id) {
|
||||
this.activeWebviewId = null;
|
||||
const next = this.webviewOrder[0];
|
||||
if (next) {
|
||||
this.openWebview(next);
|
||||
} else {
|
||||
this._hideWebviewLayer();
|
||||
// Fall back to whatever session was last shown, or the welcome screen.
|
||||
if (this.activeSessionId) this._updateActiveTabImmediate(this.activeSessionId);
|
||||
else this.showWelcome?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Called by selectSession(): a session tab takes the stage back from a web tab. */
|
||||
_hideWebviewLayer() {
|
||||
if (!this.activeWebviewId && !document.querySelector('.main.webview-active')) return;
|
||||
this.activeWebviewId = null;
|
||||
document.querySelector('.main')?.classList.remove('webview-active');
|
||||
for (const frame of document.querySelectorAll('#webviewLayer .webview-frame')) {
|
||||
frame.classList.remove('active');
|
||||
}
|
||||
this._updateActiveWebviewTab();
|
||||
},
|
||||
|
||||
// ── Run-menu entries ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Saved dashboards listed inside the Run dropdown, under "Web / URL".
|
||||
*
|
||||
* Each row carries its own edit and delete buttons. Without them the only way to
|
||||
* change or remove a saved URL was to open it as a tab first and go through the
|
||||
* tab's gear, which is a dead end for a URL you no longer want open at all.
|
||||
*/
|
||||
renderWebviewMenuItems() {
|
||||
const container = document.getElementById('runModeWebviews');
|
||||
if (!container) return;
|
||||
const list = [...(this.webviews?.values() || [])];
|
||||
if (list.length === 0) {
|
||||
container.innerHTML = '<div class="run-mode-empty">No URLs yet</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = list
|
||||
.map((w) => {
|
||||
const jsonId = escapeHtml(JSON.stringify(w.id));
|
||||
const name = escapeHtml(w.name);
|
||||
const icon = w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>';
|
||||
return `<div class="run-mode-row run-mode-row--web">
|
||||
<button class="run-mode-option run-mode-option--web" onclick="app.openWebviewFromMenu(${jsonId})" title="${escapeHtml(w.url)}">
|
||||
<span class="run-mode-menu-icon">${icon}</span><span class="run-mode-web-name">${name}</span>
|
||||
</button>
|
||||
<button class="run-mode-row-btn run-mode-webview-edit" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})"
|
||||
title="Edit URL" aria-label="Edit ${name}">⚙</button>
|
||||
<button class="run-mode-row-btn run-mode-webview-delete" onclick="event.stopPropagation(); app.deleteWebviewById(${jsonId})"
|
||||
title="Delete URL" aria-label="Delete ${name}">×</button>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
},
|
||||
|
||||
openWebviewFromMenu(id) {
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
return this.openWebview(id);
|
||||
},
|
||||
|
||||
// ── Icon picker ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Common dashboard/service glyphs. The text field stays open for anything else. */
|
||||
_webviewIconChoices() {
|
||||
return ['📊', '📈', '🖥️', '🎛️', '📡', '🐳', '🗄️', '🔒', '🌐', '📁', '🧪', '🧬', '⚡', '🔔', '📝', '🎧'];
|
||||
},
|
||||
|
||||
_renderWebviewIconPicker(selected) {
|
||||
const picker = document.getElementById('webviewIconPicker');
|
||||
if (!picker) return;
|
||||
picker.innerHTML = this._webviewIconChoices()
|
||||
.map(
|
||||
(icon) =>
|
||||
`<button type="button" class="webview-icon-choice${icon === selected ? ' selected' : ''}"
|
||||
onclick="app.pickWebviewIcon(${escapeHtml(JSON.stringify(icon))})"
|
||||
aria-label="Use ${escapeHtml(icon)} as the icon">${escapeHtml(icon)}</button>`
|
||||
)
|
||||
.join('');
|
||||
},
|
||||
|
||||
/** Clicking the selected icon again clears it, so there is a way back to no icon. */
|
||||
pickWebviewIcon(icon) {
|
||||
const field = document.getElementById('webviewIcon');
|
||||
if (!field) return;
|
||||
field.value = field.value === icon ? '' : icon;
|
||||
this._renderWebviewIconPicker(field.value);
|
||||
},
|
||||
|
||||
// ── Editor modal ──────────────────────────────────────────────────────────
|
||||
|
||||
showWebviewModal(id) {
|
||||
const modal = document.getElementById('webviewModal');
|
||||
if (!modal) return;
|
||||
const webview = id ? this.webviews.get(id) : null;
|
||||
this._editingWebviewId = webview ? webview.id : null;
|
||||
|
||||
document.getElementById('webviewModalTitle').textContent = webview ? 'Edit URL' : 'Add URL';
|
||||
this._renderWebviewIconPicker(webview?.icon || '');
|
||||
document.getElementById('webviewName').value = webview?.name || '';
|
||||
document.getElementById('webviewUrl').value = webview?.url || '';
|
||||
document.getElementById('webviewIcon').value = webview?.icon || '';
|
||||
document.getElementById('webviewSandboxed').checked = !webview?.trusted;
|
||||
document.getElementById('webviewProbeResult').textContent = '';
|
||||
document.getElementById('webviewDeleteBtn').style.display = webview ? '' : 'none';
|
||||
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
modal.classList.add('active');
|
||||
document.getElementById('webviewName').focus();
|
||||
},
|
||||
|
||||
closeWebviewModal() {
|
||||
document.getElementById('webviewModal')?.classList.remove('active');
|
||||
this._editingWebviewId = null;
|
||||
},
|
||||
|
||||
/** Server-side probe: it runs from the network position the proxy will use. */
|
||||
async testWebviewUrl() {
|
||||
const url = document.getElementById('webviewUrl').value.trim();
|
||||
const out = document.getElementById('webviewProbeResult');
|
||||
if (!url) {
|
||||
out.textContent = 'Enter a URL first.';
|
||||
return;
|
||||
}
|
||||
out.textContent = 'Testing...';
|
||||
const probe = await this._apiJson('/api/webviews/probe', { method: 'POST', body: { url } });
|
||||
if (!probe) {
|
||||
out.textContent = 'Test failed (invalid URL?).';
|
||||
return;
|
||||
}
|
||||
out.textContent = probe.reachable
|
||||
? `Reachable (HTTP ${probe.status}). ${probe.reason}`
|
||||
: `Not reachable. ${probe.reason}`;
|
||||
out.className = 'form-hint webview-probe-result ' + (probe.reachable ? 'ok' : 'bad');
|
||||
},
|
||||
|
||||
async saveWebview() {
|
||||
const name = document.getElementById('webviewName').value.trim();
|
||||
const url = document.getElementById('webviewUrl').value.trim();
|
||||
const icon = document.getElementById('webviewIcon').value.trim();
|
||||
const trusted = !document.getElementById('webviewSandboxed').checked;
|
||||
if (!name || !url) {
|
||||
this.showToast?.('Name and URL are required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// `icon: undefined` rather than null, the schema uses .optional(), which
|
||||
// rejects an explicit null on the wire.
|
||||
const body = { name, url, icon: icon || undefined, trusted };
|
||||
const editing = this._editingWebviewId;
|
||||
const data = editing
|
||||
? await this._apiJson(`/api/webviews/${encodeURIComponent(editing)}`, { method: 'PATCH', body })
|
||||
: await this._apiJson('/api/webviews', { method: 'POST', body });
|
||||
|
||||
if (!data) {
|
||||
this.showToast?.('Could not save (check the URL)', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.refreshWebviews();
|
||||
this.closeWebviewModal();
|
||||
if (editing) {
|
||||
// The capability was revoked server-side by the edit, so a mounted frame is
|
||||
// now pointing at a dead URL. Remount it.
|
||||
if (this.webviewOrder.includes(editing)) this.reloadWebview(editing);
|
||||
} else {
|
||||
this.openWebview(data.id);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteWebview() {
|
||||
const id = this._editingWebviewId;
|
||||
if (!id) return;
|
||||
if (await this._confirmAndDeleteWebview(id)) this.closeWebviewModal();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete straight from a Run-dropdown row, without opening the editor first.
|
||||
*
|
||||
* The dropdown's outside-click handler closes the menu when the click target is
|
||||
* not inside it, and by the time the delete resolves this row is gone, so the
|
||||
* menu is re-asserted open: deleting one of several saved URLs should leave you
|
||||
* looking at the rest of the list.
|
||||
*/
|
||||
async deleteWebviewById(id) {
|
||||
if (!id) return;
|
||||
if (!(await this._confirmAndDeleteWebview(id))) return;
|
||||
document.getElementById('runModeMenu')?.classList.add('active');
|
||||
},
|
||||
|
||||
/** Shared by the row button and the editor modal. @returns true when deleted. */
|
||||
async _confirmAndDeleteWebview(id) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!confirm(`Delete "${webview?.name || id}"?`)) return false;
|
||||
const res = await this._apiDelete(`/api/webviews/${encodeURIComponent(id)}`);
|
||||
if (!res || !res.ok) {
|
||||
this.showToast?.('Could not delete URL', 'error');
|
||||
return false;
|
||||
}
|
||||
this._removeWebviewTab(id);
|
||||
this.webviews.delete(id);
|
||||
this.renderWebviewMenuItems();
|
||||
this.renderSessionTabs();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -9,13 +9,16 @@
|
||||
* POST /api/admin/users/:username/reset-password
|
||||
* POST /api/admin/users/:username/logout
|
||||
* DELETE /api/admin/users/:username
|
||||
* GET /api/admin/users/:username/cases (list the user's case folders)
|
||||
* DELETE /api/admin/users/:username/cases/:caseName (delete one case folder)
|
||||
*
|
||||
* Self-service GET /api/me + POST /api/me/password live in me-routes.ts.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { readdirSync, promises as fsp } from 'node:fs';
|
||||
import { isAbsolute, join, relative } from 'node:path';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { isMultiUserMode, userCasesDir } from '../../config/multiuser.js';
|
||||
import {
|
||||
@@ -24,6 +27,7 @@ import {
|
||||
deleteUserSpace,
|
||||
findUser,
|
||||
generateOneTimePassword,
|
||||
normalizeUsername,
|
||||
readUsers,
|
||||
setPassword,
|
||||
toPublicUser,
|
||||
@@ -50,6 +54,10 @@ const UpdateUserSchema = z.object({
|
||||
});
|
||||
const DeleteUserSchema = z.object({ deleteSpace: z.boolean().optional() });
|
||||
|
||||
// Case-folder names: a single path segment, no separators, no leading dot (hidden
|
||||
// dirs like .claude are infrastructure, not cases), so '.'/'..' are excluded too.
|
||||
const SAFE_CASE_NAME = /^[^./\\][^/\\]{0,127}$/;
|
||||
|
||||
/** Map a UserStoreError's code onto the API error code + status. */
|
||||
function storeError(reply: FastifyReply, err: unknown): ReturnType<typeof createErrorResponse> {
|
||||
if (err instanceof UserStoreError) {
|
||||
@@ -201,4 +209,90 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Count live sessions whose workingDir sits inside `dir` (any owner: a folder
|
||||
// in use by ANYONE must not be deleted out from under a running agent).
|
||||
const liveSessionsInside = (dir: string): number => {
|
||||
let n = 0;
|
||||
for (const s of ctx.sessions.values()) {
|
||||
const rel = relative(dir, s.workingDir || '');
|
||||
if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) n++;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
app.get('/api/admin/users/:username/cases', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const username = normalizeUsername((req.params as { username: string }).username);
|
||||
if (!(await findUser(username))) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'No such user');
|
||||
}
|
||||
const dir = userCasesDir(username);
|
||||
let cases: { name: string; modifiedAt: number; liveSessions: number }[] = [];
|
||||
try {
|
||||
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
cases = await Promise.all(
|
||||
entries
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
||||
.map(async (e) => {
|
||||
const p = join(dir, e.name);
|
||||
const st = await fsp.stat(p).catch(() => null);
|
||||
return { name: e.name, modifiedAt: st ? Math.floor(st.mtimeMs) : 0, liveSessions: liveSessionsInside(p) };
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
/* no cases dir yet */
|
||||
}
|
||||
cases.sort((a, b) => b.modifiedAt - a.modifiedAt);
|
||||
return { success: true, data: { dir, cases } };
|
||||
});
|
||||
|
||||
app.delete('/api/admin/users/:username/cases/:caseName', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const params = req.params as { username: string; caseName: string };
|
||||
const username = normalizeUsername(params.username);
|
||||
if (!(await findUser(username))) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'No such user');
|
||||
}
|
||||
if (!SAFE_CASE_NAME.test(params.caseName)) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
// Same guard rails as deleteUserSpace: never follow a symlink, and the
|
||||
// realpath must stay strictly inside the user's cases dir.
|
||||
const root = userCasesDir(username);
|
||||
const target = join(root, params.caseName);
|
||||
let lst;
|
||||
try {
|
||||
lst = await fsp.lstat(target);
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'No such case folder');
|
||||
}
|
||||
if (lst.isSymbolicLink() || !lst.isDirectory()) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Refusing to delete: not a plain directory');
|
||||
}
|
||||
const realRoot = await fsp.realpath(root).catch(() => root);
|
||||
const realTarget = await fsp.realpath(target);
|
||||
const rel = relative(realRoot, realTarget);
|
||||
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Case folder escapes the user space');
|
||||
}
|
||||
const inUse = liveSessionsInside(target) || liveSessionsInside(realTarget);
|
||||
if (inUse > 0) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.CONFLICT,
|
||||
`Case folder is in use by ${inUse} live session(s), close them first`
|
||||
);
|
||||
}
|
||||
await fsp.rm(realTarget, { recursive: true, force: true });
|
||||
audit(req, 'user.case-delete', username, { caseName: params.caseName });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { username, caseName: params.caseName } };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import fs from 'node:fs/promises';
|
||||
import { join, resolve, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ApiResponse, CaseInfo, DockerHost, SessionDocker } from '../../types.js';
|
||||
import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocker } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import {
|
||||
CreateCaseSchema,
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
import { buildDockerRemoveCommand } from '../../tmux-manager.js';
|
||||
import {
|
||||
checkRemoteTmuxAvailable,
|
||||
listRemoteCodemanSessions,
|
||||
readRemoteCases,
|
||||
readRemoteHosts,
|
||||
remoteDisplayPath,
|
||||
@@ -312,6 +313,26 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
? null
|
||||
: (reply.code(403), createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'));
|
||||
|
||||
// COD-105 — discover `codeman-*` tmux sessions already running on a remote
|
||||
// host (created by the remote's own Codeman, another instance, or this one)
|
||||
// so the operator can attach to one this Codeman didn't launch. Explicit
|
||||
// trigger only (Decision A): the frontend calls this on a "Discover" click,
|
||||
// never automatically on host select. listRemoteCodemanSessions never throws
|
||||
// (returns [] on unreachable/no-tmux/no-sessions) and is ssh-guarded under test.
|
||||
// Hosts are admin-only infra in multi-user mode, so discovery is too.
|
||||
app.get(
|
||||
'/api/remote-hosts/:hostId/sessions',
|
||||
async (req, reply): Promise<ApiResponse<{ sessions: RemoteSessionInfo[] }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
const { hostId } = req.params as { hostId: string };
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
const sessions = await listRemoteCodemanSessions(host);
|
||||
return { success: true, data: { sessions } };
|
||||
}
|
||||
);
|
||||
|
||||
app.post('/api/remote-hosts', async (req, reply): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const denied = adminOnly(req, reply);
|
||||
if (denied) return denied;
|
||||
|
||||
@@ -4,9 +4,17 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||||
import { basename as pathBasename, join } from 'node:path';
|
||||
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import type {
|
||||
ApiResponse,
|
||||
FilesystemBrowseData,
|
||||
FilesystemBrowseEntry,
|
||||
FilesystemBrowseRoot,
|
||||
FilesystemPreviewKind,
|
||||
} from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||
import {
|
||||
@@ -22,12 +30,21 @@ import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { canAccessOwned, findSessionOrFail, getAuthUser, validateSessionFilePath } from '../route-helpers.js';
|
||||
import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js';
|
||||
import {
|
||||
CASES_DIR,
|
||||
canAccessOwned,
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
parseBody,
|
||||
validateSessionFilePath,
|
||||
} from '../route-helpers.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
||||
import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema } from '../schemas.js';
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
@@ -45,8 +62,14 @@ const MIME_TYPES: Record<string, string> = {
|
||||
txt: 'text/plain',
|
||||
};
|
||||
|
||||
function sanitizeDownloadName(fileName: string): string {
|
||||
return fileName.replace(/["\\\r\n]/g, '_');
|
||||
function buildContentDisposition(disposition: 'inline' | 'attachment', fileName: string): string {
|
||||
const cleaned = fileName.replace(/["\\\r\n]/g, '_');
|
||||
const fallback = cleaned.replace(/[^\x20-\x7e]/g, '_') || 'file';
|
||||
const encoded = encodeURIComponent(cleaned).replace(
|
||||
/['()*]/g,
|
||||
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
||||
@@ -92,13 +115,12 @@ async function serveRawFile(
|
||||
return;
|
||||
}
|
||||
const content = createReadStream(resolvedPath);
|
||||
const safeName = sanitizeDownloadName(fileName);
|
||||
if (download || extension === 'svg') {
|
||||
reply.header(
|
||||
'Content-Type',
|
||||
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
||||
);
|
||||
reply.header('Content-Disposition', `attachment; filename="${safeName}"`);
|
||||
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
|
||||
reply.header('Content-Length', stat.size);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendRawStream(reply, content);
|
||||
@@ -106,7 +128,7 @@ async function serveRawFile(
|
||||
}
|
||||
|
||||
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
||||
reply.header('Content-Disposition', `inline; filename="${safeName}"`);
|
||||
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
||||
reply.header('Content-Length', stat.size);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendRawStream(reply, content);
|
||||
@@ -194,7 +216,10 @@ async function serveConvertedPreview(
|
||||
|
||||
const content = await fs.readFile(previewPath);
|
||||
reply.header('Content-Type', 'application/pdf');
|
||||
reply.header('Content-Disposition', `inline; filename="${getPreviewPdfDownloadName(fileName, extension)}"`);
|
||||
reply.header(
|
||||
'Content-Disposition',
|
||||
buildContentDisposition('inline', getPreviewPdfDownloadName(fileName, extension))
|
||||
);
|
||||
reply.header('Cache-Control', 'no-cache');
|
||||
reply.header('Content-Length', content.length);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
@@ -260,6 +285,170 @@ type AttachmentHistoryRouteItem = Omit<SessionAttachmentHistoryItem, 'externalPa
|
||||
attachmentId?: string;
|
||||
};
|
||||
|
||||
const FILESYSTEM_PICKER_ENTRY_LIMIT = 500;
|
||||
const FILESYSTEM_TEXT_PREVIEW_LIMIT = 2 * 1024 * 1024;
|
||||
const FILESYSTEM_BINARY_PREVIEW_LIMIT = 50 * 1024 * 1024;
|
||||
const FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
|
||||
const FILESYSTEM_TEXT_PREVIEW_EXTENSIONS = new Set(['md', 'txt', 'json']);
|
||||
const FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS = new Set(['pdf', 'docx', 'pptx']);
|
||||
|
||||
function isPathWithinRoot(root: string, candidate: string): boolean {
|
||||
const rel = relative(root, candidate);
|
||||
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
|
||||
}
|
||||
|
||||
function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string): FilesystemBrowseRoot | undefined {
|
||||
return roots
|
||||
.filter((root) => isPathWithinRoot(root.path, candidate))
|
||||
.sort((a, b) => b.path.length - a.path.length)[0];
|
||||
}
|
||||
|
||||
function containsHiddenPickerSegment(root: string, candidate: string): boolean {
|
||||
const rel = relative(root, candidate);
|
||||
return rel !== '' && rel.split(sep).some((segment) => segment.startsWith('.'));
|
||||
}
|
||||
|
||||
function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | undefined {
|
||||
const extension = extname(fileName).slice(1).toLowerCase();
|
||||
if (FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS.has(extension)) return 'image';
|
||||
if (FILESYSTEM_TEXT_PREVIEW_EXTENSIONS.has(extension)) return 'text';
|
||||
if (FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS.has(extension)) return 'document';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
|
||||
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
|
||||
// The shared sensitive-path matcher describes file locations such as
|
||||
// ~/.ssh/<key>. Probe a child path as well so the directory itself cannot be
|
||||
// opened and used to enumerate those filenames.
|
||||
return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees);
|
||||
}
|
||||
|
||||
function extraConfiguredPickerRoots(): Array<{ label: string; path: string }> {
|
||||
const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS;
|
||||
if (!extraRoots) return [];
|
||||
return extraRoots
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.map((path, index) => ({ label: `Configured ${index + 1}`, path }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse roots for the requesting identity.
|
||||
*
|
||||
* Single-user mode (and multi-user admins) get the host-wide set. ⚠️ A regular
|
||||
* multi-user user must NOT: per-user spaces live at `<USER_SPACES_DIR>/<name>`,
|
||||
* which is *inside* `homedir()`, so handing out a `Home` root would let any
|
||||
* authenticated user browse and preview every other user's workspace. The
|
||||
* shared `CASES_DIR` leaks the same way, and `/mnt/d` is a broad host mount
|
||||
* that a multi-user deployment should not expose by default. Operators who
|
||||
* genuinely want a shared area can still name it in `CODEMAN_FILE_PICKER_ROOTS`,
|
||||
* which stays an explicit opt-in in both modes.
|
||||
*/
|
||||
function configuredFilesystemPickerRoots(req: FastifyRequest): Array<{ label: string; path: string }> {
|
||||
const user = getAuthUser(req);
|
||||
if (isMultiUserMode() && user.role !== 'admin') {
|
||||
return [{ label: 'My Space', path: userSpacePath(user.username) }, ...extraConfiguredPickerRoots()];
|
||||
}
|
||||
return [
|
||||
{ label: 'Home', path: homedir() },
|
||||
{ label: 'Codeman Cases', path: CASES_DIR },
|
||||
{ label: 'WSL D:', path: '/mnt/d' },
|
||||
...extraConfiguredPickerRoots(),
|
||||
];
|
||||
}
|
||||
|
||||
async function resolveFilesystemPickerRoots(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
req: FastifyRequest,
|
||||
sessionId?: string
|
||||
): Promise<FilesystemBrowseRoot[]> {
|
||||
const candidates = configuredFilesystemPickerRoots(req);
|
||||
if (sessionId) {
|
||||
const session = ctx.sessions.get(sessionId) ?? ctx.store.getSession(sessionId);
|
||||
// ⚠️ Ownership must be checked here, exactly as `findSessionOrFail` does for
|
||||
// the other session-scoped handlers in this file. Without it a multi-user
|
||||
// caller could pin ANOTHER user's `workingDir` as a browse root just by
|
||||
// passing their sessionId. Report not-found rather than forbidden so the
|
||||
// endpoint does not confirm that a session id exists.
|
||||
if (!session || !canAccessOwned(getAuthUser(req), (session as { owner?: string }).owner)) {
|
||||
throw Object.assign(new Error(`Session ${sessionId} not found`), {
|
||||
statusCode: 404,
|
||||
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
|
||||
});
|
||||
}
|
||||
candidates.unshift({ label: 'Current Folder', path: session.workingDir });
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
const roots: FilesystemBrowseRoot[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
if (!isAbsolute(candidate.path)) continue;
|
||||
try {
|
||||
const resolved = realpathSync(candidate.path);
|
||||
if (seen.has(resolved) || isBlockedPickerPath(resolved, guard.blockedTrees, true)) continue;
|
||||
const stat = await fs.stat(resolved);
|
||||
if (!stat.isDirectory()) continue;
|
||||
seen.add(resolved);
|
||||
roots.push({ label: candidate.label, path: resolved });
|
||||
} catch {
|
||||
// Optional roots (for example /mnt/d on non-WSL hosts) are omitted.
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
type ResolvedFilesystemPickerPath = {
|
||||
candidatePath: string;
|
||||
resolvedPath: string;
|
||||
roots: FilesystemBrowseRoot[];
|
||||
matchingRoot: FilesystemBrowseRoot;
|
||||
blockedTrees: readonly string[];
|
||||
};
|
||||
|
||||
function throwFilesystemPickerError(statusCode: number, code: ApiErrorCode, message: string): never {
|
||||
throw Object.assign(new Error(message), {
|
||||
statusCode,
|
||||
body: createErrorResponse(code, message),
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveFilesystemPickerPath(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
req: FastifyRequest,
|
||||
requestedPath: string | undefined,
|
||||
sessionId?: string
|
||||
): Promise<ResolvedFilesystemPickerPath> {
|
||||
const roots = await resolveFilesystemPickerRoots(ctx, req, sessionId);
|
||||
if (roots.length === 0) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
|
||||
}
|
||||
|
||||
const fallbackRoot =
|
||||
roots.find((root) => root.label === 'Current Folder') ?? roots.find((root) => root.path === '/mnt/d') ?? roots[0];
|
||||
const candidatePath = resolve(requestedPath ?? fallbackRoot.path);
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = realpathSync(candidatePath);
|
||||
} catch {
|
||||
throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `Path not found: ${candidatePath}`);
|
||||
}
|
||||
|
||||
const matchingRoot = findMatchingPickerRoot(roots, resolvedPath);
|
||||
if (!matchingRoot) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
|
||||
}
|
||||
if (containsHiddenPickerSegment(matchingRoot.path, resolvedPath)) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Hidden paths are not available in the file picker');
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
return { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees: guard.blockedTrees };
|
||||
}
|
||||
|
||||
function appendDownloadFlag(url: string): string {
|
||||
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
|
||||
}
|
||||
@@ -375,6 +564,179 @@ async function buildExternalAttachmentRouteItem(
|
||||
}
|
||||
|
||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
|
||||
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
|
||||
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
|
||||
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
|
||||
const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
|
||||
ctx,
|
||||
req,
|
||||
requestedPath,
|
||||
sessionId
|
||||
);
|
||||
|
||||
if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked');
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The browse path must be a directory');
|
||||
}
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
|
||||
}
|
||||
|
||||
let dirEntries;
|
||||
try {
|
||||
dirEntries = await fs.readdir(resolvedPath, { withFileTypes: true });
|
||||
} catch {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder cannot be read');
|
||||
}
|
||||
|
||||
dirEntries.sort((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||||
if (!a.isDirectory() && b.isDirectory()) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const entries: FilesystemBrowseEntry[] = [];
|
||||
let truncated = false;
|
||||
for (const entry of dirEntries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
if (entries.length >= FILESYSTEM_PICKER_ENTRY_LIMIT) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const visiblePath = join(candidatePath, entry.name);
|
||||
let targetPath: string;
|
||||
try {
|
||||
targetPath = realpathSync(visiblePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetRoot = findMatchingPickerRoot(roots, targetPath);
|
||||
if (!targetRoot || containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
|
||||
|
||||
let type: FilesystemBrowseEntry['type'];
|
||||
let size: number | undefined;
|
||||
const symlink = entry.isSymbolicLink();
|
||||
if (entry.isDirectory()) {
|
||||
type = 'directory';
|
||||
} else if (entry.isFile()) {
|
||||
type = 'file';
|
||||
} else if (symlink) {
|
||||
try {
|
||||
const targetStat = await fs.stat(targetPath);
|
||||
type = targetStat.isDirectory() ? 'directory' : 'file';
|
||||
if (type === 'file') size = targetStat.size;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue;
|
||||
if (type === 'file' && size === undefined) {
|
||||
try {
|
||||
size = (await fs.stat(targetPath)).size;
|
||||
} catch {
|
||||
// The path is still selectable even when a size lookup races a change.
|
||||
}
|
||||
}
|
||||
entries.push({
|
||||
name: entry.name,
|
||||
path: visiblePath,
|
||||
type,
|
||||
size,
|
||||
symlink: symlink || undefined,
|
||||
previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const parentCandidate = resolve(candidatePath, '..');
|
||||
let parent: string | null = null;
|
||||
if (candidatePath !== matchingRoot.path) {
|
||||
try {
|
||||
const resolvedParent = realpathSync(parentCandidate);
|
||||
if (isPathWithinRoot(matchingRoot.path, resolvedParent)) parent = parentCandidate;
|
||||
} catch {
|
||||
// A concurrently removed parent simply disables upward navigation.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: candidatePath,
|
||||
parent,
|
||||
root: matchingRoot.path,
|
||||
roots,
|
||||
entries,
|
||||
truncated,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Inline preview for files selected through the root-confined filesystem picker.
|
||||
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
|
||||
const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query);
|
||||
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
|
||||
ctx,
|
||||
req,
|
||||
requestedPath,
|
||||
sessionId
|
||||
);
|
||||
if (isBlockedPickerPath(resolvedPath, blockedTrees)) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked');
|
||||
}
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(resolvedPath);
|
||||
} catch {
|
||||
throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `File not found: ${candidatePath}`);
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'The preview path must be a file');
|
||||
}
|
||||
|
||||
const fileName = pathBasename(candidatePath);
|
||||
const extension = extname(fileName).slice(1).toLowerCase();
|
||||
const previewKind = getFilesystemPreviewKind(fileName);
|
||||
if (!previewKind) {
|
||||
throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'This file type cannot be previewed');
|
||||
}
|
||||
const sizeLimit = previewKind === 'text' ? FILESYSTEM_TEXT_PREVIEW_LIMIT : FILESYSTEM_BINARY_PREVIEW_LIMIT;
|
||||
if (stat.size > sizeLimit) {
|
||||
throwFilesystemPickerError(
|
||||
413,
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`File too large to preview (${Math.ceil(stat.size / 1024 / 1024)}MB limit: ${sizeLimit / 1024 / 1024}MB)`
|
||||
);
|
||||
}
|
||||
|
||||
reply.header('Cache-Control', 'no-cache');
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
if (previewKind === 'text') {
|
||||
const content = await fs.readFile(resolvedPath, 'utf8');
|
||||
reply.type('text/plain; charset=utf-8').send(content);
|
||||
return;
|
||||
}
|
||||
if (extension === 'docx' || extension === 'pptx') {
|
||||
await serveConvertedPreview(reply, resolvedPath, fileName, extension);
|
||||
return;
|
||||
}
|
||||
await serveRawFile(reply, resolvedPath, fileName, extension);
|
||||
});
|
||||
|
||||
// File tree listing
|
||||
app.get('/api/sessions/:id/files', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
@@ -22,3 +22,4 @@ export { registerSearchRoutes } from './search-routes.js';
|
||||
export { registerMeRoutes } from './me-routes.js';
|
||||
export { registerAdminRoutes } from './admin-routes.js';
|
||||
export { registerWsRoutes } from './ws-routes.js';
|
||||
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
|
||||
|
||||
@@ -32,12 +32,15 @@ import {
|
||||
AutoClearSchema,
|
||||
AutoCompactSchema,
|
||||
AutoResumeSchema,
|
||||
PinSessionSchema,
|
||||
ImageWatcherSchema,
|
||||
FlickerFilterSchema,
|
||||
QuickRunSchema,
|
||||
QuickStartSchema,
|
||||
InteractiveStartSchema,
|
||||
SessionOrderUpdateSchema,
|
||||
} from '../schemas.js';
|
||||
import { mergeSessionOrder } from '../../session-order.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
canAccessOwned,
|
||||
@@ -83,7 +86,13 @@ import { RunSummaryTracker } from '../../run-summary.js';
|
||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
|
||||
import {
|
||||
checkRemoteTmuxAvailable,
|
||||
readRemoteCases,
|
||||
readRemoteHosts,
|
||||
toAttachedSessionRemote,
|
||||
toSessionRemote,
|
||||
} from '../../remote-hosts.js';
|
||||
import {
|
||||
checkDockerAvailable,
|
||||
checkDockerConfigDrift,
|
||||
@@ -328,6 +337,18 @@ export function registerSessionRoutes(
|
||||
return (list as Array<{ owner?: string }>).filter((s) => canAccessOwned(user, s.owner));
|
||||
});
|
||||
|
||||
// ========== Session Tab Order (global sync, COD-131) ==========
|
||||
|
||||
app.put('/api/session-order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
|
||||
const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order');
|
||||
// Server is authoritative but never drops ids it knows about that the
|
||||
// pushing device hadn't loaded yet — those fall to the end (mergeSessionOrder).
|
||||
const merged = mergeSessionOrder(order, ctx.store.getSessionOrder());
|
||||
ctx.store.setSessionOrder(merged);
|
||||
ctx.broadcast(SseEvent.SessionOrderChanged, { order: merged });
|
||||
return { success: true, data: { order: merged } };
|
||||
});
|
||||
|
||||
// ========== Session Creation ==========
|
||||
|
||||
app.post('/api/sessions', async (req) => {
|
||||
@@ -337,7 +358,21 @@ export function registerSessionRoutes(
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
|
||||
|
||||
const body = parseBody(CreateSessionSchema, req.body);
|
||||
const workingDir = body.workingDir || process.cwd();
|
||||
let workingDir = body.workingDir || process.cwd();
|
||||
let remote = undefined;
|
||||
|
||||
// COD-105 — attach to a discovered (non-owned) remote tmux session. The
|
||||
// remote session is already running, so we skip the tmux-prereq probe and
|
||||
// build a NON-owned SessionRemote (detach-not-kill on close). Remote CASE
|
||||
// creation (owned durable sessions) is handled by the dedicated case-create
|
||||
// endpoint below, which #145 consolidated remote-host resolution into.
|
||||
if (body.attachRemoteSession) {
|
||||
const { hostId, remoteSessionName } = body.attachRemoteSession;
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
workingDir = `${host.username}@${host.host}:${remoteSessionName}`;
|
||||
remote = toAttachedSessionRemote(host, remoteSessionName, workingDir);
|
||||
}
|
||||
|
||||
// Multi-user: shell mode is arbitrary command execution as the host account,
|
||||
// gated behind the same grant as bypass (section 6.3). Resolve the owner's grant
|
||||
@@ -516,6 +551,7 @@ export function registerSessionRoutes(
|
||||
envOverrides: body.envOverrides,
|
||||
effort: body.effort,
|
||||
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
|
||||
remote,
|
||||
owner,
|
||||
});
|
||||
|
||||
@@ -980,6 +1016,180 @@ export function registerSessionRoutes(
|
||||
return candidateSid;
|
||||
}
|
||||
|
||||
interface ClaudeResponseMessage {
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
interface ClaudeTranscriptEntry {
|
||||
type?: string;
|
||||
timestamp?: string;
|
||||
isMeta?: boolean;
|
||||
isSidechain?: boolean;
|
||||
isCompactSummary?: boolean;
|
||||
message?: { content?: unknown };
|
||||
}
|
||||
|
||||
function extractClaudeText(content: unknown, separator: string): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.filter(
|
||||
(block): block is { type: string; text: string } =>
|
||||
!!block &&
|
||||
typeof block === 'object' &&
|
||||
(block as { type?: string }).type === 'text' &&
|
||||
typeof (block as { text?: string }).text === 'string'
|
||||
)
|
||||
.map((block) => block.text)
|
||||
.join(separator);
|
||||
}
|
||||
|
||||
function isClaudeSyntheticUserMessage(entry: ClaudeTranscriptEntry, text: string): boolean {
|
||||
if (entry.isMeta || entry.isCompactSummary) return true;
|
||||
return /^(?:<local-command|<command-name>|<task-notification>|<system-reminder>|<teammate-message\b|Another Claude session sent a message:|Base directory for this skill:)/i.test(
|
||||
text
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude writes one logical turn as many JSONL rows: text, thinking and tool
|
||||
* blocks share message ids, while tool results are represented as user rows.
|
||||
* Build viewer cards from real user boundaries instead of treating every row
|
||||
* as a separate chat message.
|
||||
*/
|
||||
function parseClaudeResponseTranscript(
|
||||
content: string,
|
||||
full: boolean
|
||||
): { text: string; timestamp: string; messages?: ClaudeResponseMessage[] } {
|
||||
let lastText = '';
|
||||
let lastTimestamp = '';
|
||||
const messages: ClaudeResponseMessage[] = [];
|
||||
let currentUserFragments = new Set<string>();
|
||||
let currentAssistantFragments = new Set<string>();
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
if (!line) continue;
|
||||
let entry: ClaudeTranscriptEntry;
|
||||
try {
|
||||
entry = JSON.parse(line) as ClaudeTranscriptEntry;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// Sidechains belong to agents/forks, not the main conversation. Meta user
|
||||
// rows include repeated image dimensions and other UI-generated context.
|
||||
if (entry.isSidechain) continue;
|
||||
|
||||
if (entry.type === 'user') {
|
||||
const text = extractClaudeText(entry.message?.content, '\n').trim();
|
||||
// A tool_result block has no text block and naturally drops out here.
|
||||
if (!text || isClaudeSyntheticUserMessage(entry, text)) continue;
|
||||
if (!full) continue;
|
||||
|
||||
const previous = messages.at(-1);
|
||||
if (previous?.role === 'user') {
|
||||
// Claude can replay the initial user row while restoring a transcript.
|
||||
// Only collapse duplicates within the same unanswered user turn; the
|
||||
// same prompt after an assistant response remains a legitimate turn.
|
||||
if (currentUserFragments.has(text)) continue;
|
||||
previous.text += `\n\n${text}`;
|
||||
currentUserFragments.add(text);
|
||||
} else {
|
||||
messages.push({ role: 'user', text, timestamp: entry.timestamp });
|
||||
currentUserFragments = new Set([text]);
|
||||
}
|
||||
currentAssistantFragments.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type !== 'assistant') continue;
|
||||
const text = extractClaudeText(entry.message?.content, '\n\n').trim();
|
||||
if (!text) continue;
|
||||
lastText = text;
|
||||
lastTimestamp = entry.timestamp || '';
|
||||
if (!full) continue;
|
||||
|
||||
const previous = messages.at(-1);
|
||||
if (previous?.role === 'assistant') {
|
||||
// Replayed snapshots sometimes repeat an identical text block. Distinct
|
||||
// progress/final blocks are kept, but remain inside one Claude card.
|
||||
if (currentAssistantFragments.has(text)) continue;
|
||||
previous.text += `\n\n${text}`;
|
||||
previous.timestamp = entry.timestamp || previous.timestamp;
|
||||
currentAssistantFragments.add(text);
|
||||
} else {
|
||||
messages.push({ role: 'assistant', text, timestamp: entry.timestamp });
|
||||
currentAssistantFragments = new Set([text]);
|
||||
}
|
||||
currentUserFragments.clear();
|
||||
}
|
||||
|
||||
return full ? { text: lastText, timestamp: lastTimestamp, messages } : { text: lastText, timestamp: lastTimestamp };
|
||||
}
|
||||
|
||||
/** Locate a top-level Claude transcript, including recovered tmux sessions. */
|
||||
async function findClaudeTranscript(
|
||||
projectsDir: string,
|
||||
conversationId: string,
|
||||
codemanSessionId: string
|
||||
): Promise<{ sessionId: string; path: string } | null> {
|
||||
let projectDirs: import('node:fs').Dirent[];
|
||||
try {
|
||||
projectDirs = await fs.readdir(projectsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeIds = [...new Set([conversationId, codemanSessionId])].filter((value) => /^[a-zA-Z0-9._-]+$/.test(value));
|
||||
for (const candidateId of safeIds) {
|
||||
for (const projectDir of projectDirs) {
|
||||
if (!projectDir.isDirectory()) continue;
|
||||
const jsonlPath = join(projectsDir, projectDir.name, `${candidateId}.jsonl`);
|
||||
try {
|
||||
const stat = await fs.stat(jsonlPath);
|
||||
if (stat.isFile()) return { sessionId: candidateId, path: jsonlPath };
|
||||
} catch {
|
||||
/* continue */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If mux-sessions.json was lost or stale, reconcileSessions() historically
|
||||
// recovered `codeman-40568a29` as `restored-40568a29` and used the server cwd.
|
||||
// The tmux name still carries the first eight UUID characters, which safely
|
||||
// reconnects the viewer when exactly one matching top-level transcript exists.
|
||||
const restoredMatch = /^restored-([a-f0-9]{8,})$/i.exec(codemanSessionId);
|
||||
if (!restoredMatch) return null;
|
||||
const fragment = restoredMatch[1].toLowerCase();
|
||||
const candidates: Array<{ sessionId: string; path: string; mtimeMs: number }> = [];
|
||||
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
|
||||
|
||||
for (const projectDir of projectDirs) {
|
||||
if (!projectDir.isDirectory()) continue;
|
||||
const dirPath = join(projectsDir, projectDir.name);
|
||||
let files: import('node:fs').Dirent[];
|
||||
try {
|
||||
files = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const file of files) {
|
||||
if (!file.isFile() || !file.name.endsWith('.jsonl')) continue;
|
||||
const candidateId = file.name.slice(0, -'.jsonl'.length);
|
||||
if (!candidateId.toLowerCase().startsWith(fragment) || !uuidPattern.test(candidateId)) continue;
|
||||
const path = join(dirPath, file.name);
|
||||
const stat = await fs.stat(path).catch(() => null);
|
||||
if (stat) candidates.push({ sessionId: candidateId, path, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
}
|
||||
|
||||
const candidateIds = new Set(candidates.map((candidate) => candidate.sessionId));
|
||||
if (candidateIds.size !== 1) return null;
|
||||
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
app.get('/api/sessions/:id/last-response', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
@@ -1009,106 +1219,30 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// The Claude conversation ID (used as JSONL filename)
|
||||
const query = req.query as { context?: string };
|
||||
const claudeSessionId = session.claudeSessionId || session.id;
|
||||
let transcriptText = '';
|
||||
let transcriptTimestamp = '';
|
||||
const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id);
|
||||
if (!transcript) {
|
||||
return query.context === 'full' ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
}
|
||||
|
||||
if (transcript.sessionId !== session.claudeSessionId && transcript.sessionId !== session.id) {
|
||||
session.adoptClaudeSessionId(transcript.sessionId);
|
||||
if (session.docker) {
|
||||
void persistDockerCaseClaudeSessionId(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
session.docker.containerName,
|
||||
transcript.sessionId
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const jsonlPath = join(projectsDir, projDir, `${claudeSessionId}.jsonl`);
|
||||
try {
|
||||
const content = await fs.readFile(jsonlPath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
// Search from end for last assistant message with text
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]);
|
||||
if (entry.type === 'assistant' && entry.message?.content) {
|
||||
const blocks = Array.isArray(entry.message.content)
|
||||
? entry.message.content
|
||||
: [{ type: 'text', text: String(entry.message.content) }];
|
||||
const textBlocks = blocks
|
||||
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
|
||||
.map((b: { type: string; text?: string }) => b.text);
|
||||
if (textBlocks.length > 0) {
|
||||
transcriptText = textBlocks.join('\n\n');
|
||||
transcriptTimestamp = entry.timestamp || '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
if (transcriptText) break; // Found it, stop scanning directories
|
||||
} catch {
|
||||
// File doesn't exist in this project dir, continue
|
||||
}
|
||||
}
|
||||
const content = await fs.readFile(transcript.path, 'utf8');
|
||||
return parseClaudeResponseTranscript(content, query.context === 'full');
|
||||
} catch {
|
||||
// projects dir doesn't exist
|
||||
return query.context === 'full' ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
}
|
||||
|
||||
// If ?context=full, return all user+assistant messages for conversation view
|
||||
const query = req.query as { context?: string };
|
||||
if (query.context === 'full' && transcriptText) {
|
||||
const allMessages: Array<{ role: string; text: string; timestamp?: string }> = [];
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const jsonlPath = join(projectsDir, projDir, `${claudeSessionId}.jsonl`);
|
||||
try {
|
||||
const content = await fs.readFile(jsonlPath, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === 'user' && entry.message?.content) {
|
||||
const text =
|
||||
typeof entry.message.content === 'string'
|
||||
? entry.message.content
|
||||
: (entry.message.content as Array<{ type: string; text?: string }>)
|
||||
.filter((b) => b.type === 'text' && b.text)
|
||||
.map((b) => b.text)
|
||||
.join('\n');
|
||||
// Skip system/command messages
|
||||
if (text && !text.startsWith('<local-command') && !text.startsWith('<command-name>')) {
|
||||
allMessages.push({ role: 'user', text, timestamp: entry.timestamp });
|
||||
}
|
||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||
const blocks = Array.isArray(entry.message.content)
|
||||
? entry.message.content
|
||||
: [{ type: 'text', text: String(entry.message.content) }];
|
||||
const text = blocks
|
||||
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
|
||||
.map((b: { type: string; text?: string }) => b.text)
|
||||
.join('\n\n');
|
||||
if (text) {
|
||||
allMessages.push({ role: 'assistant', text, timestamp: entry.timestamp });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
if (allMessages.length > 0) break;
|
||||
} catch {
|
||||
/* continue */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { text: transcriptText, timestamp: transcriptTimestamp, messages: allMessages };
|
||||
}
|
||||
|
||||
return {
|
||||
text: transcriptText,
|
||||
timestamp: transcriptTimestamp,
|
||||
};
|
||||
});
|
||||
|
||||
function isCodexInjectedContext(text: string): boolean {
|
||||
@@ -1631,6 +1765,50 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Pin (float to top of the session manager list, COD-139) ==========
|
||||
|
||||
app.post('/api/sessions/:id/pin', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(PinSessionSchema, req.body, 'Invalid request body');
|
||||
|
||||
const session = ctx.sessions.get(id);
|
||||
if (session) {
|
||||
if (!canAccessOwned(getAuthUser(req), session.owner)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
|
||||
}
|
||||
session.setPinned(body.pinned);
|
||||
// Persist + broadcast session:updated (keeps tabs/state consistent), then a
|
||||
// dedicated session:pinned event so the session manager list re-sorts live.
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
ctx.broadcast(SseEvent.SessionPinned, {
|
||||
id,
|
||||
pinned: session.pinned,
|
||||
pinnedAt: session.pinnedAt ?? undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
pinned: session.pinned,
|
||||
pinnedAt: session.pinnedAt ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// COD-142 keeps a pinned session's record after kill (demoteOrRemoveSession),
|
||||
// so pin toggles must also work WITHOUT a live Session — otherwise a
|
||||
// pinned-then-killed record could never be unpinned (cleanup skips pinned
|
||||
// records, and the record has no live session to route through).
|
||||
const persisted = ctx.store.getSession(id);
|
||||
if (!persisted || !canAccessOwned(getAuthUser(req), persisted.owner)) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
|
||||
}
|
||||
const pinnedAt = body.pinned ? Date.now() : undefined;
|
||||
ctx.store.setSession(id, { ...persisted, pinned: body.pinned || undefined, pinnedAt });
|
||||
ctx.broadcast(SseEvent.SessionPinned, { id, pinned: body.pinned, pinnedAt });
|
||||
return { success: true, data: { pinned: body.pinned, pinnedAt } };
|
||||
});
|
||||
|
||||
// ========== Image Watcher ==========
|
||||
|
||||
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
||||
@@ -2228,6 +2406,59 @@ export function registerSessionRoutes(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the text of the LAST user message from a JSONL transcript chunk
|
||||
* (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
|
||||
* detection, same noise/secret/slash-command filters, same 120-char cap — but
|
||||
* keeps the last qualifying match instead of returning on the first. Scan the
|
||||
* file tail for this (the most recent prompt lives near the end).
|
||||
*/
|
||||
function extractLastUserPrompt(text: string): string | undefined {
|
||||
const MAX_PROMPT_LEN = 120;
|
||||
let result: string | undefined;
|
||||
let start = 0;
|
||||
while (start < text.length) {
|
||||
const end = text.indexOf('\n', start);
|
||||
const line = end === -1 ? text.slice(start) : text.slice(start, end);
|
||||
start = end === -1 ? text.length : end + 1;
|
||||
if (!line.includes('"type":"user"')) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type !== 'user' || !entry.message) continue;
|
||||
const content = entry.message.content;
|
||||
let msgText: string | undefined;
|
||||
if (typeof content === 'string') {
|
||||
msgText = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
const textBlock = content.find((b: { type: string }) => b.type === 'text');
|
||||
if (textBlock) msgText = textBlock.text;
|
||||
}
|
||||
if (!msgText) continue;
|
||||
msgText = msgText
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(new RegExp(String.raw`\x1b\[[0-9;]*[a-zA-Z]`, 'g'), '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
if (!msgText) continue;
|
||||
if (
|
||||
/^(Caveat:|init\b|clear\b|resume\b|\/[a-z][\w-]*\b|You are a |\[Request |Set model to )/i.test(msgText) ||
|
||||
/^(Please )?(analyze|review) this codebase/i.test(msgText) ||
|
||||
/^(Read|Implement the following) .+, then (search|list|check) /i.test(msgText) ||
|
||||
/^\d+ vulnerabilit/i.test(msgText) ||
|
||||
/\btoolu_/.test(msgText) ||
|
||||
/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+/.test(msgText) ||
|
||||
/\b(sk-ant-|ANTHROPIC_API_KEY|API_KEY=|SECRET|TOKEN=)/i.test(msgText) ||
|
||||
msgText.length < 8
|
||||
)
|
||||
continue;
|
||||
result = msgText.length > MAX_PROMPT_LEN ? msgText.slice(0, MAX_PROMPT_LEN) + '…' : msgText;
|
||||
} catch {
|
||||
// Malformed line — skip
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Claude project key (e.g. "-Users-teigen-Documents-Workspace-AI-project-Mirror")
|
||||
* back to a filesystem path ("/Users/teigen/Documents/Workspace/AI_project/Mirror").
|
||||
@@ -2366,6 +2597,7 @@ export function registerSessionRoutes(
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
lastPrompt?: string;
|
||||
};
|
||||
|
||||
// Scan a single project directory and return all valid history sessions in it.
|
||||
@@ -2411,6 +2643,17 @@ export function registerSessionRoutes(
|
||||
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
||||
}
|
||||
|
||||
// COD-145: last (most recent) user prompt lives near the END of the file, so
|
||||
// prefer the tail. For large files where no tail was read yet, read one
|
||||
// (mirrors the firstPrompt > 65536 block). Small files fit in `head`, which
|
||||
// then contains the whole transcript — scan it for the last match instead.
|
||||
if (!tail && fileStat.size > 65536) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
}
|
||||
const lastPrompt =
|
||||
(tail ? extractLastUserPrompt(tail) : undefined) ?? (head ? extractLastUserPrompt(head) : undefined);
|
||||
|
||||
out.push({
|
||||
sessionId,
|
||||
workingDir,
|
||||
@@ -2418,6 +2661,7 @@ export function registerSessionRoutes(
|
||||
sizeBytes: fileStat.size,
|
||||
lastModified: fileStat.mtime.toISOString(),
|
||||
firstPrompt,
|
||||
lastPrompt,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -2493,6 +2737,8 @@ export function registerSessionRoutes(
|
||||
createdAt: st.createdAt,
|
||||
lastActivityAt: st.lastActivityAt,
|
||||
claudeSessionId: s.claudeSessionId ?? undefined,
|
||||
pinned: st.pinned,
|
||||
pinnedAt: st.pinnedAt,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2508,6 +2754,8 @@ export function registerSessionRoutes(
|
||||
createdAt: p.createdAt,
|
||||
lastActivityAt: p.lastActivityAt,
|
||||
claudeSessionId: p.resumeSessionId,
|
||||
pinned: p.pinned,
|
||||
pinnedAt: p.pinnedAt,
|
||||
}));
|
||||
|
||||
// Lifecycle audit log (newest-first, capped).
|
||||
@@ -2541,6 +2789,7 @@ export function registerSessionRoutes(
|
||||
sizeBytes: h.sizeBytes,
|
||||
lastModified: h.lastModified,
|
||||
firstPrompt: h.firstPrompt,
|
||||
lastPrompt: h.lastPrompt,
|
||||
projectKey: h.projectKey,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
/**
|
||||
* @fileoverview Web tabs: saved dashboard URLs, plus the reverse proxy that makes
|
||||
* them embeddable.
|
||||
*
|
||||
* Two distinct surfaces live here, and the split matters:
|
||||
*
|
||||
* 1. `/api/webviews/*`, ordinary authenticated CRUD, owner-scoped like every
|
||||
* other resource, returning the `ApiResponse` envelope.
|
||||
* 2. `/webview/:cap/*`, the proxy. NOT an API surface. It authenticates on an
|
||||
* unguessable capability in the path instead of Codeman's session cookie, and
|
||||
* is correspondingly exempt from the cookie and Origin checks in
|
||||
* `middleware/auth.ts`. See `src/webview-capabilities.ts` for why a cookie
|
||||
* cannot work here (sandboxed iframes are opaque-origin, so their requests are
|
||||
* cross-site and arrive with `Origin: null`).
|
||||
*
|
||||
* The proxy is registered inside an ENCAPSULATED plugin scope with its own
|
||||
* catch-all content-type parser. Fastify scopes parsers to the plugin that
|
||||
* registers them, which is what lets the proxy forward raw request bodies
|
||||
* upstream while the rest of the app keeps its JSON parsing (and, critically,
|
||||
* keeps `text/plain` raw, auto-parsing that was a real CSRF hole once).
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/webviews
|
||||
* POST /api/webviews
|
||||
* PATCH /api/webviews/:id
|
||||
* DELETE /api/webviews/:id
|
||||
* POST /api/webviews/probe
|
||||
* POST /api/webviews/:id/open
|
||||
* ALL /webview/:cap/* (+ WebSocket upgrade on GET)
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { WebSocket as WsClient } from 'ws';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
MAX_LIVE_WEBVIEW_FRAMES,
|
||||
MAX_WEBVIEWS,
|
||||
MAX_WEBVIEW_HTML_REWRITE_BYTES,
|
||||
MAX_WEBVIEW_SOCKETS,
|
||||
WEBVIEW_PROBE_TIMEOUT_MS,
|
||||
WEBVIEW_PROXY_PREFIX,
|
||||
WEBVIEW_UPSTREAM_TIMEOUT_MS,
|
||||
} from '../../config/webview-limits.js';
|
||||
import { readWebviews, writeWebviews } from '../../webview-store.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import type { Webview, WebviewOpenData, WebviewProbe } from '../../types.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { canAccessOwned, getAuthUser, ownerFor, parseBody } from '../route-helpers.js';
|
||||
import { WebviewCreateSchema, WebviewProbeSchema, WebviewUpdateSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort } from '../ports/index.js';
|
||||
import {
|
||||
buildDownstreamResponseHeaders,
|
||||
buildProxyCorsHeaders,
|
||||
buildUpstreamRequestHeaders,
|
||||
capabilityFromReferer,
|
||||
extractFrameAncestors,
|
||||
isFramableCrossOrigin,
|
||||
isHtmlContentType,
|
||||
parseWebviewUrl,
|
||||
proxyPrefixFor,
|
||||
resolveUpstreamUrl,
|
||||
rewriteHtml,
|
||||
upstreamWebSocketUrl,
|
||||
} from '../webview-proxy.js';
|
||||
|
||||
/**
|
||||
* Resolved per call rather than captured at module load. `getDataDir()` reads
|
||||
* `CODEMAN_DATA_DIR` each time, so a lazy lookup keeps tests writing to a temp dir
|
||||
* instead of the developer's real `~/.codeman/webviews.json`.
|
||||
*/
|
||||
function configDir(): string {
|
||||
return getDataDir();
|
||||
}
|
||||
|
||||
/** Live proxied WebSockets per webview id, so one dashboard cannot exhaust the socket budget. */
|
||||
const socketCounts = new Map<string, number>();
|
||||
|
||||
interface ProxyParams {
|
||||
cap: string;
|
||||
'*'?: string;
|
||||
}
|
||||
|
||||
/** Serialize webview mutations: read-modify-write on a shared JSON file otherwise races. */
|
||||
let writeChain: Promise<unknown> = Promise.resolve();
|
||||
function withWebviews<T>(fn: (list: Webview[]) => Promise<T> | T): Promise<T> {
|
||||
const next = writeChain.then(async () => {
|
||||
const list = await readWebviews(configDir());
|
||||
return fn(list);
|
||||
});
|
||||
// Keep the chain alive even if this link rejects, or every later write deadlocks.
|
||||
writeChain = next.catch(() => undefined);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort): void {
|
||||
registerCrudRoutes(app, ctx);
|
||||
registerProxyRoutes(app);
|
||||
}
|
||||
|
||||
// ───────────────────────────── CRUD ─────────────────────────────
|
||||
|
||||
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void {
|
||||
app.get('/api/webviews', async (req) => {
|
||||
const user = getAuthUser(req);
|
||||
const all = await readWebviews(configDir());
|
||||
const webviews = all.filter((w) => canAccessOwned(user, w.owner));
|
||||
return { success: true, data: { webviews, maxLiveFrames: MAX_LIVE_WEBVIEW_FRAMES } };
|
||||
});
|
||||
|
||||
app.post('/api/webviews', async (req, reply) => {
|
||||
const input = parseBody(WebviewCreateSchema, req.body);
|
||||
const owner = ownerFor(req);
|
||||
const user = getAuthUser(req);
|
||||
|
||||
const created = await withWebviews(async (list) => {
|
||||
const mine = list.filter((w) => canAccessOwned(user, w.owner));
|
||||
if (mine.length >= MAX_WEBVIEWS) return null;
|
||||
|
||||
const webview: Webview = {
|
||||
id: randomUUID(),
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
icon: input.icon,
|
||||
// Proxy is the safe default: it is the only mode that works for a plain-HTTP
|
||||
// dashboard on an HTTPS Codeman, which is the common case.
|
||||
embedMode: input.embedMode ?? 'proxy',
|
||||
trusted: input.trusted ?? false,
|
||||
owner,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
list.push(webview);
|
||||
await writeWebviews(configDir(), list);
|
||||
return webview;
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `Webview limit reached (max ${MAX_WEBVIEWS})`));
|
||||
}
|
||||
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id });
|
||||
return { success: true, data: created };
|
||||
});
|
||||
|
||||
app.patch<{ Params: { id: string } }>('/api/webviews/:id', async (req, reply) => {
|
||||
const input = parseBody(WebviewUpdateSchema, req.body);
|
||||
const user = getAuthUser(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const updated = await withWebviews(async (list) => {
|
||||
const index = list.findIndex((w) => w.id === id);
|
||||
if (index === -1) return 'not-found' as const;
|
||||
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
|
||||
|
||||
const next: Webview = { ...list[index], ...input };
|
||||
list[index] = next;
|
||||
await writeWebviews(configDir(), list);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (updated === 'not-found') {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
|
||||
}
|
||||
if (updated === 'forbidden') {
|
||||
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
|
||||
}
|
||||
|
||||
// Any edit invalidates the outstanding capability. Otherwise a token minted
|
||||
// against the OLD url keeps proxying to it after the user repointed the tab.
|
||||
webviewCapabilities.revokeWebview(id);
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'updated', id });
|
||||
return { success: true, data: updated };
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/webviews/:id', async (req, reply) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await withWebviews(async (list) => {
|
||||
const index = list.findIndex((w) => w.id === id);
|
||||
if (index === -1) return 'not-found' as const;
|
||||
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
|
||||
list.splice(index, 1);
|
||||
await writeWebviews(configDir(), list);
|
||||
return 'deleted' as const;
|
||||
});
|
||||
|
||||
if (result === 'not-found') {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
|
||||
}
|
||||
if (result === 'forbidden') {
|
||||
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
|
||||
}
|
||||
|
||||
webviewCapabilities.revokeWebview(id);
|
||||
socketCounts.delete(id);
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'deleted', id });
|
||||
return { success: true, data: { id } };
|
||||
});
|
||||
|
||||
/**
|
||||
* Reachability + framing probe for the editor's "Test" button.
|
||||
*
|
||||
* Runs from the SERVER, which is the network position the proxy will use, so a
|
||||
* green result here means the proxy will actually work. Never throws upstream
|
||||
* failures at the caller: an unreachable dashboard is a normal answer, not a 500.
|
||||
*/
|
||||
app.post('/api/webviews/probe', async (req) => {
|
||||
const { url } = parseBody(WebviewProbeSchema, req.body);
|
||||
return { success: true, data: await probeUrl(url) };
|
||||
});
|
||||
|
||||
/**
|
||||
* Mint the capability the iframe will load. Separate from GET /api/webviews so a
|
||||
* capability exists only for dashboards actually opened, and so the TTL clock
|
||||
* starts on open rather than on page load.
|
||||
*/
|
||||
app.post<{ Params: { id: string } }>('/api/webviews/:id/open', async (req, reply) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const webview = await withWebviews(async (list) => {
|
||||
const index = list.findIndex((w) => w.id === id);
|
||||
if (index === -1) return 'not-found' as const;
|
||||
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
|
||||
list[index] = { ...list[index], lastOpenedAt: Date.now() };
|
||||
await writeWebviews(configDir(), list);
|
||||
return list[index];
|
||||
});
|
||||
|
||||
if (webview === 'not-found') {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
|
||||
}
|
||||
if (webview === 'forbidden') {
|
||||
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
|
||||
}
|
||||
|
||||
// Direct mode has no capability to mint: the iframe loads the real URL.
|
||||
if (webview.embedMode === 'direct') {
|
||||
const data: WebviewOpenData = { webview };
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
const capability = webviewCapabilities.mint(webview.id, webview.owner);
|
||||
const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability) };
|
||||
return { success: true, data };
|
||||
});
|
||||
}
|
||||
|
||||
async function probeUrl(url: string): Promise<WebviewProbe> {
|
||||
const target = parseWebviewUrl(url);
|
||||
if (!target) {
|
||||
return {
|
||||
reachable: false,
|
||||
framable: false,
|
||||
recommendedMode: 'proxy',
|
||||
reason: 'Invalid URL',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(target.href, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(WEBVIEW_PROBE_TIMEOUT_MS),
|
||||
});
|
||||
// The body is irrelevant to the probe; release the socket rather than leak it.
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
|
||||
const xFrameOptions = response.headers.get('x-frame-options') ?? undefined;
|
||||
const csp = response.headers.get('content-security-policy') ?? undefined;
|
||||
const frameAncestors = extractFrameAncestors(csp);
|
||||
const framable = isFramableCrossOrigin(xFrameOptions, csp);
|
||||
const isHttp = target.protocol === 'http:';
|
||||
|
||||
// Direct embedding is only viable for an HTTPS target that permits framing:
|
||||
// an HTTPS Codeman page cannot embed http:// at all (mixed content).
|
||||
const recommendedMode = !isHttp && framable ? 'direct' : 'proxy';
|
||||
const reason = isHttp
|
||||
? 'Plain HTTP: an HTTPS Codeman page cannot embed it directly, so it is proxied.'
|
||||
: framable
|
||||
? 'Reachable and allows framing: can be embedded directly.'
|
||||
: 'Reachable but refuses framing, so it is proxied.';
|
||||
|
||||
return {
|
||||
reachable: true,
|
||||
status: response.status,
|
||||
xFrameOptions,
|
||||
frameAncestors,
|
||||
framable,
|
||||
recommendedMode,
|
||||
reason,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
reachable: false,
|
||||
framable: false,
|
||||
recommendedMode: 'proxy',
|
||||
reason: `Server could not reach it: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── Proxy ─────────────────────────────
|
||||
|
||||
function registerProxyRoutes(app: FastifyInstance): void {
|
||||
app.register(async (scope) => {
|
||||
// Encapsulated to this plugin only. The proxy must relay request bodies
|
||||
// BYTE-FOR-BYTE, so every parser is replaced with a pass-through that hands
|
||||
// back the raw stream. Doing this on the root instance would break JSON
|
||||
// routes and un-fix the text/plain CSRF hardening.
|
||||
scope.removeAllContentTypeParsers();
|
||||
scope.addContentTypeParser('*', (_req, payload, done) => done(null, payload));
|
||||
|
||||
// A single GET route serving both roles: `handler` for normal requests,
|
||||
// `wsHandler` for upgrades. Registering them as two routes on one URL would
|
||||
// collide.
|
||||
scope.route<{ Params: ProxyParams }>({
|
||||
method: 'GET',
|
||||
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
|
||||
handler: proxyHttp,
|
||||
wsHandler: proxyWebSocket,
|
||||
});
|
||||
|
||||
// HEAD is deliberately absent: Fastify's `exposeHeadRoutes` already derives a
|
||||
// HEAD route from the GET above, and declaring it again is a startup error.
|
||||
scope.route<{ Params: ProxyParams }>({
|
||||
method: ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
|
||||
handler: proxyHttp,
|
||||
});
|
||||
|
||||
// `/webview/<cap>` with no trailing slash: redirect rather than serve, so the
|
||||
// browser's notion of the base path ends in `/` and relative URLs in the
|
||||
// dashboard's HTML resolve inside the prefix instead of one level above it.
|
||||
scope.get<{ Params: { cap: string } }>(`${WEBVIEW_PROXY_PREFIX}/:cap`, (req, reply) => {
|
||||
return reply.redirect(proxyPrefixFor(req.params.cap), 302);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve a capability to its live webview record, or null. */
|
||||
async function lookupCapability(capability: string): Promise<Webview | null> {
|
||||
const record = webviewCapabilities.resolve(capability);
|
||||
if (!record) return null;
|
||||
const list = await readWebviews(configDir());
|
||||
const webview = list.find((w) => w.id === record.webviewId);
|
||||
if (!webview) return null;
|
||||
// The capability is bound to the identity that minted it; an ownership change
|
||||
// on the record must not leave a stale token working.
|
||||
if (webview.owner !== record.owner) return null;
|
||||
return webview;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠ Every exit path RETURNS `reply.send(...)`.
|
||||
*
|
||||
* This handler is `async`, and Fastify resolves an async handler's promise as the
|
||||
* response. `reply.send(stream)` followed by a bare `return` resolves to
|
||||
* `undefined` before the stream has been consumed, and Fastify then answers with
|
||||
* an EMPTY body: HTML (a synchronously-set string payload) survives it, every
|
||||
* streamed asset comes back zero-length. Returning the reply is what tells Fastify
|
||||
* the response is already owned by this handler.
|
||||
*/
|
||||
function proxyHttp(req: FastifyRequest<{ Params: ProxyParams }>, reply: FastifyReply): Promise<FastifyReply> {
|
||||
return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy one request to the dashboard behind `cap`, serving `wildcard` as the
|
||||
* upstream path. Split out from the route handler so the 404 fallback (which has
|
||||
* no route params) can reuse it.
|
||||
*/
|
||||
async function proxyRequest(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
cap: string,
|
||||
wildcard: string
|
||||
): Promise<FastifyReply> {
|
||||
const webview = await lookupCapability(cap);
|
||||
if (!webview) {
|
||||
return reply.code(403).type('text/plain').send('Forbidden: unknown or expired webview capability');
|
||||
}
|
||||
|
||||
// CORS is required even though the URL is on this host: a sandboxed dashboard is
|
||||
// opaque-origin, so its fetch/XHR are cross-origin requests. See
|
||||
// buildProxyCorsHeaders.
|
||||
const cors = buildProxyCorsHeaders(
|
||||
typeof req.headers.origin === 'string' ? req.headers.origin : undefined,
|
||||
typeof req.headers['access-control-request-headers'] === 'string'
|
||||
? req.headers['access-control-request-headers']
|
||||
: undefined
|
||||
);
|
||||
|
||||
// Answer the preflight here rather than relaying it: the dashboard has no reason
|
||||
// to know it is being framed, and most would reject an unexpected `Origin: null`.
|
||||
if (req.method === 'OPTIONS' && req.headers['access-control-request-method']) {
|
||||
for (const [key, value] of Object.entries(cors)) reply.header(key, value);
|
||||
return reply.code(204).send();
|
||||
}
|
||||
|
||||
const queryStart = req.url.indexOf('?');
|
||||
const search = queryStart === -1 ? '' : req.url.slice(queryStart);
|
||||
const upstream = resolveUpstreamUrl(webview.url, wildcard, search);
|
||||
if (!upstream) {
|
||||
return reply.code(400).type('text/plain').send('Bad Request: path escapes the dashboard origin');
|
||||
}
|
||||
|
||||
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
||||
const headers = buildUpstreamRequestHeaders(req.headers, upstream, {
|
||||
forwardCookies: webview.trusted,
|
||||
sessionCookieName: AUTH_COOKIE_NAME,
|
||||
refererPath: typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap) : undefined,
|
||||
});
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(upstream.href, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: hasBody ? (req.body as Readable) : undefined,
|
||||
// Required by undici whenever the body is a stream.
|
||||
...(hasBody ? { duplex: 'half' } : {}),
|
||||
// Redirects are rewritten into the proxy prefix instead of followed, so the
|
||||
// browser's URL stays inside the frame and relative assets keep resolving.
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(WEBVIEW_UPSTREAM_TIMEOUT_MS),
|
||||
} as RequestInit);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return reply.code(502).type('text/plain').send(`Dashboard unreachable: ${message}`);
|
||||
}
|
||||
|
||||
const secureContext = req.protocol === 'https';
|
||||
const {
|
||||
headers: outHeaders,
|
||||
setCookie,
|
||||
csp,
|
||||
} = buildDownstreamResponseHeaders(
|
||||
response.headers as unknown as Iterable<[string, string]>,
|
||||
response.headers.getSetCookie(),
|
||||
cap,
|
||||
upstream,
|
||||
secureContext
|
||||
);
|
||||
|
||||
reply.code(response.status);
|
||||
for (const [key, value] of Object.entries(outHeaders)) reply.header(key, value);
|
||||
// After the upstream headers, so ours win: an upstream ACAO would name the
|
||||
// dashboard's own origin, not the opaque origin this frame actually has.
|
||||
for (const [key, value] of Object.entries(cors)) reply.header(key, value);
|
||||
for (const cookie of setCookie) reply.header('set-cookie', cookie);
|
||||
|
||||
// registerSecurityHeaders already stamped Codeman's own `default-src 'self'`
|
||||
// policy on this reply during onRequest. Left in place it breaks essentially
|
||||
// every dashboard (inline scripts, CDN assets), so it is replaced by the
|
||||
// upstream's own policy, or removed when the upstream had none.
|
||||
if (csp) reply.header('content-security-policy', csp);
|
||||
else reply.removeHeader('content-security-policy');
|
||||
|
||||
if (!response.body || req.method === 'HEAD') {
|
||||
return reply.send();
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? undefined;
|
||||
const declaredLength = Number(response.headers.get('content-length') ?? '0');
|
||||
const rewritable = isHtmlContentType(contentType) && declaredLength <= MAX_WEBVIEW_HTML_REWRITE_BYTES;
|
||||
|
||||
if (rewritable) {
|
||||
// Buffer only HTML, only under the cap: `<base>` injection needs the whole
|
||||
// document, and buffering an unbounded upstream body is a memory hazard.
|
||||
const html = await response.text();
|
||||
return reply.send(html.length <= MAX_WEBVIEW_HTML_REWRITE_BYTES ? rewriteHtml(html, cap) : html);
|
||||
}
|
||||
|
||||
return reply.send(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort handler for a dashboard asset requested with a ROOT-ABSOLUTE URL.
|
||||
*
|
||||
* `<base href>` fixes relative URLs and the HTML rewrite fixes `src`/`href`/`action`
|
||||
* attributes, but neither can reach a URL built at runtime: `fetch('/api/data')`,
|
||||
* `import('/chunk.js')`, `url(/img.png)` inside a stylesheet. Those arrive at
|
||||
* Codeman's root and 404.
|
||||
*
|
||||
* The `Referer` identifies which dashboard asked, so the request can be routed to
|
||||
* the right upstream. Wiring it into the 404 handler rather than a catch-all route
|
||||
* is what keeps it contained: every real Codeman route matches first, and this only
|
||||
* ever sees requests that were going to fail anyway.
|
||||
*
|
||||
* @returns true when the request was handled (caller must not also reply).
|
||||
*/
|
||||
export async function tryWebviewRefererFallback(req: FastifyRequest, reply: FastifyReply): Promise<boolean> {
|
||||
// Safe methods only. A write arriving here has already lost its raw body to the
|
||||
// root instance's JSON parser, so it could not be relayed faithfully anyway.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
||||
|
||||
const capability = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
|
||||
if (!capability) return false;
|
||||
if (!webviewCapabilities.resolve(capability)) return false;
|
||||
|
||||
const path = req.url.split('?')[0].replace(/^\//, '');
|
||||
await proxyRequest(req, reply, capability, path);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Turn a proxy-side Referer back into the upstream path it corresponds to. */
|
||||
function stripProxyPrefix(referer: string, capability: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(referer);
|
||||
const prefix = proxyPrefixFor(capability);
|
||||
if (!url.pathname.startsWith(prefix)) return undefined;
|
||||
return `/${url.pathname.slice(prefix.length)}${url.search}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── WebSocket ───────────────────────────
|
||||
|
||||
/**
|
||||
* Relay a WebSocket through to the dashboard.
|
||||
*
|
||||
* Live dashboards (Grafana, Home Assistant, Uptime Kuma) push over WebSocket, so
|
||||
* without this leg they load but their realtime panels stay permanently empty.
|
||||
*
|
||||
* The upgrade is guarded on the capability, NOT on `Origin`: a sandboxed iframe is
|
||||
* opaque-origin, so its upgrade arrives with `Origin: null`. The host allowlist
|
||||
* still applies (it runs in the global onRequest hook), so DNS-rebinding
|
||||
* protection is unaffected.
|
||||
*/
|
||||
function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyParams }>): void {
|
||||
const { cap } = req.params;
|
||||
|
||||
void (async () => {
|
||||
const webview = await lookupCapability(cap);
|
||||
if (!webview) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const live = socketCounts.get(webview.id) ?? 0;
|
||||
if (live >= MAX_WEBVIEW_SOCKETS) {
|
||||
socket.close(4008, 'Too many connections');
|
||||
return;
|
||||
}
|
||||
|
||||
const wildcard = req.params['*'] ?? '';
|
||||
const queryStart = req.url.indexOf('?');
|
||||
const search = queryStart === -1 ? '' : req.url.slice(queryStart);
|
||||
const upstream = resolveUpstreamUrl(webview.url, wildcard, search);
|
||||
if (!upstream) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
socketCounts.set(webview.id, live + 1);
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const count = socketCounts.get(webview.id) ?? 1;
|
||||
if (count <= 1) socketCounts.delete(webview.id);
|
||||
else socketCounts.set(webview.id, count - 1);
|
||||
};
|
||||
|
||||
const protocols = req.headers['sec-websocket-protocol'];
|
||||
const upstreamSocket = new WsClient(
|
||||
upstreamWebSocketUrl(upstream),
|
||||
protocols ? String(protocols).split(/,\s*/) : [],
|
||||
{
|
||||
headers: {
|
||||
origin: upstream.origin,
|
||||
...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}),
|
||||
},
|
||||
handshakeTimeout: WEBVIEW_UPSTREAM_TIMEOUT_MS,
|
||||
}
|
||||
);
|
||||
|
||||
// Buffer anything the browser sends before the upstream handshake completes,
|
||||
// rather than dropping it: a client that sends a subscribe frame immediately
|
||||
// would otherwise sit connected and silent forever.
|
||||
const pending: Array<Buffer | string> = [];
|
||||
let upstreamOpen = false;
|
||||
|
||||
upstreamSocket.on('open', () => {
|
||||
upstreamOpen = true;
|
||||
for (const message of pending) upstreamSocket.send(message);
|
||||
pending.length = 0;
|
||||
});
|
||||
|
||||
socket.on('message', (data: Buffer, isBinary: boolean) => {
|
||||
const payload = isBinary ? data : data.toString();
|
||||
if (upstreamOpen) upstreamSocket.send(payload);
|
||||
else if (pending.length < 64) pending.push(payload);
|
||||
});
|
||||
|
||||
upstreamSocket.on('message', (data: Buffer, isBinary: boolean) => {
|
||||
if (socket.readyState === socket.OPEN) socket.send(isBinary ? data : data.toString());
|
||||
});
|
||||
|
||||
// Paired close in both directions, so neither side is left half-open.
|
||||
const closeBoth = (code?: number, reason?: string) => {
|
||||
release();
|
||||
// Codes outside 3000-4999 (and 1000/1001) are not valid to send onward.
|
||||
const safeCode = code && code >= 3000 && code <= 4999 ? code : 1000;
|
||||
if (socket.readyState === socket.OPEN) socket.close(safeCode, reason);
|
||||
if (upstreamSocket.readyState === WsClient.OPEN || upstreamSocket.readyState === WsClient.CONNECTING) {
|
||||
upstreamSocket.close(safeCode, reason);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
||||
upstreamSocket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
||||
socket.on('error', () => closeBoth());
|
||||
upstreamSocket.on('error', () => {
|
||||
release();
|
||||
if (socket.readyState === socket.OPEN) socket.close(1011, 'Upstream error');
|
||||
});
|
||||
})();
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { z } from 'zod';
|
||||
import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js';
|
||||
import { isValidWebviewUrl } from './webview-proxy.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_BYTES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
@@ -49,6 +50,39 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
|
||||
message: 'Invalid path: must be absolute, no shell metacharacters or traversal',
|
||||
});
|
||||
|
||||
/**
|
||||
* Filesystem picker paths are never interpolated into a shell command, so legal
|
||||
* filename characters such as spaces, quotes, and parentheses are accepted.
|
||||
* Containment and symlink resolution are enforced by the route after parsing.
|
||||
*/
|
||||
const filesystemPickerPathSchema = z
|
||||
.string()
|
||||
.max(4096)
|
||||
.refine((p) => p.startsWith('/') && !p.includes('\0') && !p.includes('\n') && !p.includes('\r'), {
|
||||
message: 'Path must be an absolute filesystem path',
|
||||
})
|
||||
.refine((p) => !p.split('/').includes('..'), { message: 'Path traversal is not allowed' });
|
||||
|
||||
/** Query validation for the lazy, allowlisted filesystem path picker. */
|
||||
export const FilesystemBrowseQuerySchema = z.object({
|
||||
path: filesystemPickerPathSchema.optional(),
|
||||
sessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** Query validation for a single allowlisted path-picker file preview. */
|
||||
export const FilesystemPreviewQuerySchema = z.object({
|
||||
path: filesystemPickerPathSchema,
|
||||
sessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ========== Env Var Allowlist ==========
|
||||
|
||||
/** Allowlisted env var key prefixes */
|
||||
@@ -192,6 +226,22 @@ export const CreateSessionSchema = z.object({
|
||||
.max(100)
|
||||
.regex(/^[a-f0-9-]+$/, 'resumeSessionId must be a valid UUID')
|
||||
.optional(),
|
||||
/**
|
||||
* COD-105 — attach to an EXISTING remote tmux session discovered via
|
||||
* `GET /api/remote-hosts/:hostId/sessions` (one this Codeman didn't create).
|
||||
* The resulting session is NON-owned (closing it detaches, never kills the
|
||||
* remote). `remoteSessionName` is a discovered `codeman-*` tmux session name.
|
||||
*/
|
||||
attachRemoteSession: z
|
||||
.object({
|
||||
hostId: z.string().min(1).max(200),
|
||||
remoteSessionName: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.regex(/^codeman-[a-zA-Z0-9._-]+$/, 'remoteSessionName must be a codeman-* tmux session name'),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -634,6 +684,22 @@ const NotificationEventSchema = z
|
||||
|
||||
export const SettingsUpdateSchema = z
|
||||
.object({
|
||||
// User-facing product branding. This changes browser/UI copy only; package,
|
||||
// CLI, API, storage, and protocol identifiers remain Codeman.
|
||||
displayName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(40)
|
||||
.refine(
|
||||
(value) =>
|
||||
Array.from(value).every((character) => {
|
||||
const codePoint = character.codePointAt(0);
|
||||
return codePoint !== undefined && codePoint > 31 && codePoint !== 127;
|
||||
}),
|
||||
'Display name must not contain control characters'
|
||||
)
|
||||
.optional(),
|
||||
// Paths
|
||||
defaultClaudeMdPath: z.string().max(500).optional(),
|
||||
defaultWorkingDir: z.string().max(500).optional(),
|
||||
@@ -658,6 +724,10 @@ export const SettingsUpdateSchema = z
|
||||
/** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */
|
||||
claudeModel: z.string().max(50).optional(),
|
||||
opusContext1mEnabled: z.boolean().optional(),
|
||||
// COD-108 remote-session auto-reconnect kill-switch (default ON). When false,
|
||||
// the TmuxManager watcher does nothing — dropped remote sessions are NOT
|
||||
// auto-reattached.
|
||||
remoteAutoReconnect: z.boolean().optional(),
|
||||
thinkingEffort: z.string().max(20).optional(),
|
||||
// UI visibility
|
||||
showFontControls: z.boolean().optional(),
|
||||
@@ -847,6 +917,11 @@ export const AutoResumeSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */
|
||||
export const PinSessionSchema = z.object({
|
||||
pinned: z.boolean(),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/image-watcher */
|
||||
export const ImageWatcherSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
@@ -949,6 +1024,14 @@ export const CaseOrderSchema = z.object({
|
||||
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
|
||||
});
|
||||
|
||||
/** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */
|
||||
export const SessionOrderUpdateSchema = z.object({
|
||||
// Bounded defensively: ids are uuid-ish (<=100 chars) and the client pushes only
|
||||
// its open-tab order (max sessions is 50) — 500 leaves ample headroom while
|
||||
// keeping a hostile/buggy client from persisting megabytes into state.json.
|
||||
order: z.array(z.string().max(100)).max(500),
|
||||
});
|
||||
|
||||
/** POST /api/auth/revoke */
|
||||
export const RevokeSessionSchema = z.object({
|
||||
sessionToken: z.string().min(1).max(200).optional(),
|
||||
@@ -1135,3 +1218,42 @@ export const SearchQuerySchema = z.object({
|
||||
),
|
||||
limit: z.coerce.number().int().min(1).max(60).optional(),
|
||||
});
|
||||
|
||||
// ========== Web Tabs (dashboard URLs) ==========
|
||||
|
||||
/**
|
||||
* A dashboard URL. `isValidWebviewUrl` rejects anything that is not plain
|
||||
* http/https, anything carrying embedded credentials, and anything without a
|
||||
* hostname. See `src/web/webview-proxy.ts` for why each of those matters.
|
||||
*/
|
||||
const webviewUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'URL is required')
|
||||
.max(2000, 'URL too long (max 2000 chars)')
|
||||
.refine(isValidWebviewUrl, {
|
||||
message: 'Invalid URL: must be http(s), with a hostname and no embedded credentials',
|
||||
});
|
||||
|
||||
const WebviewBaseSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name is required').max(60, 'Name too long (max 60 chars)'),
|
||||
url: webviewUrlSchema,
|
||||
/** A single glyph shown on the tab. Bounded generously: one emoji can be several code units. */
|
||||
icon: z.string().max(8).optional(),
|
||||
embedMode: z.enum(['proxy', 'direct']).optional(),
|
||||
/**
|
||||
* Opt out of the iframe sandbox. Defaults to false: a proxied page is served
|
||||
* from Codeman's own origin, so `allow-same-origin` would let it read this page
|
||||
* and call the API that spawns agents.
|
||||
*/
|
||||
trusted: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** POST /api/webviews */
|
||||
export const WebviewCreateSchema = WebviewBaseSchema;
|
||||
|
||||
/** PATCH /api/webviews/:id, partial update. */
|
||||
export const WebviewUpdateSchema = WebviewBaseSchema.partial();
|
||||
|
||||
/** POST /api/webviews/probe: reachability + framing check for the editor's Test button. */
|
||||
export const WebviewProbeSchema = z.object({ url: webviewUrlSchema });
|
||||
|
||||
@@ -160,6 +160,8 @@ import {
|
||||
registerMeRoutes,
|
||||
registerAdminRoutes,
|
||||
registerWsRoutes,
|
||||
registerWebviewRoutes,
|
||||
tryWebviewRefererFallback,
|
||||
} from './routes/index.js';
|
||||
import { CronService } from '../cron/cron-service.js';
|
||||
|
||||
@@ -288,7 +290,7 @@ export class WebServer extends EventEmitter {
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
||||
private readonly titleHostname: string;
|
||||
private readonly windowTitle: string;
|
||||
private windowTitle: string;
|
||||
private readonly indexHtmlTemplate: string;
|
||||
private readonly allowUnauthenticatedNetwork: boolean;
|
||||
private _pasteImageGcStop: (() => void) | null = null;
|
||||
@@ -359,6 +361,22 @@ export class WebServer extends EventEmitter {
|
||||
this.broadcast(SseEvent.MuxStatsUpdated, sessions);
|
||||
});
|
||||
|
||||
// COD-108 — remote-session auto-reconnect. The TmuxManager watcher detects a
|
||||
// dead remote pane and emits `remoteSessionDropped`; the session owner (here)
|
||||
// reassembles the respawn options and reattaches via Session.reattachRemote()
|
||||
// (D1: the watcher does NOT reassemble options itself). On success we reset
|
||||
// the watcher's backoff; on failure the backoff schedules the next attempt.
|
||||
this.mux.on('remoteSessionDropped', (data) => {
|
||||
const { sessionId, attempt } = data as { sessionId: string; attempt: number };
|
||||
this.broadcast(SseEvent.RemoteSessionDropped, { sessionId, attempt });
|
||||
void this.handleRemoteSessionDropped(sessionId);
|
||||
});
|
||||
this.mux.on('remoteReconnectExhausted', (data) => {
|
||||
const { sessionId } = data as { sessionId: string };
|
||||
console.warn(`[Server] Remote auto-reconnect exhausted for session ${sessionId}`);
|
||||
this.broadcast(SseEvent.RemoteReconnectExhausted, { sessionId });
|
||||
});
|
||||
|
||||
// Set up subagent watcher listeners
|
||||
this.setupSubagentWatcherListeners();
|
||||
this.setupWorkflowRunWatcherListeners();
|
||||
@@ -835,13 +853,24 @@ export class WebServer extends EventEmitter {
|
||||
// Stable-contract 404 for unknown /api routes — without this, Fastify's
|
||||
// default not-found payload {message,error,statusCode} would be wrapped by
|
||||
// the envelope hook into a contradictory HTTP 404 {success:true,...}.
|
||||
this.app.setNotFoundHandler((req, reply) => {
|
||||
this.app.setNotFoundHandler(async (req, reply) => {
|
||||
const notFound = `Route ${req.method}:${req.url} not found`;
|
||||
// A web-tab dashboard asking for a root-absolute asset (`fetch('/api/data')`,
|
||||
// `import('/chunk.js')`, `url(/img.png)` inside a stylesheet) lands here,
|
||||
// because `<base href>` cannot rewrite a URL built at runtime. Its Referer says
|
||||
// which dashboard to relay to. Deliberately placed on the 404 path so every
|
||||
// real Codeman route still wins.
|
||||
//
|
||||
// Tried BEFORE the API-shaped 404, because a dashboard's own assets commonly
|
||||
// live under its `/api/...` namespace and were the one class this could never
|
||||
// rescue. Reaching this handler at all already proves no Codeman route matched,
|
||||
// and the relay declines unless the Referer carries a live capability, so
|
||||
// genuinely unknown `/api` paths still get the envelope below.
|
||||
if (await tryWebviewRefererFallback(req, reply)) return reply;
|
||||
if (req.url.startsWith('/api')) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
|
||||
return;
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
|
||||
}
|
||||
reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
|
||||
return reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
|
||||
});
|
||||
|
||||
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them.
|
||||
@@ -909,6 +938,7 @@ export class WebServer extends EventEmitter {
|
||||
registerMeRoutes(this.app, ctx);
|
||||
registerAdminRoutes(this.app, ctx);
|
||||
registerOrchestratorRoutes(this.app, ctx);
|
||||
registerWebviewRoutes(this.app, ctx);
|
||||
|
||||
// Cron: build the service from the same context, recompute
|
||||
// due times for any persisted jobs, then expose it to its routes.
|
||||
@@ -1196,7 +1226,7 @@ export class WebServer extends EventEmitter {
|
||||
// Only remove from state.json if we're also killing the mux session.
|
||||
// When killMux=false (server shutdown), preserve state for recovery.
|
||||
if (killMux) {
|
||||
this.store.removeSession(sessionId);
|
||||
this.store.demoteOrRemoveSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1204,6 +1234,17 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
private async renderIndexHtml(soloSessionId?: string): Promise<string> {
|
||||
// Detached-session windows intentionally skip App Settings during server
|
||||
// rendering (their client bootstrap loads the synced name moments later).
|
||||
const persistedSettings: Record<string, unknown> = soloSessionId ? {} : await this.readSettings(true);
|
||||
const configuredDisplayName =
|
||||
typeof persistedSettings.displayName === 'string' ? persistedSettings.displayName.trim() : '';
|
||||
const displayName = configuredDisplayName || 'Codeman';
|
||||
// Solo renders read no settings; recomputing here would reset the shared
|
||||
// push-notification prefix (hostTitle) to the default name.
|
||||
if (!soloSessionId) {
|
||||
this.windowTitle = `${displayName === 'Codeman' ? 'codeman' : displayName}:${this.titleHostname}`;
|
||||
}
|
||||
let html = this.indexHtmlTemplate.replace(
|
||||
'<title>Codeman</title>',
|
||||
`<title>${escapeHtmlText(this.windowTitle)}</title>`
|
||||
@@ -1217,7 +1258,7 @@ export class WebServer extends EventEmitter {
|
||||
// moments ago triggers a reload here, and the cached value would render the
|
||||
// pre-toggle state (e.g. the gesture bundle wouldn't inject until a 2nd
|
||||
// reload). Skipped for solo popups (their header differs).
|
||||
const settings: Record<string, unknown> = soloSessionId ? {} : await this.readSettings(true);
|
||||
const settings: Record<string, unknown> = soloSessionId ? {} : persistedSettings;
|
||||
// Multi-monitor header button: carries the `btn-multimonitor--hidden` class
|
||||
// in the template by default (App Settings → Display → "Header Displays");
|
||||
// reveal by stripping that class when the user enabled it. Matching a unique
|
||||
@@ -1855,6 +1896,7 @@ export class WebServer extends EventEmitter {
|
||||
timestamp: now,
|
||||
inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON',
|
||||
planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load
|
||||
sessionOrder: this.store.getSessionOrder(), // global tab order, synced across devices (COD-131)
|
||||
};
|
||||
|
||||
this.cachedLightState = { data: result, timestamp: now };
|
||||
@@ -2603,6 +2645,13 @@ export class WebServer extends EventEmitter {
|
||||
(this.mux as { startMouseModeSync: (ms?: number) => void }).startMouseModeSync();
|
||||
}
|
||||
|
||||
// COD-108 — start the remote-session auto-reconnect watcher (tmux only).
|
||||
// Always-on (D3) with a `remoteAutoReconnect` kill-switch the watcher reads
|
||||
// each tick. Start even with no sessions — remote sessions may arrive later.
|
||||
if ('startRemoteReconnectWatcher' in this.mux) {
|
||||
(this.mux as { startRemoteReconnectWatcher: (ms?: number) => void }).startRemoteReconnectWatcher();
|
||||
}
|
||||
|
||||
if (dead.length > 0) {
|
||||
console.log(`[Server] Cleaned up ${dead.length} dead mux session(s)`);
|
||||
}
|
||||
@@ -2611,6 +2660,41 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* COD-108 — handle a `remoteSessionDropped` emit from the watcher: reattach
|
||||
* the dropped remote session and report the outcome back to the watcher so it
|
||||
* can reset/advance its backoff. Re-running the idempotent remote command
|
||||
* REATTACHES the durable remote tmux session (does NOT recreate it).
|
||||
*/
|
||||
private async handleRemoteSessionDropped(sessionId: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
// No live Session object (e.g. detached/restored-but-not-attached) — nothing
|
||||
// to drive the reattach; report failure so the watcher backs off and retries.
|
||||
if (!session) {
|
||||
this.noteRemoteReconnect(sessionId, false);
|
||||
return;
|
||||
}
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await session.reattachRemote();
|
||||
} catch (err) {
|
||||
console.error(`[Server] Remote reattach failed for ${sessionId}:`, err);
|
||||
ok = false;
|
||||
}
|
||||
this.noteRemoteReconnect(sessionId, ok);
|
||||
if (ok) {
|
||||
this.persistSessionState(session);
|
||||
this.broadcast(SseEvent.RemoteSessionReconnected, { sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
/** Forward a reattach outcome to the TmuxManager watcher (resets/clears backoff). */
|
||||
private noteRemoteReconnect(sessionId: string, success: boolean): void {
|
||||
if ('noteRemoteReconnect' in this.mux) {
|
||||
(this.mux as { noteRemoteReconnect: (id: string, ok: boolean) => void }).noteRemoteReconnect(sessionId, success);
|
||||
}
|
||||
}
|
||||
|
||||
private initOrchestratorLoop(): import('../orchestrator-loop.js').OrchestratorLoop {
|
||||
if (this._orchestratorLoop) return this._orchestratorLoop;
|
||||
|
||||
|
||||
@@ -5,27 +5,32 @@
|
||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||
* Both files MUST be kept in sync.
|
||||
*
|
||||
* 120 event constants organized by category:
|
||||
* 149 event constants organized by category:
|
||||
* - **Core** (1): init
|
||||
* - **Session lifecycle** (17): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
||||
* - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate
|
||||
* - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded
|
||||
* - **Tasks** (4): created, completed, failed, updated
|
||||
* - **Mux** (4): created, killed, died, statsUpdated
|
||||
* - **Remote auto-reconnect** (3): sessionDropped, sessionReconnected, reconnectExhausted
|
||||
* - **Respawn** (24): stateChanged, cycleStarted/Completed, step*, aiCheck*, planCheck*, timer*, log, ...
|
||||
* - **Subagents** (7): discovered, updated, tool_call, tool_result, progress, message, completed
|
||||
* - **Workflow runs** (3): run_discovered, run_updated, run_removed (ultracode / Workflow tool)
|
||||
* - **Scheduled** (6): created, updated, completed, stopped, log, deleted
|
||||
* - **Cron jobs** (4): jobsChanged, jobDeleted, runCreated, runUpdated
|
||||
* - **Teams** (4): created, updated, removed, taskUpdated
|
||||
* - **Transcript** (4): complete, plan_mode, tool_start, tool_end
|
||||
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
|
||||
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
|
||||
* - **Image** (1): detected
|
||||
* - **Image / attachments** (2): image:detected, attachment:detected
|
||||
* - **Hooks** (6): idle_prompt, permission_prompt, elicitation_dialog, stop, teammate_idle, task_completed
|
||||
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
|
||||
* - **Clipboard** (1): write
|
||||
* - **Cases** (4): created, linked, deleted, order-changed
|
||||
* - **Docker cases** (8): exportComplete/Failed, importComplete, imageBuild*, containerRecreated
|
||||
* - **Multi-user** (3): admin:usersChanged, auth:passwordChangeRequired, session:orderChanged
|
||||
* - **Web tabs** (1): webview:changed
|
||||
*
|
||||
* Naming convention: `domain:action` (e.g., `session:created`, `respawn:stateChanged`)
|
||||
*
|
||||
@@ -84,6 +89,8 @@ export const SessionLimitResumeCancelled = 'session:limitResumeCancelled' as con
|
||||
export const SessionRespawnBreakerTripped = 'session:respawnBreakerTripped' as const;
|
||||
/** CLI version/model info detected from session output. */
|
||||
export const SessionCliInfo = 'session:cliInfo' as const;
|
||||
/** Session pin state changed (COD-139): pinned/unpinned in the session manager list. */
|
||||
export const SessionPinned = 'session:pinned' as const;
|
||||
/** General session message (e.g. status text). */
|
||||
export const SessionMessage = 'session:message' as const;
|
||||
/** Session entered interactive mode (claude or shell). */
|
||||
@@ -150,6 +157,15 @@ export const MuxDied = 'mux:died' as const;
|
||||
/** tmux session stats refreshed. */
|
||||
export const MuxStatsUpdated = 'mux:statsUpdated' as const;
|
||||
|
||||
// ─── Remote auto-reconnect (COD-108) ─────────────────────────────────────────
|
||||
|
||||
/** A remote session's local ssh pane died; an auto-reconnect attempt is starting. */
|
||||
export const RemoteSessionDropped = 'remote:sessionDropped' as const;
|
||||
/** A dropped remote session was successfully re-established (reattached). */
|
||||
export const RemoteSessionReconnected = 'remote:sessionReconnected' as const;
|
||||
/** Auto-reconnect gave up after the bounded backoff cap — manual reconnect needed. */
|
||||
export const RemoteReconnectExhausted = 'remote:reconnectExhausted' as const;
|
||||
|
||||
// ─── Respawn ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Respawn loop started for a session. */
|
||||
@@ -395,6 +411,14 @@ export const AdminUsersChanged = 'admin:usersChanged' as const;
|
||||
/** A user must change their password (targeted); the frontend shows the modal. */
|
||||
export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const;
|
||||
|
||||
/** Global session tab order changed (synced across devices). COD-131. */
|
||||
export const SessionOrderChanged = 'session:orderChanged' as const;
|
||||
|
||||
/** A saved web tab (dashboard URL) was created, updated or deleted.
|
||||
* Payload: `{ action: 'created' | 'updated' | 'deleted', id }`. The client
|
||||
* re-fetches the list rather than patching from the payload. */
|
||||
export const WebviewChanged = 'webview:changed' as const;
|
||||
|
||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -424,6 +448,7 @@ export const SseEvent = {
|
||||
SessionLimitResumeCancelled,
|
||||
SessionRespawnBreakerTripped,
|
||||
SessionCliInfo,
|
||||
SessionPinned,
|
||||
SessionMessage,
|
||||
SessionInteractive,
|
||||
SessionRunning,
|
||||
@@ -460,6 +485,11 @@ export const SseEvent = {
|
||||
MuxDied,
|
||||
MuxStatsUpdated,
|
||||
|
||||
// Remote auto-reconnect (COD-108)
|
||||
RemoteSessionDropped,
|
||||
RemoteSessionReconnected,
|
||||
RemoteReconnectExhausted,
|
||||
|
||||
// Respawn
|
||||
RespawnStarted,
|
||||
RespawnStopped,
|
||||
@@ -588,4 +618,10 @@ export const SseEvent = {
|
||||
AdminUsersChanged,
|
||||
AuthPasswordChangeRequired,
|
||||
DockerContainerRecreated,
|
||||
|
||||
// Session order (global tab order sync)
|
||||
SessionOrderChanged,
|
||||
|
||||
// Web tabs (dashboard URLs)
|
||||
WebviewChanged,
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
/**
|
||||
* @fileoverview Pure helpers for the web-tab reverse proxy. No I/O, no Fastify.
|
||||
*
|
||||
* The proxy exists because an iframe pointing straight at a dashboard cannot work
|
||||
* in the deployment that matters: prod serves HTTPS (behind `tailscale serve`), so
|
||||
* a plain-HTTP dashboard is hard-blocked as mixed content; many dashboards also
|
||||
* refuse framing outright via `X-Frame-Options` / `frame-ancestors`; and Codeman's
|
||||
* own CSP (`default-src 'self'`) blocks cross-origin frames anyway. Serving the
|
||||
* dashboard through Codeman's own origin dissolves all three at once, and keeps
|
||||
* the production CSP byte-for-byte unchanged because `/webview/...` is `'self'`.
|
||||
*
|
||||
* ## Origin-scoped, not path-scoped
|
||||
*
|
||||
* `/webview/<cap>/x/y` always maps to `<upstream origin>/x/y`, never to
|
||||
* `<upstream origin><saved path>/x/y`. Dashboards reference assets with
|
||||
* root-absolute paths (`/public/build/app.js`), so origin-scoping is the only
|
||||
* mapping under which those resolve. The saved URL's own path+query is used for
|
||||
* exactly one thing: what `/webview/<cap>/` itself serves (the landing page).
|
||||
*
|
||||
* ## What gets rewritten, and why each one is load-bearing
|
||||
*
|
||||
* - `x-frame-options` / CSP `frame-ancestors`: dropped, else the browser refuses
|
||||
* to render the frame. This is the whole point of the proxy.
|
||||
* - `content-encoding` / `content-length`: dropped, because undici's `fetch`
|
||||
* already decoded the body. Forwarding them makes the browser try to gunzip
|
||||
* plaintext.
|
||||
* - `authorization` + the `codeman_session` cookie: stripped on the way OUT. In
|
||||
* trusted mode the iframe is same-origin, so the browser attaches Codeman's own
|
||||
* Basic-auth header and session cookie to every proxied request. Forwarding
|
||||
* those would hand CODEMAN_PASSWORD to the dashboard.
|
||||
* - `Location` and `Set-Cookie`: remapped into the proxy path, else a redirect or
|
||||
* a login cookie escapes the prefix and lands on Codeman's root.
|
||||
* - `<base href>` + root-absolute attribute rewriting: relative and `/`-rooted
|
||||
* URLs in the HTML resolve back through the proxy instead of hitting Codeman.
|
||||
*
|
||||
* No `X-Forwarded-*` is sent deliberately: apps that honor it generate absolute
|
||||
* URLs against Codeman's root, which would bypass the `/webview/<cap>/` prefix
|
||||
* that everything else here works to preserve.
|
||||
*/
|
||||
|
||||
import { WEBVIEW_PROXY_PREFIX } from '../config/webview-limits.js';
|
||||
|
||||
/** Headers that are per-connection and must never be relayed in either direction. */
|
||||
const HOP_BY_HOP = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'proxy-connection',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Request headers dropped on the way to the upstream. `authorization` and `cookie`
|
||||
* carry Codeman's own credentials on a same-origin (trusted) frame; `host`,
|
||||
* `content-length` and `accept-encoding` are recomputed by undici.
|
||||
*/
|
||||
const DROP_REQUEST_HEADERS = new Set([
|
||||
...HOP_BY_HOP,
|
||||
'host',
|
||||
'content-length',
|
||||
'accept-encoding',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'origin',
|
||||
'referer',
|
||||
'x-codeman-hook-secret',
|
||||
]);
|
||||
|
||||
/** Response headers dropped on the way back to the browser. */
|
||||
const DROP_RESPONSE_HEADERS = new Set([
|
||||
...HOP_BY_HOP,
|
||||
'content-encoding',
|
||||
'content-length',
|
||||
'x-frame-options',
|
||||
'content-security-policy-report-only',
|
||||
// Cross-origin isolation headers describe the UPSTREAM's origin policy; applied
|
||||
// to a frame on Codeman's origin they only produce blocked-resource surprises.
|
||||
'cross-origin-opener-policy',
|
||||
'cross-origin-embedder-policy',
|
||||
'cross-origin-resource-policy',
|
||||
'set-cookie',
|
||||
'location',
|
||||
'content-security-policy',
|
||||
// The upstream's CORS answer describes ITS origin; the frame asking is
|
||||
// opaque-origin on ours, so ours must replace it (see buildProxyCorsHeaders).
|
||||
'access-control-allow-origin',
|
||||
'access-control-allow-credentials',
|
||||
'access-control-allow-methods',
|
||||
'access-control-allow-headers',
|
||||
'access-control-expose-headers',
|
||||
'access-control-max-age',
|
||||
]);
|
||||
|
||||
/** The same-origin path prefix an iframe loads for a given capability. */
|
||||
export function proxyPrefixFor(capability: string): string {
|
||||
return `${WEBVIEW_PROXY_PREFIX}/${capability}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a user-supplied dashboard URL.
|
||||
*
|
||||
* Rejects everything that is not plain `http:`/`https:`, anything carrying
|
||||
* embedded credentials (they would be silently forwarded and logged), and
|
||||
* anything without a hostname. Returns the normalized `URL` or null.
|
||||
*/
|
||||
export function parseWebviewUrl(raw: string): URL | null {
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
if (url.username !== '' || url.password !== '') return null;
|
||||
if (!url.hostname) return null;
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Convenience predicate for Zod refinements. */
|
||||
export function isValidWebviewUrl(raw: string): boolean {
|
||||
return parseWebviewUrl(raw) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a proxy request path to its upstream URL.
|
||||
*
|
||||
* `wildcard` is Fastify's `*` param: the path after `/webview/<cap>/`, without a
|
||||
* leading slash. An empty wildcard means the landing page, which is the saved
|
||||
* URL's own path and query.
|
||||
*
|
||||
* Returns null when the result would escape the upstream origin (a `..` chain, a
|
||||
* protocol-relative `//evil.com` wildcard, or an absolute URL smuggled into the
|
||||
* path). That check is what keeps this from being an open proxy.
|
||||
*/
|
||||
export function resolveUpstreamUrl(savedUrl: string, wildcard: string, search: string): URL | null {
|
||||
const base = parseWebviewUrl(savedUrl);
|
||||
if (!base) return null;
|
||||
|
||||
if (wildcard === '' || wildcard === '/') {
|
||||
const landing = new URL(base.pathname + (search || base.search), base.origin);
|
||||
return landing.origin === base.origin ? landing : null;
|
||||
}
|
||||
|
||||
// A wildcard starting with `//` would parse as protocol-relative and jump host.
|
||||
const path = wildcard.startsWith('/') ? wildcard : `/${wildcard}`;
|
||||
if (path.startsWith('//')) return null;
|
||||
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(path + (search || ''), base.origin);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return target.origin === base.origin ? target : null;
|
||||
}
|
||||
|
||||
/** Extract the capability from a `/webview/<cap>/...` pathname, or null. */
|
||||
export function capabilityFromProxyPath(pathname: string): string | null {
|
||||
if (typeof pathname !== 'string') return null;
|
||||
const prefix = `${WEBVIEW_PROXY_PREFIX}/`;
|
||||
if (!pathname.startsWith(prefix)) return null;
|
||||
const rest = pathname.slice(prefix.length);
|
||||
const slash = rest.indexOf('/');
|
||||
const cap = slash === -1 ? rest : rest.slice(0, slash);
|
||||
return /^[A-Za-z0-9_-]{16,128}$/.test(cap) ? cap : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the capability a `Referer` belongs to. Backs the 404 fallback that
|
||||
* catches root-absolute asset requests (`/static/app.js`) which `<base>` cannot fix.
|
||||
*/
|
||||
export function capabilityFromReferer(referer: string | undefined): string | null {
|
||||
if (!referer) return null;
|
||||
try {
|
||||
return capabilityFromProxyPath(new URL(referer).pathname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the `frame-ancestors` directive from a CSP, preserving the rest. */
|
||||
export function stripFrameAncestors(csp: string): string {
|
||||
return csp
|
||||
.split(';')
|
||||
.map((d) => d.trim())
|
||||
.filter((d) => d !== '' && !/^frame-ancestors\b/i.test(d))
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
/** The `frame-ancestors` directive value from a CSP, or undefined. */
|
||||
export function extractFrameAncestors(csp: string | undefined): string | undefined {
|
||||
if (!csp) return undefined;
|
||||
for (const directive of csp.split(';')) {
|
||||
const trimmed = directive.trim();
|
||||
if (/^frame-ancestors\b/i.test(trimmed)) {
|
||||
return trimmed.slice('frame-ancestors'.length).trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a target permits being framed by a different origin, judged from its
|
||||
* `X-Frame-Options` and CSP. Used only to recommend proxy vs direct mode in the
|
||||
* editor; the proxy path works either way.
|
||||
*/
|
||||
export function isFramableCrossOrigin(xFrameOptions: string | undefined, csp: string | undefined): boolean {
|
||||
const xfo = xFrameOptions?.trim().toLowerCase();
|
||||
if (xfo === 'deny' || xfo === 'sameorigin') return false;
|
||||
const ancestors = extractFrameAncestors(csp)?.toLowerCase();
|
||||
if (ancestors === undefined) return true;
|
||||
if (ancestors.includes("'none'")) return false;
|
||||
// 'self' alone means same-origin only, which a cross-origin embed is not.
|
||||
if (ancestors === "'self'") return false;
|
||||
return ancestors.includes('*') || ancestors.includes('http');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite an upstream `Location` into the proxy path.
|
||||
*
|
||||
* Same-origin redirects (relative or absolute) are remapped so the browser stays
|
||||
* inside the frame. Cross-origin redirects are returned unchanged rather than
|
||||
* proxied: relaying them would turn this into an open proxy for any host the
|
||||
* upstream chooses to name.
|
||||
*/
|
||||
export function rewriteLocation(location: string, requestUrl: URL, capability: string): string {
|
||||
let resolved: URL;
|
||||
try {
|
||||
resolved = new URL(location, requestUrl);
|
||||
} catch {
|
||||
return location;
|
||||
}
|
||||
if (resolved.origin !== requestUrl.origin) return location;
|
||||
const suffix = resolved.pathname.replace(/^\//, '');
|
||||
return `${proxyPrefixFor(capability)}${suffix}${resolved.search}${resolved.hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite an upstream `Set-Cookie` so it applies to the proxy path only.
|
||||
*
|
||||
* `Domain` is dropped (the cookie now belongs to Codeman's host), `Path` is
|
||||
* rebased onto the proxy prefix so two dashboards cannot collide on a shared
|
||||
* cookie name, and `Secure` is dropped when Codeman itself is serving plain HTTP
|
||||
* in dev, where a Secure cookie would simply be discarded.
|
||||
*/
|
||||
export function rewriteSetCookie(cookie: string, capability: string, secureContext: boolean): string {
|
||||
const parts = cookie.split(';');
|
||||
const out: string[] = [parts[0]];
|
||||
let sawPath = false;
|
||||
|
||||
for (const raw of parts.slice(1)) {
|
||||
const attr = raw.trim();
|
||||
const lower = attr.toLowerCase();
|
||||
if (lower.startsWith('domain=')) continue;
|
||||
if (lower === 'secure' && !secureContext) continue;
|
||||
if (lower.startsWith('path=')) {
|
||||
sawPath = true;
|
||||
const value = attr.slice('path='.length);
|
||||
const suffix = value.replace(/^\//, '');
|
||||
out.push(`Path=${proxyPrefixFor(capability)}${suffix}`);
|
||||
continue;
|
||||
}
|
||||
out.push(attr);
|
||||
}
|
||||
|
||||
if (!sawPath) out.push(`Path=${proxyPrefixFor(capability)}`);
|
||||
return out.join('; ');
|
||||
}
|
||||
|
||||
/** Drop named cookies from a `Cookie` request header, returning undefined if none remain. */
|
||||
export function filterCookieHeader(cookie: string | undefined, drop: string[]): string | undefined {
|
||||
if (!cookie) return undefined;
|
||||
const dropSet = new Set(drop.map((n) => n.toLowerCase()));
|
||||
const kept = cookie
|
||||
.split(';')
|
||||
.map((c) => c.trim())
|
||||
.filter((c) => c !== '' && !dropSet.has(c.slice(0, c.indexOf('=')).trim().toLowerCase()));
|
||||
return kept.length > 0 ? kept.join('; ') : undefined;
|
||||
}
|
||||
|
||||
/** Build the header set sent upstream, from the browser's request headers. */
|
||||
export function buildUpstreamRequestHeaders(
|
||||
incoming: Record<string, string | string[] | undefined>,
|
||||
upstream: URL,
|
||||
opts: { forwardCookies: boolean; sessionCookieName: string; refererPath?: string }
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
const lower = key.toLowerCase();
|
||||
if (DROP_REQUEST_HEADERS.has(lower)) continue;
|
||||
if (value === undefined) continue;
|
||||
headers[lower] = Array.isArray(value) ? value.join(', ') : value;
|
||||
}
|
||||
|
||||
if (opts.forwardCookies) {
|
||||
const raw = incoming['cookie'];
|
||||
const cookie = filterCookieHeader(Array.isArray(raw) ? raw.join('; ') : raw, [opts.sessionCookieName]);
|
||||
if (cookie) headers['cookie'] = cookie;
|
||||
}
|
||||
|
||||
// Present as if the browser were talking to the dashboard directly. Apps that
|
||||
// check Origin on writes (CSRF defenses) need this to match their own origin.
|
||||
headers['origin'] = upstream.origin;
|
||||
headers['referer'] = opts.refererPath ? new URL(opts.refererPath, upstream.origin).href : upstream.href;
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the response headers sent to the browser.
|
||||
*
|
||||
* Also returns the CSP to apply: the upstream's, minus `frame-ancestors`. Callers
|
||||
* MUST set (or explicitly clear) this, because `registerSecurityHeaders` has
|
||||
* already stamped Codeman's own `default-src 'self'` policy onto the reply, and
|
||||
* that policy would break virtually every dashboard.
|
||||
*/
|
||||
export function buildDownstreamResponseHeaders(
|
||||
upstreamHeaders: Iterable<[string, string]>,
|
||||
/**
|
||||
* Upstream `Set-Cookie` values, already separated. Passed in rather than read
|
||||
* from `upstreamHeaders` because iterating a `Headers` object JOINS duplicate
|
||||
* set-cookie values into one comma-separated string, which cannot be split back
|
||||
* apart reliably (Expires dates contain commas). Callers use
|
||||
* `response.headers.getSetCookie()`.
|
||||
*/
|
||||
setCookies: string[],
|
||||
capability: string,
|
||||
requestUrl: URL,
|
||||
secureContext: boolean
|
||||
): { headers: Record<string, string>; setCookie: string[]; csp: string | null } {
|
||||
const headers: Record<string, string> = {};
|
||||
let csp: string | null = null;
|
||||
|
||||
for (const [key, value] of upstreamHeaders) {
|
||||
const lower = key.toLowerCase();
|
||||
if (lower === 'content-security-policy') {
|
||||
const stripped = stripFrameAncestors(value);
|
||||
csp = stripped === '' ? null : stripped;
|
||||
continue;
|
||||
}
|
||||
if (lower === 'location') {
|
||||
headers['location'] = rewriteLocation(value, requestUrl, capability);
|
||||
continue;
|
||||
}
|
||||
if (DROP_RESPONSE_HEADERS.has(lower)) continue;
|
||||
headers[lower] = value;
|
||||
}
|
||||
|
||||
const setCookie = setCookies.map((cookie) => rewriteSetCookie(cookie, capability, secureContext));
|
||||
|
||||
return { headers, setCookie, csp };
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny script injected at the top of every proxied document, rewriting
|
||||
* ROOT-ABSOLUTE URLs built at runtime so they stay inside the proxy prefix.
|
||||
*
|
||||
* `<base href>` only governs URLs the HTML parser resolves. A dashboard that calls
|
||||
* `fetch('/api/data')` bypasses it entirely and the request lands on Codeman's own
|
||||
* root, where it 404s. That is not a rare shape: it is how most dashboards talk to
|
||||
* their own backend, and it presents as the dashboard's own "Failed to fetch".
|
||||
*
|
||||
* The `Referer`-keyed 404 fallback catches some of these, but it is a rescue rather
|
||||
* than a fix (it only fires for a request that already missed every Codeman route,
|
||||
* and only when the browser sends a usable `Referer`). Rewriting inside the iframe
|
||||
* removes the whole class instead: the page never emits a root-absolute request in
|
||||
* the first place.
|
||||
*
|
||||
* ## Why the DOM sinks are patched too, not just fetch/XHR
|
||||
*
|
||||
* A dashboard that renders `container.innerHTML = '<img src="/api/hero?slug=x">'`
|
||||
* or `img.src = '/api/slide?n=01'` produces exactly the same root-absolute request,
|
||||
* and NONE of the other layers can reach it: `<base>` does not apply to
|
||||
* root-absolute URLs at all, and `rewriteHtml()` only ever sees the initial
|
||||
* document, not markup built later by page script. The visible symptom is very
|
||||
* specific and easy to misread: the dashboard's DATA loads (reads go through
|
||||
* `fetch`, which was already patched) while every IMAGE stays broken. So the same
|
||||
* `rw()` is applied to `innerHTML`/`outerHTML`/`insertAdjacentHTML`, to
|
||||
* `setAttribute`, and to the `src`/`href`/`srcset`/... property setters, with a
|
||||
* `MutationObserver` as a last net for any sink not patched above (that one costs a
|
||||
* wasted 404 per node, since the browser starts fetching on insert, so it is a net
|
||||
* and not the mechanism).
|
||||
*
|
||||
* Runs before any page script because it is injected immediately after `<base>`.
|
||||
* Only same-origin, non-prefixed, root-absolute URLs are touched; relative URLs
|
||||
* (already handled by `<base>`) and cross-origin URLs are passed through. Every
|
||||
* rewrite is idempotent, so a value that passes through two layers is unchanged by
|
||||
* the second.
|
||||
*/
|
||||
export function runtimeUrlShim(prefix: string): string {
|
||||
// Kept dependency-free and defensive: it runs inside a page we do not control,
|
||||
// and a throw here would break the dashboard rather than fix it.
|
||||
return `<script>(function(){try{
|
||||
var P=${JSON.stringify(prefix)};
|
||||
function rw(u){
|
||||
try{
|
||||
if(u==null)return u;
|
||||
if(typeof u!=='string'){
|
||||
if(typeof URL!=='undefined'&&u instanceof URL)return rw(u.href);
|
||||
return u;
|
||||
}
|
||||
if(u.indexOf(P)===0)return u;
|
||||
if(u.charAt(0)==='/'&&u.charAt(1)!=='/')return P+u.slice(1);
|
||||
if(/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(u)||u.indexOf('//')===0){
|
||||
var a=new URL(u,location.href);
|
||||
if(a.host===location.host&&a.pathname.indexOf(P)!==0){
|
||||
a.pathname=P+a.pathname.replace(/^\\//,'');
|
||||
return a.href;
|
||||
}
|
||||
}
|
||||
return u;
|
||||
}catch(e){return u;}
|
||||
}
|
||||
var of=window.fetch;
|
||||
if(of)window.fetch=function(i,o){
|
||||
try{
|
||||
if(typeof Request!=='undefined'&&i instanceof Request)return of.call(this,new Request(rw(i.url),i),o);
|
||||
return of.call(this,rw(i),o);
|
||||
}catch(e){return of.call(this,i,o);}
|
||||
};
|
||||
if(window.XMLHttpRequest&&XMLHttpRequest.prototype.open){
|
||||
var oo=XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open=function(m,u){
|
||||
var a=[].slice.call(arguments);a[1]=rw(u);return oo.apply(this,a);
|
||||
};
|
||||
}
|
||||
['WebSocket','EventSource'].forEach(function(k){
|
||||
var C=window[k];if(!C)return;
|
||||
function W(u,p){return p===undefined?new C(rw(u)):new C(rw(u),p);}
|
||||
W.prototype=C.prototype;
|
||||
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(function(s){if(s in C)W[s]=C[s];});
|
||||
window[k]=W;
|
||||
});
|
||||
var A=['src','href','action','poster','data','formaction','srcset'];
|
||||
function rwSet(v){
|
||||
try{
|
||||
return String(v).split(',').map(function(p){
|
||||
var t=p.trim();if(!t)return t;
|
||||
var i=t.search(/\\s/);
|
||||
return i===-1?rw(t):rw(t.slice(0,i))+t.slice(i);
|
||||
}).join(', ');
|
||||
}catch(e){return v;}
|
||||
}
|
||||
function rwAttr(n,v){
|
||||
try{
|
||||
if(v==null)return v;
|
||||
var k=String(n).toLowerCase();
|
||||
if(k==='srcset')return rwSet(v);
|
||||
return A.indexOf(k)===-1?v:rw(v);
|
||||
}catch(e){return v;}
|
||||
}
|
||||
// CSS built at runtime is the one sink NO relay can rescue: a <style> element has
|
||||
// no URL of its own, so an opaque-origin document sends an EMPTY Referer with the
|
||||
// resulting image request, and the 404 fallback has nothing to key on.
|
||||
function rwCss(s){
|
||||
try{
|
||||
return String(s).replace(/url\\(\\s*(['"]?)(\\/(?!\\/)[^'")]*)\\1\\s*\\)/gi,function(m,q,u){return 'url('+q+rw(u)+q+')';});
|
||||
}catch(e){return s;}
|
||||
}
|
||||
// Each value goes through rw() rather than a blind prefix concat, because unlike
|
||||
// the server-side rewriteHtml() this runs on markup that may ALREADY be proxied
|
||||
// (a page re-injecting its own outerHTML), and rw() is the idempotent one.
|
||||
function rwHtml(s){
|
||||
try{
|
||||
if(typeof s!=='string')return s;
|
||||
return s
|
||||
.replace(/(\\s(?:src|href|action|poster|formaction|data)\\s*=\\s*")([^"]*)(")/gi,function(m,a,v,q){return a+rw(v)+q;})
|
||||
.replace(/(\\s(?:src|href|action|poster|formaction|data)\\s*=\\s*')([^']*)(')/gi,function(m,a,v,q){return a+rw(v)+q;})
|
||||
.replace(/(\\ssrcset\\s*=\\s*")([^"]*)(")/gi,function(m,a,v,q){return a+rwSet(v)+q;})
|
||||
.replace(/(\\ssrcset\\s*=\\s*')([^']*)(')/gi,function(m,a,v,q){return a+rwSet(v)+q;})
|
||||
.replace(/(<style\\b[^>]*>)([^]*?)(<\\/style>)/gi,function(m,a,b,c){return a+rwCss(b)+c;});
|
||||
}catch(e){return s;}
|
||||
}
|
||||
// Marked with __cmrw so a double injection (a page that re-runs the shim) cannot
|
||||
// wrap an already-wrapped setter and rewrite twice.
|
||||
function patchProp(C,prop,conv){
|
||||
try{
|
||||
if(!C||!C.prototype)return;
|
||||
var d=Object.getOwnPropertyDescriptor(C.prototype,prop);
|
||||
if(!d||!d.set||d.set.__cmrw)return;
|
||||
var s=d.set;
|
||||
var ns=function(v){var w=v;try{w=conv(v);}catch(e){}return s.call(this,w);};
|
||||
ns.__cmrw=1;
|
||||
Object.defineProperty(C.prototype,prop,{get:d.get,set:ns,configurable:true,enumerable:d.enumerable});
|
||||
}catch(e){}
|
||||
}
|
||||
function patchHtmlProp(O,prop){
|
||||
try{
|
||||
if(!O)return;
|
||||
var d=Object.getOwnPropertyDescriptor(O,prop);
|
||||
if(!d||!d.set||d.set.__cmrw)return;
|
||||
var s=d.set;
|
||||
var ns=function(v){return s.call(this,rwHtml(v));};
|
||||
ns.__cmrw=1;
|
||||
Object.defineProperty(O,prop,{get:d.get,set:ns,configurable:true,enumerable:d.enumerable});
|
||||
}catch(e){}
|
||||
}
|
||||
function patchFn(O,name,wrap){
|
||||
try{
|
||||
var f=O&&O[name];
|
||||
if(typeof f!=='function'||f.__cmrw)return;
|
||||
var nf=wrap(f);nf.__cmrw=1;O[name]=nf;
|
||||
}catch(e){}
|
||||
}
|
||||
[['HTMLImageElement','src'],['HTMLImageElement','srcset'],['HTMLSourceElement','src'],
|
||||
['HTMLSourceElement','srcset'],['HTMLMediaElement','src'],['HTMLVideoElement','poster'],
|
||||
['HTMLScriptElement','src'],['HTMLIFrameElement','src'],['HTMLEmbedElement','src'],
|
||||
['HTMLTrackElement','src'],['HTMLLinkElement','href'],['HTMLAnchorElement','href'],
|
||||
['HTMLAreaElement','href'],['HTMLObjectElement','data'],['HTMLFormElement','action']
|
||||
].forEach(function(p){patchProp(window[p[0]],p[1],p[1]==='srcset'?rwSet:rw);});
|
||||
var EP=window.Element&&window.Element.prototype;
|
||||
patchHtmlProp(EP,'innerHTML');
|
||||
patchHtmlProp(EP,'outerHTML');
|
||||
patchHtmlProp(window.ShadowRoot&&window.ShadowRoot.prototype,'innerHTML');
|
||||
patchFn(EP,'insertAdjacentHTML',function(f){return function(p,h){return f.call(this,p,rwHtml(h));};});
|
||||
patchFn(EP,'setAttribute',function(f){return function(n,v){return f.call(this,n,rwAttr(n,v));};});
|
||||
patchFn(EP,'setAttributeNS',function(f){return function(ns,n,v){
|
||||
var k=String(n==null?'':n),i=k.indexOf(':');
|
||||
return f.call(this,ns,n,rwAttr(i===-1?k:k.slice(i+1),v));
|
||||
};});
|
||||
// Last net: anything inserted by a sink not patched above still gets corrected.
|
||||
// setAttribute below is the patched one, so this stays idempotent and terminates.
|
||||
try{
|
||||
var doc=window.document,MO=window.MutationObserver;
|
||||
if(MO&&doc&&doc.documentElement){
|
||||
var fix=function(el){
|
||||
try{
|
||||
if(!el||el.nodeType!==1||!el.hasAttribute)return;
|
||||
for(var i=0;i<A.length;i++){
|
||||
var n=A[i];if(!el.hasAttribute(n))continue;
|
||||
var c=el.getAttribute(n),x=rwAttr(n,c);
|
||||
if(x!=null&&x!==c)el.setAttribute(n,x);
|
||||
}
|
||||
}catch(e){}
|
||||
};
|
||||
var fixStyle=function(el){
|
||||
try{
|
||||
if(!el||el.tagName!=='STYLE')return;
|
||||
var t=el.textContent;
|
||||
if(!t||t.indexOf('url(')===-1)return;
|
||||
var n=rwCss(t);
|
||||
if(n!==t)el.textContent=n;
|
||||
}catch(e){}
|
||||
};
|
||||
var scan=function(node){
|
||||
try{
|
||||
fix(node);fixStyle(node);
|
||||
if(node&&node.querySelectorAll){
|
||||
var l=node.querySelectorAll('[src],[href],[action],[poster],[data],[srcset],[formaction]');
|
||||
for(var i=0;i<l.length;i++)fix(l[i]);
|
||||
var st=node.querySelectorAll('style');
|
||||
for(var j=0;j<st.length;j++)fixStyle(st[j]);
|
||||
}
|
||||
}catch(e){}
|
||||
};
|
||||
new MO(function(ms){
|
||||
for(var i=0;i<ms.length;i++){
|
||||
var m=ms[i];
|
||||
if(m.type==='attributes')fix(m.target);
|
||||
else for(var j=0;j<m.addedNodes.length;j++)scan(m.addedNodes[j]);
|
||||
}
|
||||
}).observe(doc.documentElement,{subtree:true,childList:true,attributes:true,attributeFilter:A});
|
||||
}
|
||||
}catch(e){}
|
||||
}catch(e){}})();</script>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject `<base href="/webview/<cap>/">` plus the runtime URL shim, and rebase
|
||||
* root-absolute `src`/`href`/`action` attributes, which `<base>` alone does not
|
||||
* affect.
|
||||
*
|
||||
* Three layers, because no single one is sufficient: `<base>` for parser-resolved
|
||||
* relative URLs, attribute rewriting for root-absolute markup, and the shim for
|
||||
* URLs built at runtime.
|
||||
*/
|
||||
export function rewriteHtml(html: string, capability: string): string {
|
||||
const prefix = proxyPrefixFor(capability);
|
||||
|
||||
// Fresh regexes per call: module-level /g patterns carry `lastIndex` between calls.
|
||||
const rebased = html
|
||||
.replace(/(\s(?:src|href|action)\s*=\s*")\/(?!\/)/gi, `$1${prefix}`)
|
||||
.replace(/(\s(?:src|href|action)\s*=\s*')\/(?!\/)/gi, `$1${prefix}`);
|
||||
|
||||
// A page that ships its own <base> keeps it (overriding it would break the
|
||||
// author's intent), but it STILL needs the shim, which is the layer that
|
||||
// catches runtime-built URLs. So only the base tag is conditional.
|
||||
const injected = (/<base\b/i.test(rebased) ? '' : `<base href="${prefix}">`) + runtimeUrlShim(prefix);
|
||||
|
||||
const headMatch = /<head\b[^>]*>/i.exec(rebased);
|
||||
if (headMatch) {
|
||||
const at = headMatch.index + headMatch[0].length;
|
||||
return rebased.slice(0, at) + injected + rebased.slice(at);
|
||||
}
|
||||
const htmlMatch = /<html\b[^>]*>/i.exec(rebased);
|
||||
if (htmlMatch) {
|
||||
const at = htmlMatch.index + htmlMatch[0].length;
|
||||
return rebased.slice(0, at) + injected + rebased.slice(at);
|
||||
}
|
||||
return injected + rebased;
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS headers for a proxied response.
|
||||
*
|
||||
* Non-obvious but load-bearing: a SANDBOXED iframe (no `allow-same-origin`) runs
|
||||
* in an OPAQUE origin, so every `fetch`/XHR it makes is a cross-origin request even
|
||||
* though the URL is on this very host, and the browser requires CORS headers to
|
||||
* hand back the response. Without this, a dashboard renders fine (script/css/img
|
||||
* loads are not CORS-checked) while every one of its API calls fails with an opaque
|
||||
* `net::ERR_FAILED` and the page shows its own "failed to load" state. `curl`
|
||||
* cannot reproduce it, because curl does not enforce CORS.
|
||||
*
|
||||
* The origin is echoed rather than `*` so credentialed requests still work in
|
||||
* trusted mode. `null` (the opaque-origin case) is echoed as-is, but WITHOUT
|
||||
* `allow-credentials`, which browsers reject in combination.
|
||||
*
|
||||
* This grants nothing extra: the URL is already gated by the capability, and only
|
||||
* a document that was handed the capability can construct these requests.
|
||||
*/
|
||||
export function buildProxyCorsHeaders(origin: string | undefined, requestedHeaders?: string): Record<string, string> {
|
||||
if (!origin) return {};
|
||||
const headers: Record<string, string> = {
|
||||
'access-control-allow-origin': origin,
|
||||
'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS',
|
||||
'access-control-allow-headers': requestedHeaders && requestedHeaders.trim() !== '' ? requestedHeaders : '*',
|
||||
'access-control-expose-headers': '*',
|
||||
'access-control-max-age': '600',
|
||||
vary: 'Origin',
|
||||
};
|
||||
// `Access-Control-Allow-Credentials: true` alongside a `null` origin is rejected
|
||||
// by browsers; a sandboxed frame sends no credentials anyway.
|
||||
if (origin !== 'null' && origin !== '*') headers['access-control-allow-credentials'] = 'true';
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** Whether a content-type identifies HTML worth rewriting. */
|
||||
export function isHtmlContentType(contentType: string | undefined): boolean {
|
||||
if (!contentType) return false;
|
||||
const type = contentType.split(';')[0].trim().toLowerCase();
|
||||
return type === 'text/html' || type === 'application/xhtml+xml';
|
||||
}
|
||||
|
||||
/** Map an upstream http(s) URL to its ws(s) equivalent for the WebSocket leg. */
|
||||
export function upstreamWebSocketUrl(target: URL): string {
|
||||
const ws = new URL(target.href);
|
||||
ws.protocol = ws.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return ws.href;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @fileoverview Capability tokens for the web-tab proxy.
|
||||
*
|
||||
* The proxy cannot authenticate on Codeman's session cookie. A sandboxed iframe
|
||||
* (no `allow-same-origin`) runs in an OPAQUE origin, so every request it makes is
|
||||
* cross-site: the `SameSite=lax` `codeman_session` cookie is not sent, and its
|
||||
* non-GET requests and WebSocket upgrades arrive with `Origin: null`, which the
|
||||
* host guard rejects by design.
|
||||
*
|
||||
* So `/webview/:cap/*` authenticates on an unguessable capability minted by an
|
||||
* already-authenticated `POST /api/webviews/:id/open`. Properties that make this
|
||||
* safe to exempt from the cookie/Origin checks:
|
||||
*
|
||||
* - 128 bits of `randomBytes` entropy, base64url, never derived from anything.
|
||||
* - Held in memory only. A restart invalidates every outstanding capability.
|
||||
* - Rolling TTL: refreshed on use, expired after inactivity.
|
||||
* - Bound to the minting user, so multi-user ownership survives the exemption.
|
||||
* - Grants exactly one thing: relaying bytes to that one saved URL. It reaches no
|
||||
* session, no file, no API surface.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { StaleExpirationMap } from './utils/index.js';
|
||||
import { MAX_WEBVIEW_CAPABILITIES, WEBVIEW_CAPABILITY_TTL_MS } from './config/webview-limits.js';
|
||||
|
||||
export interface WebviewCapabilityRecord {
|
||||
webviewId: string;
|
||||
/** Username that minted it (multi-user); undefined in single-user mode. */
|
||||
owner?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export class WebviewCapabilityStore {
|
||||
private readonly capabilities: StaleExpirationMap<string, WebviewCapabilityRecord>;
|
||||
/** Reverse index so re-opening a webview reuses its capability instead of leaking one per click. */
|
||||
private readonly byWebview = new Map<string, string>();
|
||||
|
||||
constructor(ttlMs: number = WEBVIEW_CAPABILITY_TTL_MS) {
|
||||
this.capabilities = new StaleExpirationMap<string, WebviewCapabilityRecord>({
|
||||
ttlMs,
|
||||
refreshOnGet: true,
|
||||
onExpire: (_token, record) => {
|
||||
const current = this.byWebview.get(record.webviewId);
|
||||
if (current !== undefined) this.byWebview.delete(record.webviewId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Mint (or reuse) a capability for a webview. Returns the token. */
|
||||
mint(webviewId: string, owner?: string): string {
|
||||
const existing = this.byWebview.get(webviewId);
|
||||
if (existing) {
|
||||
const record = this.capabilities.get(existing);
|
||||
// Reuse only while the record is live AND still belongs to the same identity.
|
||||
if (record && record.owner === owner) return existing;
|
||||
this.capabilities.delete(existing);
|
||||
this.byWebview.delete(webviewId);
|
||||
}
|
||||
|
||||
// Bound growth: a client that never reuses tokens must not grow this forever.
|
||||
if (this.capabilities.size >= MAX_WEBVIEW_CAPABILITIES) this.capabilities.cleanup();
|
||||
|
||||
const token = randomBytes(24).toString('base64url');
|
||||
this.capabilities.set(token, { webviewId, owner, createdAt: Date.now() });
|
||||
this.byWebview.set(webviewId, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Resolve a capability, refreshing its TTL. Returns undefined when unknown or expired. */
|
||||
resolve(token: string): WebviewCapabilityRecord | undefined {
|
||||
if (!token) return undefined;
|
||||
return this.capabilities.get(token);
|
||||
}
|
||||
|
||||
/** Revoke every capability for a webview (called on delete/edit). */
|
||||
revokeWebview(webviewId: string): void {
|
||||
const token = this.byWebview.get(webviewId);
|
||||
if (token) {
|
||||
this.capabilities.delete(token);
|
||||
this.byWebview.delete(webviewId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Revoke every capability minted by a user (called on logout / user deletion). */
|
||||
revokeOwner(owner: string): void {
|
||||
for (const [webviewId, token] of [...this.byWebview]) {
|
||||
const record = this.capabilities.peek(token);
|
||||
if (record?.owner === owner) {
|
||||
this.capabilities.delete(token);
|
||||
this.byWebview.delete(webviewId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.capabilities.size;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.capabilities.dispose();
|
||||
this.byWebview.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-wide capability store.
|
||||
*
|
||||
* A singleton rather than an injected dependency because two unrelated layers must
|
||||
* agree on it: the proxy routes that mint and consume capabilities, and the auth
|
||||
* middleware, which has to recognize a valid capability to know that a
|
||||
* `/webview/...` request is legitimately exempt from the cookie and Origin checks.
|
||||
* Threading a store through the auth middleware's construction just to answer that
|
||||
* one question would be worse. The map's cleanup timer is `unref`'d, so holding
|
||||
* this at module scope does not keep the process alive.
|
||||
*/
|
||||
export const webviewCapabilities = new WebviewCapabilityStore();
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @fileoverview Persistence for web tabs (saved dashboard URLs).
|
||||
*
|
||||
* Stores `Webview` records in `~/.codeman/webviews.json`, following the same
|
||||
* read-array / write-array shape as `src/remote-hosts.ts`. Deliberately dumb: no
|
||||
* caching, no watchers. The list is small (bounded by MAX_WEBVIEWS) and is read
|
||||
* on demand by the route handlers.
|
||||
*
|
||||
* The file lives under the instance data dir, so a beta instance started with a
|
||||
* distinct CODEMAN_INSTANCE keeps its own dashboards.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { Webview } from './types.js';
|
||||
|
||||
const WEBVIEWS_FILE = 'webviews.json';
|
||||
|
||||
export function webviewsPath(configDir: string): string {
|
||||
return join(configDir, WEBVIEWS_FILE);
|
||||
}
|
||||
|
||||
export async function readWebviews(configDir: string): Promise<Webview[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(webviewsPath(configDir), 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as Webview[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeWebviews(configDir: string, webviews: Webview[]): Promise<void> {
|
||||
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
||||
await fs.writeFile(webviewsPath(configDir), JSON.stringify(webviews, null, 2));
|
||||
}
|
||||
@@ -145,3 +145,58 @@ describe('admin API', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin case-folder API', () => {
|
||||
const kim = { Authorization: basic('kim', 'kimpass1234') };
|
||||
|
||||
it('lists a user case folders (admin only, hidden dirs excluded)', async () => {
|
||||
// Fresh regular user with a known password (joe's was reset above).
|
||||
const created = await fetch(url('/api/admin/users'), {
|
||||
method: 'POST',
|
||||
headers: admin,
|
||||
body: JSON.stringify({ username: 'kim', role: 'user', password: 'kimpass1234' }),
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
await fs.mkdir(path.join(spacesDir, 'kim', 'cases', 'proj1'), { recursive: true });
|
||||
await fs.mkdir(path.join(spacesDir, 'kim', 'cases', '.hidden'), { recursive: true });
|
||||
|
||||
const forbidden = await fetch(url('/api/admin/users/kim/cases'), { headers: kim });
|
||||
expect(forbidden.status).toBe(403);
|
||||
|
||||
const res = await fetch(url('/api/admin/users/kim/cases'), { headers: adminNoBody });
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await res.json();
|
||||
expect(data.cases.map((c: { name: string }) => c.name)).toEqual(['proj1']);
|
||||
expect(data.cases[0].liveSessions).toBe(0);
|
||||
});
|
||||
|
||||
it('404s for an unknown user', async () => {
|
||||
const res = await fetch(url('/api/admin/users/ghost/cases'), { headers: adminNoBody });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('deletes a case folder, refusing unsafe names and symlinks', async () => {
|
||||
// Traversal-shaped name: rejected before any filesystem access.
|
||||
const bad = await fetch(url('/api/admin/users/kim/cases/..%2Fescape'), {
|
||||
method: 'DELETE',
|
||||
headers: adminNoBody,
|
||||
});
|
||||
expect([400, 404]).toContain(bad.status);
|
||||
|
||||
// A symlinked "case" is refused, never followed.
|
||||
await fs.mkdir(path.join(spacesDir, 'outside'), { recursive: true });
|
||||
await fs.symlink(path.join(spacesDir, 'outside'), path.join(spacesDir, 'kim', 'cases', 'link'));
|
||||
const sl = await fetch(url('/api/admin/users/kim/cases/link'), { method: 'DELETE', headers: adminNoBody });
|
||||
expect(sl.status).toBe(400);
|
||||
await expect(fs.stat(path.join(spacesDir, 'outside'))).resolves.toBeTruthy();
|
||||
|
||||
// A real folder is deleted.
|
||||
const del = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody });
|
||||
expect(del.status).toBe(200);
|
||||
await expect(fs.stat(path.join(spacesDir, 'kim', 'cases', 'proj1'))).rejects.toBeTruthy();
|
||||
|
||||
// Deleting it again 404s.
|
||||
const gone = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody });
|
||||
expect(gone.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ function resp(status: number, body: unknown) {
|
||||
async function bootWith(me: Record<string, unknown>) {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><body>
|
||||
<button id="adminPanelBtn" class="btn-admin-panel btn-admin-panel--hidden"></button>
|
||||
<div class="modal" id="appSettingsModal"><div class="modal-tabs"></div><div class="modal-body"></div></div>
|
||||
</body>`,
|
||||
{ url: 'http://localhost/', runScripts: 'outside-only' }
|
||||
@@ -70,6 +71,65 @@ describe('admin-ui boot', () => {
|
||||
// Forced: the cancel button is hidden.
|
||||
expect((modal!.querySelector('#cpCancel') as HTMLElement).style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('reveals the header Admin Panel button for a multi-user admin only', async () => {
|
||||
const hidden = (w: Window) =>
|
||||
w.document.getElementById('adminPanelBtn')!.classList.contains('btn-admin-panel--hidden');
|
||||
const a = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false });
|
||||
expect(hidden(a.win)).toBe(false);
|
||||
const b = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
|
||||
expect(hidden(b.win)).toBe(true);
|
||||
const c = await bootWith({ username: 'admin', role: 'admin', multiUser: false, mustChangePassword: false });
|
||||
expect(hidden(c.win)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin panel modal', () => {
|
||||
it('opens for an admin, renders users, and shows the case-folder drawer', async () => {
|
||||
const { win } = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false });
|
||||
win.fetch = (async (path: string) => {
|
||||
if (path === '/api/admin/users')
|
||||
return resp(200, {
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
username: 'root',
|
||||
role: 'admin',
|
||||
disabled: false,
|
||||
mustChangePassword: false,
|
||||
canBypassPermissions: true,
|
||||
createdAt: 1,
|
||||
lastLoginAt: 2,
|
||||
stats: { liveSessions: 1, activeSessions: 2, caseCount: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === '/api/admin/users/root/cases')
|
||||
return resp(200, {
|
||||
success: true,
|
||||
data: { dir: '/tmp/spaces/root/cases', cases: [{ name: 'proj1', modifiedAt: 3, liveSessions: 0 }] },
|
||||
});
|
||||
return resp(200, { success: true });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
(win as unknown as { codemanAdmin: { openAdminPanel: () => void } }).codemanAdmin.openAdminPanel();
|
||||
for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
const modal = win.document.getElementById('adminPanelModal') as HTMLElement;
|
||||
expect(modal).toBeTruthy();
|
||||
expect(modal.style.display).toBe('flex');
|
||||
expect(modal.querySelector('#apTable')!.textContent).toContain('root');
|
||||
|
||||
(modal.querySelector('button[data-act="cases"]') as HTMLButtonElement).click();
|
||||
for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
expect(modal.textContent).toContain('proj1');
|
||||
expect(modal.textContent).toContain('/tmp/spaces/root/cases');
|
||||
});
|
||||
|
||||
it('does NOT open for a regular user', async () => {
|
||||
const { win } = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
|
||||
(win as unknown as { codemanAdmin: { openAdminPanel: () => void } }).codemanAdmin.openAdminPanel();
|
||||
expect(win.document.getElementById('adminPanelModal')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('index.html wiring', () => {
|
||||
@@ -80,4 +140,9 @@ describe('index.html wiring', () => {
|
||||
expect(admin).toBeGreaterThan(settings);
|
||||
expect(session).toBeGreaterThan(admin);
|
||||
});
|
||||
|
||||
it('ships the header Admin Panel button hidden by default', () => {
|
||||
expect(INDEX_HTML).toContain('id="adminPanelBtn"');
|
||||
expect(INDEX_HTML).toContain('btn-admin-panel--hidden');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Regression coverage for the browser i18n layer and custom display-name setting.
|
||||
* Port: N/A (JSDOM + direct schema/server render calls only).
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const i18nSource = readFileSync(new URL('../src/web/public/i18n.js', import.meta.url), 'utf8');
|
||||
const indexSource = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf8');
|
||||
const settingsSource = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf8');
|
||||
|
||||
function makeDom(body = '<span class="logo">Codeman</span><button title="App Settings">App Settings</button>') {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><html><head><title>codeman:test-host</title></head><body>${body}</body></html>`,
|
||||
{
|
||||
runScripts: 'outside-only',
|
||||
url: 'http://localhost/',
|
||||
}
|
||||
);
|
||||
vm.runInContext(i18nSource, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
return dom;
|
||||
}
|
||||
|
||||
describe('custom display name and browser localization', () => {
|
||||
const previousDataDir = process.env.CODEMAN_DATA_DIR;
|
||||
|
||||
afterEach(() => {
|
||||
if (previousDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
|
||||
else process.env.CODEMAN_DATA_DIR = previousDataDir;
|
||||
});
|
||||
|
||||
it('accepts bounded Chinese display names and rejects invalid values', () => {
|
||||
expect(SettingsUpdateSchema.safeParse({ displayName: '小码助手' }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({ displayName: ' 小码助手 ' }).data).toMatchObject({
|
||||
displayName: '小码助手',
|
||||
});
|
||||
expect(SettingsUpdateSchema.safeParse({ displayName: '' }).success).toBe(false);
|
||||
expect(SettingsUpdateSchema.safeParse({ displayName: '名'.repeat(41) }).success).toBe(false);
|
||||
expect(SettingsUpdateSchema.safeParse({ displayName: 'unsafe\nname' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('translates static and dynamic UI, restores English, and leaves terminal/user content untouched', async () => {
|
||||
const dom = makeDom(`
|
||||
<span class="logo">Codeman</span>
|
||||
<button id="settings" title="App Settings">App Settings</button>
|
||||
<div id="user" class="session-tab-name">Home</div>
|
||||
<div class="xterm">Run</div>
|
||||
<div class="history-item" title="Run">
|
||||
<span class="history-item-title">Run</span>
|
||||
<span class="history-item-subtitle">Home</span>
|
||||
<span class="history-detail-prompt">Settings saved</span>
|
||||
<span class="history-detail-path">Home</span>
|
||||
</div>
|
||||
<div id="dynamic"></div>
|
||||
`);
|
||||
const { window } = dom;
|
||||
const api = window.CodemanI18n;
|
||||
api.start();
|
||||
api.configure({ language: 'zh-CN', displayName: '小码助手' });
|
||||
|
||||
expect(window.document.querySelector('.logo')?.textContent).toBe('小码助手');
|
||||
expect(window.document.getElementById('settings')?.textContent).toBe('应用设置');
|
||||
expect(window.document.getElementById('settings')?.getAttribute('title')).toBe('应用设置');
|
||||
expect(window.document.getElementById('user')?.textContent).toBe('Home');
|
||||
expect(window.document.querySelector('.xterm')?.textContent).toBe('Run');
|
||||
expect(window.document.querySelector('.history-item-title')?.textContent).toBe('Run');
|
||||
expect(window.document.querySelector('.history-detail-prompt')?.textContent).toBe('Settings saved');
|
||||
expect(window.document.querySelector('.history-item')?.getAttribute('title')).toBe('Run');
|
||||
expect(window.document.title).toBe('小码助手:test-host');
|
||||
|
||||
const dynamicButton = window.document.createElement('button');
|
||||
dynamicButton.textContent = 'Settings saved';
|
||||
dynamicButton.title = 'Open Codeman across all displays';
|
||||
window.document.getElementById('dynamic')?.appendChild(dynamicButton);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
expect(dynamicButton.textContent).toBe('设置已保存');
|
||||
expect(dynamicButton.title).toBe('在所有显示器上打开 小码助手');
|
||||
|
||||
api.configure({ language: 'en', displayName: 'Workbench' });
|
||||
expect(window.document.getElementById('settings')?.textContent).toBe('App Settings');
|
||||
expect(window.document.querySelector('.logo')?.textContent).toBe('Workbench');
|
||||
expect(dynamicButton.textContent).toBe('Settings saved');
|
||||
expect(window.document.title).toBe('Workbench:test-host');
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
it('renders hostile-looking names as text rather than HTML', () => {
|
||||
const dom = makeDom('<span class="logo">Codeman</span>');
|
||||
const api = dom.window.CodemanI18n;
|
||||
api.start();
|
||||
api.configure({ displayName: '<img src=x onerror=alert(1)>', language: 'en' });
|
||||
const logo = dom.window.document.querySelector('.logo');
|
||||
expect(logo?.textContent).toBe('<img src=x onerror=alert(1)>');
|
||||
expect(logo?.querySelector('img')).toBeNull();
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
it('wires the two settings with language kept per-device and display name synced', () => {
|
||||
expect(indexSource).toContain('id="appSettingsDisplayName"');
|
||||
expect(indexSource).toContain('id="appSettingsLanguage"');
|
||||
expect(indexSource).toContain('<option value="zh-CN">简体中文</option>');
|
||||
expect(settingsSource).toMatch(/language:\s*_language/);
|
||||
expect(settingsSource).toContain("'language',");
|
||||
expect(settingsSource).not.toMatch(/displayName:\s*_displayName/);
|
||||
});
|
||||
|
||||
it('uses the escaped custom name in the server-rendered window title', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'codeman-brand-title-'));
|
||||
process.env.CODEMAN_DATA_DIR = dataDir;
|
||||
writeFileSync(join(dataDir, 'settings.json'), JSON.stringify({ displayName: '小码<&助手' }));
|
||||
const server = new WebServer(0, false, true, '127.0.0.1', 'laptop');
|
||||
const html = await (server as unknown as { renderIndexHtml: () => Promise<string> }).renderIndexHtml();
|
||||
expect(html).toContain('<title>小码<&助手:laptop</title>');
|
||||
expect(html).not.toContain('<title>小码<&助手:laptop</title>');
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,61 @@ describe('terminal link-provider regexes (shipped source)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('urlPattern keeps query strings whole (a single & is part of the URL)', () => {
|
||||
// Excluding `&` truncated every real query string: a WordPress edit link
|
||||
// resolved to `?post=1479` and opened the wrong page, and Claude Code's OAuth
|
||||
// login URL (many `&` params) was not clickable at all.
|
||||
const url = shippedPattern('urlPattern');
|
||||
const strip = (u: string) => u.replace(/[.,;:!?)&]+$/, '');
|
||||
const cases: Array<[string, string]> = [
|
||||
[
|
||||
'updated in place: https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit',
|
||||
'https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit',
|
||||
],
|
||||
[
|
||||
'open https://claude.ai/oauth/authorize?code=true&client_id=abc&scope=user%3Ainference&state=xyz',
|
||||
'https://claude.ai/oauth/authorize?code=true&client_id=abc&scope=user%3Ainference&state=xyz',
|
||||
],
|
||||
['see https://x.com/a?b=1&c=2&d=3 ok', 'https://x.com/a?b=1&c=2&d=3'],
|
||||
// A lone trailing & is punctuation, not part of the target.
|
||||
['trailing https://x.com/a?b=1& next', 'https://x.com/a?b=1'],
|
||||
];
|
||||
for (const [line, want] of cases) {
|
||||
url.lastIndex = 0;
|
||||
const m = url.exec(line);
|
||||
expect(m, line).not.toBeNull();
|
||||
expect(strip(m![0]), line).toBe(want);
|
||||
}
|
||||
});
|
||||
|
||||
it('urlPattern still stops at the shell && operator', () => {
|
||||
// `&&` never appears inside a URL, so it must remain a boundary or a link
|
||||
// would swallow the next command.
|
||||
const url = shippedPattern('urlPattern');
|
||||
for (const line of ['curl https://x.com/api && echo done', 'curl https://x.com/api&&echo done']) {
|
||||
url.lastIndex = 0;
|
||||
expect(url.exec(line)![0], line).toBe('https://x.com/api');
|
||||
}
|
||||
});
|
||||
|
||||
it('extPattern links pasted image/PDF attachment paths', () => {
|
||||
// `.claude-images/paste-*.png` is what Codeman writes for a pasted screenshot;
|
||||
// without image extensions the path rendered as plain, unclickable text.
|
||||
const ext = shippedPattern('extPattern');
|
||||
const cases = [
|
||||
'/home/arkon/default/claudeman/.claude-images/paste-1785164958410-d11eb7d0.png',
|
||||
'/tmp/shot.jpeg',
|
||||
'/opt/app/report.pdf',
|
||||
'/home/a/diagram.svg',
|
||||
];
|
||||
for (const path of cases) {
|
||||
ext.lastIndex = 0;
|
||||
const m = ext.exec(`see ${path} here`);
|
||||
expect(m, path).not.toBeNull();
|
||||
expect(m![1], path).toBe(path);
|
||||
}
|
||||
});
|
||||
|
||||
it('cmdPattern arg group cannot match empty tokens (the exponential trigger)', () => {
|
||||
// structural guard: the dangerous construct is an empty-matchable token
|
||||
// inside a repeated group — `[^\s\/]*\s+` repeated. Check the pattern
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
Comprehensive mobile UI testing for Codeman's web interface using Playwright with dual-engine support (Chromium + WebKit).
|
||||
|
||||
**325 tests across 135 devices — all passing.**
|
||||
**326 tests across 136 devices — all passing.**
|
||||
|
||||
## Purpose
|
||||
|
||||
Validates Codeman's mobile UI across 135 devices, covering:
|
||||
Validates Codeman's mobile UI across 136 devices, covering:
|
||||
|
||||
- **Keyboard simulation** — 3-layer approach to emulate virtual keyboards in headless browsers
|
||||
- **Touch/swipe interactions** — CDP trusted events (Chromium) + synthetic fallback (WebKit)
|
||||
@@ -26,7 +26,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/keyboard.test.t
|
||||
# Quick mode — 6 representative devices, skip full matrix
|
||||
CI_QUICK=1 npx vitest run --config test/mobile/vitest.config.ts
|
||||
|
||||
# Full device matrix only (135 devices)
|
||||
# Full device matrix only (136 devices)
|
||||
npx vitest run --config test/mobile/vitest.config.ts test/mobile/device-matrix.test.ts
|
||||
|
||||
# Update visual baselines (delete old baselines, re-run)
|
||||
@@ -43,7 +43,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regressi
|
||||
| `subagent-windows.test.ts` | 3202 | Mobile subagent card dimensions, stacking, interactions |
|
||||
| `settings.test.ts` | 3203 | Settings modal, mobile defaults, persistence |
|
||||
| `layout.test.ts` | 3204 | General mobile layout, fixed elements, device classes |
|
||||
| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (135 devices) |
|
||||
| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (136 devices) |
|
||||
| `visual-regression.test.ts` | 3206 | Screenshot comparison at key breakpoints |
|
||||
| `accessibility.test.ts` | 3207 | WCAG touch targets, zoom, focus, ARIA |
|
||||
|
||||
@@ -58,7 +58,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regressi
|
||||
| standard-tablet | 768–834px | ~8 | iPad Mini |
|
||||
| large-tablet | 835px+ | ~5 | iPad Pro 11" |
|
||||
|
||||
135 devices are defined in `devices.ts` — 68 from Playwright's built-in device profiles plus 67 custom entries for newer devices (iPhone 16/17, Pixel 9, Galaxy S25, iPad Air M2, Surface Pro, etc.).
|
||||
136 devices are defined in `devices.ts` — 68 from Playwright's built-in device profiles plus 68 custom entries for newer devices (iPhone 16/17, Pixel 9, Galaxy S25, OPPO Find N5 unfolded, iPad Air M2, Surface Pro, etc.).
|
||||
|
||||
### How Devices Are Differentiated
|
||||
|
||||
@@ -98,7 +98,7 @@ Test File
|
||||
├─ helpers/touch-sim.ts → CDP trusted touch / synthetic fallback
|
||||
├─ helpers/assertions.ts → Layout, CSS, accessibility assertions
|
||||
├─ helpers/visual.ts → pixelmatch screenshot comparison
|
||||
└─ devices.ts → 135-device registry
|
||||
└─ devices.ts → 136-device registry
|
||||
```
|
||||
|
||||
### Keyboard Simulation — 3-Layer Approach
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { devices as playwrightDevices } from 'playwright';
|
||||
|
||||
export type DeviceCategory = 'small-phone' | 'standard-phone' | 'large-phone' | 'small-tablet' | 'standard-tablet' | 'large-tablet';
|
||||
export type DeviceCategory =
|
||||
| 'small-phone'
|
||||
| 'standard-phone'
|
||||
| 'large-phone'
|
||||
| 'small-tablet'
|
||||
| 'standard-tablet'
|
||||
| 'large-tablet';
|
||||
|
||||
export interface DeviceEntry {
|
||||
name: string;
|
||||
@@ -55,14 +61,7 @@ function fromPlaywright(name: string): DeviceEntry | null {
|
||||
}
|
||||
|
||||
/** Create a custom DeviceEntry for devices not in Playwright. */
|
||||
function custom(
|
||||
name: string,
|
||||
width: number,
|
||||
height: number,
|
||||
dpr: number,
|
||||
ua: string,
|
||||
isIOS: boolean,
|
||||
): DeviceEntry {
|
||||
function custom(name: string, width: number, height: number, dpr: number, ua: string, isIOS: boolean): DeviceEntry {
|
||||
return {
|
||||
name,
|
||||
category: categoryFor(width),
|
||||
@@ -83,89 +82,89 @@ function custom(
|
||||
|
||||
const PLAYWRIGHT_DEVICE_NAMES = [
|
||||
// Small phones (<375px)
|
||||
'iPhone SE', // 320x568
|
||||
'Galaxy S9+', // 320x658
|
||||
'Nokia Lumia 520', // 320x533
|
||||
'Galaxy S III', // 360x640
|
||||
'Galaxy Note 3', // 360x640
|
||||
'Galaxy Note II', // 360x640
|
||||
'Galaxy S5', // 360x640
|
||||
'Galaxy S8', // 360x740
|
||||
'Galaxy S24', // 360x780
|
||||
'BlackBerry Z30', // 360x640
|
||||
'iPhone SE', // 320x568
|
||||
'Galaxy S9+', // 320x658
|
||||
'Nokia Lumia 520', // 320x533
|
||||
'Galaxy S III', // 360x640
|
||||
'Galaxy Note 3', // 360x640
|
||||
'Galaxy Note II', // 360x640
|
||||
'Galaxy S5', // 360x640
|
||||
'Galaxy S8', // 360x740
|
||||
'Galaxy S24', // 360x780
|
||||
'BlackBerry Z30', // 360x640
|
||||
'Microsoft Lumia 550', // 360x640
|
||||
'Microsoft Lumia 950', // 360x640
|
||||
'Nexus 5', // 360x640
|
||||
'Moto G4', // 360x640
|
||||
'Pixel 4', // 353x745
|
||||
'Nexus 5', // 360x640
|
||||
'Moto G4', // 360x640
|
||||
'Pixel 4', // 353x745
|
||||
|
||||
// Standard phones (375-429px)
|
||||
'iPhone 6', // 375x667
|
||||
'iPhone 7', // 375x667
|
||||
'iPhone 8', // 375x667
|
||||
'iPhone 6', // 375x667
|
||||
'iPhone 7', // 375x667
|
||||
'iPhone 8', // 375x667
|
||||
'iPhone SE (3rd gen)', // 375x667
|
||||
'iPhone X', // 375x812
|
||||
'iPhone 11 Pro', // 375x635
|
||||
'iPhone 12 Mini', // 375x629
|
||||
'iPhone 13 Mini', // 375x629
|
||||
'LG Optimus L70', // 384x640
|
||||
'Nexus 4', // 384x640
|
||||
'iPhone 12', // 390x664
|
||||
'iPhone 12 Pro', // 390x664
|
||||
'iPhone 13', // 390x664
|
||||
'iPhone 13 Pro', // 390x664
|
||||
'iPhone 14', // 390x664
|
||||
'iPhone 14 Pro', // 393x660
|
||||
'iPhone 15', // 393x659
|
||||
'iPhone 15 Pro', // 393x659
|
||||
'Pixel 3', // 393x786
|
||||
'Pixel 5', // 393x727
|
||||
'Pixel 2', // 411x731
|
||||
'Pixel 2 XL', // 411x823
|
||||
'Pixel 7', // 412x839
|
||||
'Pixel 4a (5G)', // 412x765
|
||||
'Nexus 5X', // 412x732
|
||||
'Nexus 6', // 412x732
|
||||
'Nexus 6P', // 412x732
|
||||
'iPhone 6 Plus', // 414x736
|
||||
'iPhone 7 Plus', // 414x736
|
||||
'iPhone 8 Plus', // 414x736
|
||||
'iPhone XR', // 414x896
|
||||
'iPhone 11', // 414x715
|
||||
'iPhone 11 Pro Max', // 414x715
|
||||
'iPhone 12 Pro Max', // 428x746
|
||||
'iPhone 13 Pro Max', // 428x746
|
||||
'iPhone 14 Plus', // 428x746
|
||||
'iPhone X', // 375x812
|
||||
'iPhone 11 Pro', // 375x635
|
||||
'iPhone 12 Mini', // 375x629
|
||||
'iPhone 13 Mini', // 375x629
|
||||
'LG Optimus L70', // 384x640
|
||||
'Nexus 4', // 384x640
|
||||
'iPhone 12', // 390x664
|
||||
'iPhone 12 Pro', // 390x664
|
||||
'iPhone 13', // 390x664
|
||||
'iPhone 13 Pro', // 390x664
|
||||
'iPhone 14', // 390x664
|
||||
'iPhone 14 Pro', // 393x660
|
||||
'iPhone 15', // 393x659
|
||||
'iPhone 15 Pro', // 393x659
|
||||
'Pixel 3', // 393x786
|
||||
'Pixel 5', // 393x727
|
||||
'Pixel 2', // 411x731
|
||||
'Pixel 2 XL', // 411x823
|
||||
'Pixel 7', // 412x839
|
||||
'Pixel 4a (5G)', // 412x765
|
||||
'Nexus 5X', // 412x732
|
||||
'Nexus 6', // 412x732
|
||||
'Nexus 6P', // 412x732
|
||||
'iPhone 6 Plus', // 414x736
|
||||
'iPhone 7 Plus', // 414x736
|
||||
'iPhone 8 Plus', // 414x736
|
||||
'iPhone XR', // 414x896
|
||||
'iPhone 11', // 414x715
|
||||
'iPhone 11 Pro Max', // 414x715
|
||||
'iPhone 12 Pro Max', // 428x746
|
||||
'iPhone 13 Pro Max', // 428x746
|
||||
'iPhone 14 Plus', // 428x746
|
||||
|
||||
// Large phones (430-599px)
|
||||
'iPhone 14 Pro Max', // 430x740
|
||||
'iPhone 15 Plus', // 430x739
|
||||
'iPhone 15 Pro Max', // 430x739
|
||||
'Galaxy A55', // 480x1040
|
||||
'Nokia N9', // 480x854
|
||||
'iPhone 14 Pro Max', // 430x740
|
||||
'iPhone 15 Plus', // 430x739
|
||||
'iPhone 15 Pro Max', // 430x739
|
||||
'Galaxy A55', // 480x1040
|
||||
'Nokia N9', // 480x854
|
||||
|
||||
// Small tablets (600-767px)
|
||||
'Blackberry PlayBook', // 600x1024
|
||||
'Nexus 7', // 600x960
|
||||
'Galaxy Tab S9', // 640x1024
|
||||
'iPad (gen 11)', // 656x944
|
||||
'Galaxy Tab S4', // 712x1138
|
||||
'Nexus 7', // 600x960
|
||||
'Galaxy Tab S9', // 640x1024
|
||||
'iPad (gen 11)', // 656x944
|
||||
'Galaxy Tab S4', // 712x1138
|
||||
|
||||
// Standard tablets (768-834px)
|
||||
'iPad (gen 5)', // 768x1024
|
||||
'iPad (gen 6)', // 768x1024
|
||||
'iPad Mini', // 768x1024
|
||||
'Kindle Fire HDX', // 800x1280
|
||||
'Nexus 10', // 800x1280
|
||||
'iPad (gen 7)', // 810x1080
|
||||
'iPad (gen 5)', // 768x1024
|
||||
'iPad (gen 6)', // 768x1024
|
||||
'iPad Mini', // 768x1024
|
||||
'Kindle Fire HDX', // 800x1280
|
||||
'Nexus 10', // 800x1280
|
||||
'iPad (gen 7)', // 810x1080
|
||||
|
||||
// Large tablets (834px+)
|
||||
'iPad Pro 11', // 834x1194
|
||||
'iPad Pro 11', // 834x1194
|
||||
];
|
||||
|
||||
const playwrightEntries: DeviceEntry[] = PLAYWRIGHT_DEVICE_NAMES
|
||||
.map(n => fromPlaywright(n))
|
||||
.filter((d): d is DeviceEntry => d !== null);
|
||||
const playwrightEntries: DeviceEntry[] = PLAYWRIGHT_DEVICE_NAMES.map((n) => fromPlaywright(n)).filter(
|
||||
(d): d is DeviceEntry => d !== null
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom devices — newer models and those missing from Playwright
|
||||
@@ -185,84 +184,101 @@ const ANDROID_TABLET_UA = (androidVer: string, model: string) =>
|
||||
|
||||
const customEntries: DeviceEntry[] = [
|
||||
// ── Small phones (<375px) ──────────────────────────────────────────────
|
||||
custom('iPhone 5', 320, 568, 2, IOS_MOBILE_UA('10_3_4'), true),
|
||||
custom('iPhone 5s', 320, 568, 2, IOS_MOBILE_UA('12_5_7'), true),
|
||||
custom('iPhone 5c', 320, 568, 2, IOS_MOBILE_UA('10_3_3'), true),
|
||||
custom('iPod Touch (7th gen)',320, 568, 2, IOS_MOBILE_UA('15_8'), true),
|
||||
custom('Galaxy Y', 240, 320, 1, ANDROID_MOBILE_UA('2.3.6', 'GT-S5360'), false),
|
||||
custom('Galaxy Ace', 320, 480, 1, ANDROID_MOBILE_UA('2.3.7', 'GT-S5830'), false),
|
||||
custom('Pixel 4a', 353, 745, 2.75, ANDROID_MOBILE_UA('12', 'Pixel 4a'), false),
|
||||
custom('iPhone 5', 320, 568, 2, IOS_MOBILE_UA('10_3_4'), true),
|
||||
custom('iPhone 5s', 320, 568, 2, IOS_MOBILE_UA('12_5_7'), true),
|
||||
custom('iPhone 5c', 320, 568, 2, IOS_MOBILE_UA('10_3_3'), true),
|
||||
custom('iPod Touch (7th gen)', 320, 568, 2, IOS_MOBILE_UA('15_8'), true),
|
||||
custom('Galaxy Y', 240, 320, 1, ANDROID_MOBILE_UA('2.3.6', 'GT-S5360'), false),
|
||||
custom('Galaxy Ace', 320, 480, 1, ANDROID_MOBILE_UA('2.3.7', 'GT-S5830'), false),
|
||||
custom('Pixel 4a', 353, 745, 2.75, ANDROID_MOBILE_UA('12', 'Pixel 4a'), false),
|
||||
|
||||
// ── Standard phones (375-429px) ────────────────────────────────────────
|
||||
custom('iPhone 16', 393, 659, 3, IOS_MOBILE_UA('18_0'), true),
|
||||
custom('iPhone 16 Pro', 402, 674, 3, IOS_MOBILE_UA('18_0'), true),
|
||||
custom('Galaxy S20', 360, 800, 3, ANDROID_MOBILE_UA('12', 'SM-G980F'), false),
|
||||
custom('Galaxy S20 FE', 360, 800, 3, ANDROID_MOBILE_UA('13', 'SM-G780F'), false),
|
||||
custom('Galaxy S21', 360, 800, 3, ANDROID_MOBILE_UA('13', 'SM-G991B'), false),
|
||||
custom('Galaxy S21 FE', 360, 800, 3, ANDROID_MOBILE_UA('14', 'SM-G990B'), false),
|
||||
custom('Galaxy S22', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S901B'), false),
|
||||
custom('Galaxy S23', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S911B'), false),
|
||||
custom('Galaxy S24 FE', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S721B'), false),
|
||||
custom('Galaxy A54', 360, 800, 3, ANDROID_MOBILE_UA('14', 'SM-A546B'), false),
|
||||
custom('Galaxy A34', 360, 800, 2.625, ANDROID_MOBILE_UA('14', 'SM-A346B'), false),
|
||||
custom('Galaxy A14', 384, 854, 1.5, ANDROID_MOBILE_UA('13', 'SM-A145F'), false),
|
||||
custom('Galaxy Z Flip 5', 412, 919, 2.625, ANDROID_MOBILE_UA('14', 'SM-F731B'), false),
|
||||
custom('Galaxy Z Flip 4', 412, 919, 2.625, ANDROID_MOBILE_UA('14', 'SM-F721B'), false),
|
||||
custom('Pixel 6', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6'), false),
|
||||
custom('Pixel 6a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6a'), false),
|
||||
custom('Pixel 7a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 7a'), false),
|
||||
custom('Pixel 8', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8'), false),
|
||||
custom('Pixel 8a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8a'), false),
|
||||
custom('Pixel 9', 412, 923, 2.75, ANDROID_MOBILE_UA('15', 'Pixel 9'), false),
|
||||
custom('OnePlus 12', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'CPH2581'), false),
|
||||
custom('OnePlus Nord 3', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'CPH2491'), false),
|
||||
custom('Xiaomi 14', 393, 873, 2.75, ANDROID_MOBILE_UA('14', '23127PN0CC'), false),
|
||||
custom('Xiaomi Redmi Note 13',393, 873, 2.75, ANDROID_MOBILE_UA('14', '23106RN0DA'), false),
|
||||
custom('Nothing Phone (2)', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'A065'), false),
|
||||
custom('Sony Xperia 1 V', 411, 960, 2.625, ANDROID_MOBILE_UA('14', 'XQ-DQ72'), false),
|
||||
custom('iPhone 16', 393, 659, 3, IOS_MOBILE_UA('18_0'), true),
|
||||
custom('iPhone 16 Pro', 402, 674, 3, IOS_MOBILE_UA('18_0'), true),
|
||||
custom('Galaxy S20', 360, 800, 3, ANDROID_MOBILE_UA('12', 'SM-G980F'), false),
|
||||
custom('Galaxy S20 FE', 360, 800, 3, ANDROID_MOBILE_UA('13', 'SM-G780F'), false),
|
||||
custom('Galaxy S21', 360, 800, 3, ANDROID_MOBILE_UA('13', 'SM-G991B'), false),
|
||||
custom('Galaxy S21 FE', 360, 800, 3, ANDROID_MOBILE_UA('14', 'SM-G990B'), false),
|
||||
custom('Galaxy S22', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S901B'), false),
|
||||
custom('Galaxy S23', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S911B'), false),
|
||||
custom('Galaxy S24 FE', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S721B'), false),
|
||||
custom('Galaxy A54', 360, 800, 3, ANDROID_MOBILE_UA('14', 'SM-A546B'), false),
|
||||
custom('Galaxy A34', 360, 800, 2.625, ANDROID_MOBILE_UA('14', 'SM-A346B'), false),
|
||||
custom('Galaxy A14', 384, 854, 1.5, ANDROID_MOBILE_UA('13', 'SM-A145F'), false),
|
||||
custom('Galaxy Z Flip 5', 412, 919, 2.625, ANDROID_MOBILE_UA('14', 'SM-F731B'), false),
|
||||
custom('Galaxy Z Flip 4', 412, 919, 2.625, ANDROID_MOBILE_UA('14', 'SM-F721B'), false),
|
||||
custom('Pixel 6', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6'), false),
|
||||
custom('Pixel 6a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6a'), false),
|
||||
custom('Pixel 7a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 7a'), false),
|
||||
custom('Pixel 8', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8'), false),
|
||||
custom('Pixel 8a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8a'), false),
|
||||
custom('Pixel 9', 412, 923, 2.75, ANDROID_MOBILE_UA('15', 'Pixel 9'), false),
|
||||
custom('OnePlus 12', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'CPH2581'), false),
|
||||
custom('OnePlus Nord 3', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'CPH2491'), false),
|
||||
custom('Xiaomi 14', 393, 873, 2.75, ANDROID_MOBILE_UA('14', '23127PN0CC'), false),
|
||||
custom('Xiaomi Redmi Note 13', 393, 873, 2.75, ANDROID_MOBILE_UA('14', '23106RN0DA'), false),
|
||||
custom('Nothing Phone (2)', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'A065'), false),
|
||||
custom('Sony Xperia 1 V', 411, 960, 2.625, ANDROID_MOBILE_UA('14', 'XQ-DQ72'), false),
|
||||
|
||||
// ── Large phones (430-599px) ───────────────────────────────────────────
|
||||
custom('iPhone 16 Plus', 430, 739, 3, IOS_MOBILE_UA('18_0'), true),
|
||||
custom('iPhone 16 Pro Max', 440, 756, 3, IOS_MOBILE_UA('18_0'), true),
|
||||
custom('Galaxy S20 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('13', 'SM-G988B'), false),
|
||||
custom('Galaxy S21 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('13', 'SM-G998B'), false),
|
||||
custom('Galaxy S22 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S908B'), false),
|
||||
custom('Galaxy S23 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S918B'), false),
|
||||
custom('Galaxy S24 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S928B'), false),
|
||||
custom('Galaxy Z Fold 5', 460, 1016, 2.5, ANDROID_MOBILE_UA('14', 'SM-F946B'), false),
|
||||
custom('Pixel 6 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6 Pro'), false),
|
||||
custom('Pixel 7 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 7 Pro'), false),
|
||||
custom('Pixel 8 Pro', 448, 998, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8 Pro'), false),
|
||||
custom('Pixel 9 Pro XL', 448, 998, 2.75, ANDROID_MOBILE_UA('15', 'Pixel 9 Pro XL'), false),
|
||||
custom('OnePlus 12 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'CPH2583'), false),
|
||||
custom('iPhone 16 Plus', 430, 739, 3, IOS_MOBILE_UA('18_0'), true),
|
||||
custom('iPhone 16 Pro Max', 440, 756, 3, IOS_MOBILE_UA('18_0'), true),
|
||||
custom('Galaxy S20 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('13', 'SM-G988B'), false),
|
||||
custom('Galaxy S21 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('13', 'SM-G998B'), false),
|
||||
custom('Galaxy S22 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S908B'), false),
|
||||
custom('Galaxy S23 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S918B'), false),
|
||||
custom('Galaxy S24 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S928B'), false),
|
||||
custom('Galaxy Z Fold 5', 460, 1016, 2.5, ANDROID_MOBILE_UA('14', 'SM-F946B'), false),
|
||||
custom('Pixel 6 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6 Pro'), false),
|
||||
custom('Pixel 7 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 7 Pro'), false),
|
||||
custom('Pixel 8 Pro', 448, 998, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8 Pro'), false),
|
||||
custom('Pixel 9 Pro XL', 448, 998, 2.75, ANDROID_MOBILE_UA('15', 'Pixel 9 Pro XL'), false),
|
||||
custom('OnePlus 12 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'CPH2583'), false),
|
||||
|
||||
// ── Small tablets (600-767px) ──────────────────────────────────────────
|
||||
custom('Galaxy Tab A8', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-X200'), false),
|
||||
custom('Galaxy Tab S6 Lite', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-P613'), false),
|
||||
custom('Galaxy Tab A7 Lite', 600, 960, 1.5, ANDROID_TABLET_UA('13', 'SM-T220'), false),
|
||||
custom('Kindle Fire HD 8', 600, 1024, 1.5, 'Mozilla/5.0 (Linux; Android 11; KFRAPWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/110.1.4 like Chrome/110.0.5481.154 Safari/537.36', false),
|
||||
custom('Lenovo Tab M10', 600, 1024, 1.5, ANDROID_TABLET_UA('12', 'TB-X606F'), false),
|
||||
custom('Xiaomi Pad 6', 600, 960, 2, ANDROID_TABLET_UA('14', '23043RP34G'), false),
|
||||
custom('Galaxy Tab A8', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-X200'), false),
|
||||
custom('Galaxy Tab S6 Lite', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-P613'), false),
|
||||
custom('Galaxy Tab A7 Lite', 600, 960, 1.5, ANDROID_TABLET_UA('13', 'SM-T220'), false),
|
||||
custom(
|
||||
'Kindle Fire HD 8',
|
||||
600,
|
||||
1024,
|
||||
1.5,
|
||||
'Mozilla/5.0 (Linux; Android 11; KFRAPWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/110.1.4 like Chrome/110.0.5481.154 Safari/537.36',
|
||||
false
|
||||
),
|
||||
custom('Lenovo Tab M10', 600, 1024, 1.5, ANDROID_TABLET_UA('12', 'TB-X606F'), false),
|
||||
custom('Xiaomi Pad 6', 600, 960, 2, ANDROID_TABLET_UA('14', '23043RP34G'), false),
|
||||
|
||||
// ── Standard tablets (768-834px) ───────────────────────────────────────
|
||||
custom('iPad Air (5th gen)', 820, 1180, 2, IPAD_UA('16_0'), true),
|
||||
custom('iPad (9th gen)', 810, 1080, 2, IPAD_UA('16_0'), true),
|
||||
custom('iPad (10th gen)', 820, 1180, 2, IPAD_UA('16_0'), true),
|
||||
custom('iPad Mini (6th gen)', 768, 1024, 2, IPAD_UA('16_0'), true),
|
||||
custom('Galaxy Tab S7', 800, 1280, 2, ANDROID_TABLET_UA('13', 'SM-T870'), false),
|
||||
custom('Galaxy Tab S8', 800, 1280, 2, ANDROID_TABLET_UA('14', 'SM-X700'), false),
|
||||
custom('iPad Air (5th gen)', 820, 1180, 2, IPAD_UA('16_0'), true),
|
||||
custom('iPad (9th gen)', 810, 1080, 2, IPAD_UA('16_0'), true),
|
||||
custom('iPad (10th gen)', 820, 1180, 2, IPAD_UA('16_0'), true),
|
||||
custom('iPad Mini (6th gen)', 768, 1024, 2, IPAD_UA('16_0'), true),
|
||||
custom('Galaxy Tab S7', 800, 1280, 2, ANDROID_TABLET_UA('13', 'SM-T870'), false),
|
||||
custom('Galaxy Tab S8', 800, 1280, 2, ANDROID_TABLET_UA('14', 'SM-X700'), false),
|
||||
|
||||
// ── Large tablets (834px+) ──────────────────────────────────────────────
|
||||
custom('iPad Pro 12.9 (6th gen)', 1024, 1366, 2, IPAD_UA('16_0'), true),
|
||||
custom('iPad Pro 11 (4th gen)', 834, 1194, 2, IPAD_UA('16_0'), true),
|
||||
custom('iPad Air (M2)', 834, 1194, 2, IPAD_UA('17_0'), true),
|
||||
custom('Surface Pro 7', 912, 1368, 2,
|
||||
'Mozilla/5.0 (Windows NT 10.0; ARM; Surface Pro 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36 Edg/145.0.0.0', false),
|
||||
custom('Galaxy Tab S8+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X800'), false),
|
||||
custom('Galaxy Tab S9+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X810'), false),
|
||||
custom('Galaxy Tab S9 Ultra', 900, 1440, 2.25, ANDROID_TABLET_UA('14', 'SM-X910'), false),
|
||||
custom('Pixel Tablet', 888, 1280, 2, ANDROID_TABLET_UA('14', 'GPD8'), false),
|
||||
custom('Lenovo Tab P12 Pro', 900, 1440, 2, ANDROID_TABLET_UA('13', 'TB-Q706F'), false),
|
||||
custom('iPad Pro 11 (4th gen)', 834, 1194, 2, IPAD_UA('16_0'), true),
|
||||
custom('iPad Air (M2)', 834, 1194, 2, IPAD_UA('17_0'), true),
|
||||
custom(
|
||||
'Surface Pro 7',
|
||||
912,
|
||||
1368,
|
||||
2,
|
||||
'Mozilla/5.0 (Windows NT 10.0; ARM; Surface Pro 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36 Edg/145.0.0.0',
|
||||
false
|
||||
),
|
||||
custom('Galaxy Tab S8+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X800'), false),
|
||||
custom('Galaxy Tab S9+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X810'), false),
|
||||
custom('Galaxy Tab S9 Ultra', 900, 1440, 2.25, ANDROID_TABLET_UA('14', 'SM-X910'), false),
|
||||
custom('Pixel Tablet', 888, 1280, 2, ANDROID_TABLET_UA('14', 'GPD8'), false),
|
||||
custom('Lenovo Tab P12 Pro', 900, 1440, 2, ANDROID_TABLET_UA('13', 'TB-Q706F'), false),
|
||||
// Find N5 inner display is 2248x2480 physical pixels. At DPR 2 its full-
|
||||
// resolution CSS viewport crosses Codeman's desktop breakpoint while the
|
||||
// browser remains a mobile/touch device.
|
||||
custom('OPPO Find N5 (unfolded)', 1124, 1240, 2, ANDROID_MOBILE_UA('15', 'CPH2671'), false),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -276,29 +292,29 @@ export const DEVICE_REGISTRY: DeviceEntry[] = [...playwrightEntries, ...customEn
|
||||
// Per-category exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SMALL_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'small-phone');
|
||||
export const STANDARD_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'standard-phone');
|
||||
export const LARGE_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'large-phone');
|
||||
export const SMALL_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'small-tablet');
|
||||
export const STANDARD_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'standard-tablet');
|
||||
export const LARGE_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'large-tablet');
|
||||
export const SMALL_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'small-phone');
|
||||
export const STANDARD_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'standard-phone');
|
||||
export const LARGE_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'large-phone');
|
||||
export const SMALL_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'small-tablet');
|
||||
export const STANDARD_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'standard-tablet');
|
||||
export const LARGE_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'large-tablet');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const IOS_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.isIOS);
|
||||
export const ANDROID_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => !d.isIOS);
|
||||
export const IOS_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.isIOS);
|
||||
export const ANDROID_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => !d.isIOS);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Representative devices — one per category for quick smoke tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const REPRESENTATIVE_DEVICES: Record<DeviceCategory, DeviceEntry> = {
|
||||
'small-phone': SMALL_PHONES.find(d => d.name === 'iPhone SE')!,
|
||||
'standard-phone': STANDARD_PHONES.find(d => d.name === 'iPhone 14 Pro')!,
|
||||
'large-phone': LARGE_PHONES.find(d => d.name === 'iPhone 15 Pro Max')!,
|
||||
'small-tablet': SMALL_TABLETS.find(d => d.name === 'Nexus 7')!,
|
||||
'standard-tablet': STANDARD_TABLETS.find(d => d.name === 'iPad Mini')!,
|
||||
'large-tablet': LARGE_TABLETS.find(d => d.name === 'iPad Pro 11')!,
|
||||
'small-phone': SMALL_PHONES.find((d) => d.name === 'iPhone SE')!,
|
||||
'standard-phone': STANDARD_PHONES.find((d) => d.name === 'iPhone 14 Pro')!,
|
||||
'large-phone': LARGE_PHONES.find((d) => d.name === 'iPhone 15 Pro Max')!,
|
||||
'small-tablet': SMALL_TABLETS.find((d) => d.name === 'Nexus 7')!,
|
||||
'standard-tablet': STANDARD_TABLETS.find((d) => d.name === 'iPad Mini')!,
|
||||
'large-tablet': LARGE_TABLETS.find((d) => d.name === 'iPad Pro 11')!,
|
||||
};
|
||||
|
||||
@@ -5,10 +5,8 @@ import { PORTS, SELECTORS, KEYBOARD, STORAGE_KEYS, BODY_CLASSES, WAIT } from './
|
||||
import { createTestServer, stopTestServer } from './helpers/server.js';
|
||||
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
|
||||
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
|
||||
import {
|
||||
assertVisible, assertHidden, getCSSProperty, getCSSNumericValue,
|
||||
} from './helpers/assertions.js';
|
||||
import { REPRESENTATIVE_DEVICES } from './devices.js';
|
||||
import { assertVisible, assertHidden, getCSSProperty, getCSSNumericValue } from './helpers/assertions.js';
|
||||
import { DEVICE_REGISTRY, REPRESENTATIVE_DEVICES } from './devices.js';
|
||||
import type { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = PORTS.SETTINGS;
|
||||
@@ -94,9 +92,7 @@ describe('Settings Modal', () => {
|
||||
if (gearBox && toolbarBox) {
|
||||
// Gear button should be within toolbar's vertical range
|
||||
expect(gearBox.y).toBeGreaterThanOrEqual(toolbarBox.y - 5);
|
||||
expect(gearBox.y + gearBox.height).toBeLessThanOrEqual(
|
||||
toolbarBox.y + toolbarBox.height + 5,
|
||||
);
|
||||
expect(gearBox.y + gearBox.height).toBeLessThanOrEqual(toolbarBox.y + toolbarBox.height + 5);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -304,11 +300,14 @@ describe('Settings Modal', () => {
|
||||
try {
|
||||
// Store a test setting
|
||||
await page.evaluate((key) => {
|
||||
localStorage.setItem(key, JSON.stringify({
|
||||
showFontControls: true,
|
||||
showMonitor: true,
|
||||
subagentTrackingEnabled: true,
|
||||
}));
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
showFontControls: true,
|
||||
showMonitor: true,
|
||||
subagentTrackingEnabled: true,
|
||||
})
|
||||
);
|
||||
}, STORAGE_KEYS.SETTINGS_MOBILE);
|
||||
|
||||
// Reload page
|
||||
@@ -335,20 +334,32 @@ describe('Settings Modal', () => {
|
||||
|
||||
try {
|
||||
// Store both mobile and desktop settings
|
||||
await page.evaluate(({ mobileKey, desktopKey, notifKey }) => {
|
||||
localStorage.setItem(mobileKey, JSON.stringify({ showFontControls: false }));
|
||||
localStorage.setItem(desktopKey, JSON.stringify({ showFontControls: true }));
|
||||
localStorage.setItem(notifKey, JSON.stringify({ mobileNotif: true }));
|
||||
}, {
|
||||
mobileKey: STORAGE_KEYS.SETTINGS_MOBILE,
|
||||
desktopKey: STORAGE_KEYS.SETTINGS_DESKTOP,
|
||||
notifKey: STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE,
|
||||
});
|
||||
await page.evaluate(
|
||||
({ mobileKey, desktopKey, notifKey }) => {
|
||||
localStorage.setItem(mobileKey, JSON.stringify({ showFontControls: false }));
|
||||
localStorage.setItem(desktopKey, JSON.stringify({ showFontControls: true }));
|
||||
localStorage.setItem(notifKey, JSON.stringify({ mobileNotif: true }));
|
||||
},
|
||||
{
|
||||
mobileKey: STORAGE_KEYS.SETTINGS_MOBILE,
|
||||
desktopKey: STORAGE_KEYS.SETTINGS_DESKTOP,
|
||||
notifKey: STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE,
|
||||
}
|
||||
);
|
||||
|
||||
// Verify they are independent
|
||||
const mobile = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.SETTINGS_MOBILE);
|
||||
const desktop = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.SETTINGS_DESKTOP);
|
||||
const notif = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE);
|
||||
const mobile = await page.evaluate(
|
||||
(key) => JSON.parse(localStorage.getItem(key) || '{}'),
|
||||
STORAGE_KEYS.SETTINGS_MOBILE
|
||||
);
|
||||
const desktop = await page.evaluate(
|
||||
(key) => JSON.parse(localStorage.getItem(key) || '{}'),
|
||||
STORAGE_KEYS.SETTINGS_DESKTOP
|
||||
);
|
||||
const notif = await page.evaluate(
|
||||
(key) => JSON.parse(localStorage.getItem(key) || '{}'),
|
||||
STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE
|
||||
);
|
||||
|
||||
expect(mobile.showFontControls).toBe(false);
|
||||
expect(desktop.showFontControls).toBe(true);
|
||||
@@ -390,5 +401,53 @@ describe('Settings Modal', () => {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps handheld settings when a foldable unfolds past the desktop breakpoint', async () => {
|
||||
const device = DEVICE_REGISTRY.find((entry) => entry.name === 'OPPO Find N5 (unfolded)')!;
|
||||
const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
|
||||
|
||||
try {
|
||||
// Seed the preferences while folded, exactly as a phone user does.
|
||||
await page.setViewportSize({ width: 412, height: 915 });
|
||||
await page.evaluate((key) => {
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
showResponseViewer: true,
|
||||
extendedKeyboardBar: true,
|
||||
})
|
||||
);
|
||||
}, STORAGE_KEYS.SETTINGS_MOBILE);
|
||||
await page.reload({ waitUntil: WAIT.DOM_CONTENT_LOADED });
|
||||
await page.waitForTimeout(WAIT.SSE_CONNECT);
|
||||
|
||||
// Unfolding can reload Android WebView. The viewport now uses desktop
|
||||
// layout, but the physical device and its preferences have not changed.
|
||||
await page.setViewportSize(device.viewport);
|
||||
await page.reload({ waitUntil: WAIT.DOM_CONTENT_LOADED });
|
||||
await page.waitForTimeout(WAIT.SSE_CONNECT);
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
deviceType: (window as any).MobileDetection.getDeviceType(),
|
||||
handheld: (window as any).MobileDetection.isHandheldDevice(),
|
||||
storageKey: (window as any).app.getSettingsStorageKey(),
|
||||
responseViewerVisible: !document
|
||||
.querySelector('.btn-response-viewer-header')
|
||||
?.classList.contains('btn-response-viewer-header--hidden'),
|
||||
keyboardExtended: Boolean(document.querySelector('.keyboard-accessory-bar [data-action="arrow-left"]')),
|
||||
}));
|
||||
|
||||
expect(state.deviceType).toBe('desktop');
|
||||
expect(state.handheld).toBe(true);
|
||||
expect(state.storageKey).toBe(STORAGE_KEYS.SETTINGS_MOBILE);
|
||||
expect(state.responseViewerVisible).toBe(true);
|
||||
expect(state.keyboardExtended).toBe(true);
|
||||
|
||||
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
|
||||
await assertVisible(page, '.keyboard-accessory-bar');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,10 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
||||
const sessions = new Map<string, MockSession>();
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
// Stateful backing for the global tab order (COD-131) so route tests can
|
||||
// assert that setSessionOrder() actually persists what the handler computed.
|
||||
let sessionOrder: string[] = [];
|
||||
|
||||
return {
|
||||
// -- SessionPort --
|
||||
sessions,
|
||||
@@ -65,6 +69,10 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
||||
load: vi.fn(),
|
||||
incrementSessionsCreated: vi.fn(),
|
||||
setConfig: vi.fn(),
|
||||
getSessionOrder: vi.fn(() => sessionOrder),
|
||||
setSessionOrder: vi.fn((order: string[]) => {
|
||||
sessionOrder = order;
|
||||
}),
|
||||
getAggregateStats: vi.fn(() => ({ totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0 })),
|
||||
getGlobalStats: vi.fn(() => ({ sessionsCreated: 0 })),
|
||||
getDailyStats: vi.fn(() => []),
|
||||
|
||||
@@ -229,6 +229,8 @@ export class MockSession extends EventEmitter {
|
||||
color: this.color,
|
||||
mode: this.mode,
|
||||
muxName: this._muxName,
|
||||
pinned: this.pinned || undefined,
|
||||
pinnedAt: this.pinned ? (this.pinnedAt ?? undefined) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -241,6 +243,14 @@ export class MockSession extends EventEmitter {
|
||||
if (!enabled) this.autoResumeAt = null;
|
||||
});
|
||||
|
||||
/** Pin state (COD-139) */
|
||||
pinned: boolean = false;
|
||||
pinnedAt: number | null = null;
|
||||
setPinned = vi.fn((pinned: boolean) => {
|
||||
this.pinned = pinned;
|
||||
this.pinnedAt = pinned ? Date.now() : null;
|
||||
});
|
||||
|
||||
/** Check if session is busy */
|
||||
isBusy = vi.fn(() => false);
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @fileoverview Fast VM/static regressions for the shared filesystem picker and
|
||||
* extended mobile keyboard actions. No browser or real server required.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const keyboardSource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
|
||||
const terminalSource = readFileSync(resolve('src/web/public/terminal-ui.js'), 'utf8');
|
||||
const sessionSource = readFileSync(resolve('src/web/public/session-ui.js'), 'utf8');
|
||||
const indexSource = readFileSync(resolve('src/web/public/index.html'), 'utf8');
|
||||
|
||||
function loadTerminalMixin() {
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, (...args: unknown[]) => unknown> };
|
||||
const cjkClear = vi.fn();
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
CodemanApp: FakeCodemanApp,
|
||||
CjkInput: { clear: cjkClear },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
document: { addEventListener: vi.fn() },
|
||||
});
|
||||
vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' });
|
||||
return { mixin: FakeCodemanApp.prototype, cjkClear };
|
||||
}
|
||||
|
||||
const terminalHarness = loadTerminalMixin();
|
||||
|
||||
function loadKeyboardModule() {
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
sessions: new Map([['session-1', { workingDir: '/mnt/d/AI' }]]),
|
||||
terminal: { focus: vi.fn() },
|
||||
clearTerminalInput: vi.fn(),
|
||||
insertTerminalText: vi.fn(),
|
||||
sendInput: vi.fn(),
|
||||
};
|
||||
const context = vm.createContext({
|
||||
app,
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
URLSearchParams,
|
||||
fetch: vi.fn(),
|
||||
document: {},
|
||||
setTimeout: (fn: () => void) => {
|
||||
fn();
|
||||
return 1;
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
});
|
||||
vm.runInContext(
|
||||
`${keyboardSource}\nglobalThis.__bar = KeyboardAccessoryBar; globalThis.__picker = PathPicker;`,
|
||||
context
|
||||
);
|
||||
return {
|
||||
app,
|
||||
bar: (context as unknown as { __bar: { handleAction(action: string): void } }).__bar,
|
||||
picker: (context as unknown as { __picker: { open: ReturnType<typeof vi.fn> } }).__picker,
|
||||
};
|
||||
}
|
||||
|
||||
describe('mobile filesystem picker actions', () => {
|
||||
it('keeps clear-input separate from the destructive /clear command', () => {
|
||||
const { app, bar } = loadKeyboardModule();
|
||||
bar.handleAction('clear-input');
|
||||
|
||||
expect(app.clearTerminalInput).toHaveBeenCalledOnce();
|
||||
expect(app.sendInput).not.toHaveBeenCalled();
|
||||
expect(keyboardSource).toContain('data-action="clear-input"');
|
||||
expect(keyboardSource).toContain('data-action="clear" title="/clear"');
|
||||
});
|
||||
|
||||
it('opens at the active working directory and inserts the selected path without Enter', () => {
|
||||
const { app, bar, picker } = loadKeyboardModule();
|
||||
picker.open = vi.fn();
|
||||
|
||||
bar.handleAction('pick-path');
|
||||
|
||||
expect(picker.open).toHaveBeenCalledOnce();
|
||||
const options = picker.open.mock.calls[0][0];
|
||||
expect(options).toMatchObject({
|
||||
sessionId: 'session-1',
|
||||
initialPath: '/mnt/d/AI',
|
||||
directoriesOnly: false,
|
||||
});
|
||||
options.onSelect('/mnt/d/AI/project/file.ts');
|
||||
expect(app.insertTerminalText).toHaveBeenCalledWith('/mnt/d/AI/project/file.ts');
|
||||
expect(app.sendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wires Link Existing to the shared folder-only picker', () => {
|
||||
expect(indexSource).toContain('onclick="app.openLinkCasePathPicker()"');
|
||||
expect(indexSource).toContain('id="linkCasePath"');
|
||||
expect(sessionSource).toContain('openLinkCasePathPicker()');
|
||||
expect(sessionSource).toContain('directoriesOnly: true');
|
||||
});
|
||||
|
||||
it('keeps Choose separate from safe inline file preview', () => {
|
||||
expect(keyboardSource).toContain('openPreview(entry)');
|
||||
expect(keyboardSource).toContain('/api/filesystem/preview?');
|
||||
expect(keyboardSource).toContain("entry.previewKind === 'image'");
|
||||
expect(keyboardSource).toContain("entry.previewKind === 'text'");
|
||||
expect(keyboardSource).toContain("choose.textContent = 'Choose'");
|
||||
expect(keyboardSource).toContain('pre.textContent = content');
|
||||
});
|
||||
|
||||
it('inserts a selected path into the editable local-echo prompt without sending it', () => {
|
||||
const appendText = vi.fn();
|
||||
const sendInput = vi.fn();
|
||||
const focus = vi.fn();
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
_localEchoEnabled: true,
|
||||
_localEchoOverlay: { appendText },
|
||||
terminal: { focus },
|
||||
sendInput,
|
||||
};
|
||||
|
||||
terminalHarness.mixin.insertTerminalText.call(app, '/mnt/d/AI/project');
|
||||
|
||||
expect(appendText).toHaveBeenCalledWith('/mnt/d/AI/project');
|
||||
expect(sendInput).not.toHaveBeenCalled();
|
||||
expect(focus).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('clears pending and already-flushed prompt text without invoking /clear', () => {
|
||||
const clear = vi.fn();
|
||||
const suppressBufferDetection = vi.fn();
|
||||
const sendInput = vi.fn(() => Promise.resolve());
|
||||
const showToast = vi.fn();
|
||||
const focus = vi.fn();
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
_inputFlushTimeout: null,
|
||||
_pendingInput: 'pending text',
|
||||
_localEchoEnabled: true,
|
||||
_localEchoOverlay: {
|
||||
getFlushed: () => ({ count: 4, text: 'sent' }),
|
||||
clear,
|
||||
suppressBufferDetection,
|
||||
},
|
||||
_flushedOffsets: new Map([['session-1', 4]]),
|
||||
_flushedTexts: new Map([['session-1', 'sent']]),
|
||||
sendInput,
|
||||
showToast,
|
||||
terminal: { focus },
|
||||
};
|
||||
|
||||
terminalHarness.mixin.clearTerminalInput.call(app);
|
||||
|
||||
expect(app._pendingInput).toBe('');
|
||||
expect(clear).toHaveBeenCalledOnce();
|
||||
expect(suppressBufferDetection).toHaveBeenCalledOnce();
|
||||
expect(sendInput).toHaveBeenCalledWith('\x7f'.repeat(4));
|
||||
expect(sendInput).not.toHaveBeenCalledWith('/clear');
|
||||
expect(app._flushedOffsets.size).toBe(0);
|
||||
expect(app._flushedTexts.size).toBe(0);
|
||||
expect(showToast).toHaveBeenCalledWith('Input cleared', 'success');
|
||||
expect(focus).toHaveBeenCalledOnce();
|
||||
expect(terminalHarness.cjkClear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses Ctrl+U to clear the TUI-owned prompt when local echo is disabled', () => {
|
||||
const sendInput = vi.fn(() => Promise.resolve());
|
||||
const app = {
|
||||
activeSessionId: 'session-1',
|
||||
_inputFlushTimeout: null,
|
||||
_pendingInput: '',
|
||||
_localEchoEnabled: false,
|
||||
_localEchoOverlay: null,
|
||||
sendInput,
|
||||
showToast: vi.fn(),
|
||||
terminal: { focus: vi.fn() },
|
||||
};
|
||||
|
||||
terminalHarness.mixin.clearTerminalInput.call(app);
|
||||
|
||||
expect(sendInput).toHaveBeenCalledWith('\x15');
|
||||
});
|
||||
});
|
||||
@@ -250,6 +250,9 @@ describe('QR Token Manager (unit)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Must match the alphabet in `generateShortCode` (tunnel-manager.ts). */
|
||||
const BASE62_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
describe('Short code distribution (bias check)', () => {
|
||||
it('should produce roughly uniform character distribution', () => {
|
||||
// Generate 6000 codes (36000 chars) and check distribution
|
||||
@@ -265,17 +268,36 @@ describe('Short code distribution (bias check)', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Expected count per char: 36000 / 62 ≈ 580.6
|
||||
const expected = 36000 / 62;
|
||||
let maxDeviation = 0;
|
||||
for (const [, count] of charCounts) {
|
||||
const deviation = Math.abs(count - expected) / expected;
|
||||
maxDeviation = Math.max(maxDeviation, deviation);
|
||||
// Chi-square goodness-of-fit against a uniform base62 alphabet.
|
||||
//
|
||||
// This deliberately does NOT assert on the max per-character deviation.
|
||||
// That statistic is the maximum of 62 correlated near-normal cells, so its
|
||||
// tail is fat: with n=36000 the per-cell relative SD is ~4.1%, which puts a
|
||||
// 15% bound at |z| ~ 3.65 and, taken as a max over 62 cells, fails on a
|
||||
// perfectly uniform generator about 1.6% of the time. Measured over 3000
|
||||
// simulated runs: 48 spurious failures. That is the flake.
|
||||
//
|
||||
// Chi-square is the right tool for "is this multinomial uniform", and its
|
||||
// threshold is derivable rather than eyeballed. df = 62 - 1 = 61, so under
|
||||
// the null E[X²] = 61 and SD = sqrt(2*61) ~ 11.05; the Wilson-Hilferty
|
||||
// approximation puts the p = 1e-6 critical value at ~129. Rounding to 130
|
||||
// gives a false-positive rate around one run in a million.
|
||||
//
|
||||
// Power is unaffected. Dropping rejection sampling reintroduces modulo bias
|
||||
// (256 % 62 = 8, so the first 8 characters draw 5 chances per 256 instead
|
||||
// of 4, ~25% overrepresented), which scores X² ~ 237. Simulated: 3000 clean
|
||||
// runs peaked at 104, while 200 biased runs bottomed out at 174.5, so the
|
||||
// threshold sits in a wide empty gap between the two.
|
||||
const alphabetSize = 62;
|
||||
const expected = 36000 / alphabetSize;
|
||||
let chiSquare = 0;
|
||||
for (let i = 0; i < alphabetSize; i++) {
|
||||
const count = charCounts.get(BASE62_ALPHABET[i]) ?? 0;
|
||||
chiSquare += (count - expected) ** 2 / expected;
|
||||
}
|
||||
|
||||
// With rejection sampling, deviation should be < 15% (generous)
|
||||
// Without rejection sampling (modulo bias), first 6 chars would be ~25% overrepresented
|
||||
expect(maxDeviation).toBeLessThan(0.15);
|
||||
expect(charCounts.size).toBe(alphabetSize);
|
||||
expect(chiSquare).toBeLessThan(130);
|
||||
|
||||
tm.stopTokenRotation();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* @fileoverview COD-108 — remote-session auto-reconnect watcher tests.
|
||||
*
|
||||
* Three layers, all tmux-safe (under VITEST TmuxManager no-ops real tmux and
|
||||
* `isPaneDead` returns false, so live behavior is driven via injected stubs):
|
||||
*
|
||||
* (a) PURE backoff schedule — attempt→delay, cap, reset-on-success.
|
||||
* (b) PURE eligibility decision — dead remote pane + due → emit; guarded →
|
||||
* never; non-remote / pane-alive / not-due → skip; over-cap → exhaust.
|
||||
* (c) MANAGER integration — drive ticks with a stubbed pane-death signal +
|
||||
* controllable clock and assert the emit → backoff → exhausted progression,
|
||||
* and that a guarded (intentionally-killed) session emits nothing.
|
||||
*
|
||||
* Port: N/A (no server).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
BACKOFF_SCHEDULE_MS,
|
||||
MAX_RECONNECT_ATTEMPTS,
|
||||
reconnectDelayForAttempt,
|
||||
isExhausted,
|
||||
freshReconnectState,
|
||||
advanceBackoff,
|
||||
resetReconnectState,
|
||||
decideReconnect,
|
||||
} from '../src/remote-reconnect.js';
|
||||
import type { ReconnectSessionView } from '../src/remote-reconnect.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
|
||||
const REMOTE: SessionRemote = {
|
||||
hostId: 'aa-desktop',
|
||||
label: 'aa-desktop',
|
||||
host: 'aa-desktop',
|
||||
username: 'aakhter',
|
||||
remotePath: '/home/aakhter',
|
||||
owned: true,
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (a) PURE backoff schedule
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('reconnect backoff schedule (pure)', () => {
|
||||
it('returns the documented bounded exponential delays per attempt', () => {
|
||||
expect(reconnectDelayForAttempt(1)).toBe(5_000);
|
||||
expect(reconnectDelayForAttempt(2)).toBe(15_000);
|
||||
expect(reconnectDelayForAttempt(3)).toBe(45_000);
|
||||
expect(reconnectDelayForAttempt(4)).toBe(120_000);
|
||||
expect(reconnectDelayForAttempt(5)).toBe(300_000);
|
||||
expect(reconnectDelayForAttempt(6)).toBe(300_000);
|
||||
});
|
||||
|
||||
it('clamps below-range and above-range attempts to the schedule bounds', () => {
|
||||
expect(reconnectDelayForAttempt(0)).toBe(BACKOFF_SCHEDULE_MS[0]);
|
||||
expect(reconnectDelayForAttempt(-3)).toBe(BACKOFF_SCHEDULE_MS[0]);
|
||||
expect(reconnectDelayForAttempt(99)).toBe(BACKOFF_SCHEDULE_MS[BACKOFF_SCHEDULE_MS.length - 1]);
|
||||
});
|
||||
|
||||
it('flags exhaustion only at/after the cap', () => {
|
||||
expect(isExhausted(0)).toBe(false);
|
||||
expect(isExhausted(MAX_RECONNECT_ATTEMPTS - 1)).toBe(false);
|
||||
expect(isExhausted(MAX_RECONNECT_ATTEMPTS)).toBe(true);
|
||||
expect(isExhausted(MAX_RECONNECT_ATTEMPTS + 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('advanceBackoff increments attempts and schedules next-eligible from now (immutable)', () => {
|
||||
const s0 = freshReconnectState();
|
||||
const s1 = advanceBackoff(s0, 1_000);
|
||||
expect(s0.attempts).toBe(0); // input untouched
|
||||
expect(s1.attempts).toBe(1);
|
||||
expect(s1.nextEligibleAt).toBe(1_000 + 5_000);
|
||||
expect(s1.exhausted).toBe(false);
|
||||
|
||||
const s2 = advanceBackoff(s1, 10_000);
|
||||
expect(s2.attempts).toBe(2);
|
||||
expect(s2.nextEligibleAt).toBe(10_000 + 15_000);
|
||||
});
|
||||
|
||||
it('reaches the attempt cap after the scheduled number of advances', () => {
|
||||
let s = freshReconnectState();
|
||||
let now = 0;
|
||||
for (let i = 0; i < MAX_RECONNECT_ATTEMPTS; i++) {
|
||||
s = advanceBackoff(s, now);
|
||||
now += reconnectDelayForAttempt(s.attempts);
|
||||
}
|
||||
expect(s.attempts).toBe(MAX_RECONNECT_ATTEMPTS);
|
||||
// advanceBackoff does not itself set `exhausted`; the watcher decides that
|
||||
// on the following tick via isExhausted(attempts).
|
||||
expect(isExhausted(s.attempts)).toBe(true);
|
||||
});
|
||||
|
||||
it('reset-on-success returns to a fresh, immediately-eligible state', () => {
|
||||
const advanced = advanceBackoff(advanceBackoff(freshReconnectState(), 0), 100);
|
||||
expect(advanced.attempts).toBe(2);
|
||||
const reset = resetReconnectState();
|
||||
expect(reset.attempts).toBe(0);
|
||||
expect(reset.nextEligibleAt).toBe(0);
|
||||
expect(reset.exhausted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (b) PURE eligibility decision
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decideReconnect (pure eligibility)', () => {
|
||||
const deadRemote: ReconnectSessionView = { sessionId: 's1', isRemote: true, paneDead: true };
|
||||
|
||||
it('emits for a dead remote pane that is not guarded and is due', () => {
|
||||
const action = decideReconnect({
|
||||
session: deadRemote,
|
||||
state: freshReconnectState(),
|
||||
guarded: false,
|
||||
enabled: true,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'emit', attempt: 1 });
|
||||
});
|
||||
|
||||
it('NEVER reconnects a guarded (intentionally killed/detached) session', () => {
|
||||
const action = decideReconnect({
|
||||
session: deadRemote,
|
||||
state: freshReconnectState(),
|
||||
guarded: true,
|
||||
enabled: true,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'guarded' });
|
||||
});
|
||||
|
||||
it('skips non-remote sessions', () => {
|
||||
const action = decideReconnect({
|
||||
session: { sessionId: 's1', isRemote: false, paneDead: true },
|
||||
state: freshReconnectState(),
|
||||
guarded: false,
|
||||
enabled: true,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'not-remote' });
|
||||
});
|
||||
|
||||
it('skips when the pane is alive', () => {
|
||||
const action = decideReconnect({
|
||||
session: { sessionId: 's1', isRemote: true, paneDead: false },
|
||||
state: freshReconnectState(),
|
||||
guarded: false,
|
||||
enabled: true,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'pane-alive' });
|
||||
});
|
||||
|
||||
it('skips when the kill-switch is off', () => {
|
||||
const action = decideReconnect({
|
||||
session: deadRemote,
|
||||
state: freshReconnectState(),
|
||||
guarded: false,
|
||||
enabled: false,
|
||||
now: 0,
|
||||
});
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'disabled' });
|
||||
});
|
||||
|
||||
it('skips when not yet due (within backoff window)', () => {
|
||||
const state = advanceBackoff(freshReconnectState(), 0); // nextEligibleAt = 5000
|
||||
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 4_999 });
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'not-due' });
|
||||
});
|
||||
|
||||
it('emits again once the backoff window elapses', () => {
|
||||
const state = advanceBackoff(freshReconnectState(), 0); // nextEligibleAt = 5000
|
||||
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 5_000 });
|
||||
expect(action).toEqual({ kind: 'emit', attempt: 2 });
|
||||
});
|
||||
|
||||
it('skips while a reconnect is already in flight (no stacked respawns)', () => {
|
||||
const state = { ...freshReconnectState(), inFlight: true };
|
||||
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 10_000 });
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'in-flight' });
|
||||
});
|
||||
|
||||
it('exhausts once the attempt cap is reached', () => {
|
||||
const state = { ...freshReconnectState(), attempts: MAX_RECONNECT_ATTEMPTS, nextEligibleAt: 0 };
|
||||
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 1_000_000 });
|
||||
expect(action).toEqual({ kind: 'exhaust' });
|
||||
});
|
||||
|
||||
it('stays quiet after exhaustion has been recorded', () => {
|
||||
const state = { ...freshReconnectState(), attempts: MAX_RECONNECT_ATTEMPTS, exhausted: true };
|
||||
const action = decideReconnect({ session: deadRemote, state, guarded: false, enabled: true, now: 1_000_000 });
|
||||
expect(action).toEqual({ kind: 'skip', reason: 'exhausted' });
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// (c) MANAGER integration — drive ticks with a stubbed pane-death + clock
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TmuxManager remote reconnect watcher (integration)', () => {
|
||||
let manager: TmuxManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new TmuxManager();
|
||||
});
|
||||
|
||||
function registerRemote(sessionId: string): void {
|
||||
manager.registerSession({
|
||||
sessionId,
|
||||
muxName: `codeman-${sessionId}`,
|
||||
pid: 4242,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/aakhter',
|
||||
mode: 'shell',
|
||||
attached: true,
|
||||
remote: REMOTE,
|
||||
});
|
||||
}
|
||||
|
||||
it('emits remoteSessionDropped when a dead remote pane is observed, then backs off and exhausts', () => {
|
||||
registerRemote('aaaa1111');
|
||||
// Force the watcher to see a dead pane regardless of test-mode isPaneDead.
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
|
||||
const dropped: Array<{ sessionId: string; attempt: number }> = [];
|
||||
const exhausted: Array<{ sessionId: string }> = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
manager.on('remoteReconnectExhausted', (d) => exhausted.push(d));
|
||||
|
||||
// Drive ticks with a controllable clock. Each emit marks the session
|
||||
// in-flight; a failed reattach (host still down) releases it via
|
||||
// noteRemoteReconnect(false), mirroring the real server loop.
|
||||
let now = 0;
|
||||
for (let i = 0; i < MAX_RECONNECT_ATTEMPTS + 3; i++) {
|
||||
manager.runRemoteReconnectTick(now, /* enabled */ true);
|
||||
manager.noteRemoteReconnect('aaaa1111', false); // reattach failed → clear in-flight
|
||||
// jump the clock past the just-scheduled backoff window
|
||||
now += BACKOFF_SCHEDULE_MS[Math.min(i, BACKOFF_SCHEDULE_MS.length - 1)] + 1;
|
||||
}
|
||||
|
||||
expect(dropped.map((d) => d.attempt)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
expect(dropped.every((d) => d.sessionId === 'aaaa1111')).toBe(true);
|
||||
expect(exhausted).toEqual([{ sessionId: 'aaaa1111' }]); // fired exactly once
|
||||
});
|
||||
|
||||
it('emits nothing for a guarded (intentionally killed) session', () => {
|
||||
registerRemote('bbbb2222');
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
manager.guardRemoteReconnect('bbbb2222'); // simulate killSession/detach guard
|
||||
|
||||
const dropped: unknown[] = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
|
||||
let now = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
manager.runRemoteReconnectTick(now, true);
|
||||
now += 600_000;
|
||||
}
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it('resets backoff on a successful reattach (noteRemoteReconnect)', () => {
|
||||
registerRemote('cccc3333');
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
|
||||
const dropped: Array<{ attempt: number }> = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
|
||||
manager.runRemoteReconnectTick(0, true); // emit attempt 1
|
||||
manager.noteRemoteReconnect('cccc3333', true); // reattach succeeded → reset
|
||||
manager.runRemoteReconnectTick(1, true); // immediately eligible again → attempt 1
|
||||
|
||||
expect(dropped.map((d) => d.attempt)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it('does nothing when the kill-switch (enabled=false) is off', () => {
|
||||
registerRemote('dddd4444');
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
const dropped: unknown[] = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
|
||||
manager.runRemoteReconnectTick(0, false);
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears per-session reconnect/guard state when the session is removed', () => {
|
||||
registerRemote('eeee5555');
|
||||
manager.guardRemoteReconnect('eeee5555');
|
||||
manager.clearRemoteReconnectState('eeee5555');
|
||||
// After clearing the guard, a fresh dead-pane observation should emit again.
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(true);
|
||||
const dropped: unknown[] = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
manager.runRemoteReconnectTick(0, true);
|
||||
expect(dropped).toEqual([{ sessionId: 'eeee5555', attempt: 1 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @fileoverview COD-105 — discover & attach existing remote tmux sessions.
|
||||
*
|
||||
* Phase 2 of the remote-tmux arc (builds on COD-104 durable remote sessions +
|
||||
* COD-107 connection args). These tests are tmux-safe / ssh-safe: they exercise
|
||||
* the PURE parse helper, the pure attach-command builder, and the killSession
|
||||
* ownership gate — none open a real ssh connection or a real tmux server.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseRemoteSessionList } from '../src/remote-hosts.js';
|
||||
import { buildRemoteAttachCommand } from '../src/tmux-manager.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
|
||||
const baseRemote: SessionRemote = {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
};
|
||||
|
||||
describe('COD-105 parseRemoteSessionList', () => {
|
||||
it('parses tab-delimited -F output and coerces fields', () => {
|
||||
const stdout = 'codeman-disco1\t0\t1700000000\t1\n' + 'codeman-abcd1234\t1\t1700000123\t3\n';
|
||||
const list = parseRemoteSessionList(stdout);
|
||||
expect(list).toEqual([
|
||||
{ name: 'codeman-disco1', attached: false, attachedClients: 0, created: 1700000000, windows: 1 },
|
||||
{ name: 'codeman-abcd1234', attached: true, attachedClients: 1, created: 1700000123, windows: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses the LITERAL backslash-t separator the remote tmux actually emits', () => {
|
||||
// tmux next-3.7's `-F "…\t…"` does NOT expand \t — it prints a literal
|
||||
// backslash-t (verified on aa-desktop). The parser must split on that.
|
||||
const stdout = 'codeman-disco1\\t0\\t1781362858\\t1\n' + 'codeman-real\\t1\\t1781329905\\t2\n';
|
||||
const list = parseRemoteSessionList(stdout);
|
||||
expect(list).toEqual([
|
||||
{ name: 'codeman-disco1', attached: false, attachedClients: 0, created: 1781362858, windows: 1 },
|
||||
{ name: 'codeman-real', attached: true, attachedClients: 1, created: 1781329905, windows: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps only codeman-* sessions, dropping foreign tmux sessions', () => {
|
||||
const stdout = 'work\t1\t1700000000\t2\n' + 'codeman-keep\t0\t1700000001\t1\n' + 'scratch\t0\t1700000002\t1\n';
|
||||
const list = parseRemoteSessionList(stdout);
|
||||
expect(list.map((s) => s.name)).toEqual(['codeman-keep']);
|
||||
});
|
||||
|
||||
it('returns [] for empty / whitespace output (the no-sessions case)', () => {
|
||||
expect(parseRemoteSessionList('')).toEqual([]);
|
||||
expect(parseRemoteSessionList(' \n \n')).toEqual([]);
|
||||
});
|
||||
|
||||
it('tolerates malformed lines (missing columns) by skipping them', () => {
|
||||
const stdout = 'codeman-ok\t0\t1700000000\t1\n' + 'codeman-bad\tnotanumber\n';
|
||||
const list = parseRemoteSessionList(stdout);
|
||||
expect(list).toEqual([
|
||||
{ name: 'codeman-ok', attached: false, attachedClients: 0, created: 1700000000, windows: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('COD-105 buildRemoteAttachCommand', () => {
|
||||
it('emits ssh -t <target> tmux -L codeman attach -t <session>', () => {
|
||||
const command = buildRemoteAttachCommand(baseRemote, 'codeman-disco1');
|
||||
expect(command).toContain('ssh');
|
||||
expect(command).toContain('BatchMode=yes');
|
||||
expect(command).toContain('-t');
|
||||
expect(command).toContain('ubuntu@10.0.0.42');
|
||||
// The tmux invocation is nested-quoted (inner session name escaped, whole
|
||||
// invocation re-escaped as one ssh arg). Assert the stable prefix here; the
|
||||
// exact re-parsed token is verified by the argv-reparse test below.
|
||||
expect(command).toContain('tmux -L codeman attach -t ');
|
||||
expect(command).toContain('codeman-disco1');
|
||||
});
|
||||
|
||||
it('threads the COD-107 connection args (port / identity / proxy) into the ssh invocation', () => {
|
||||
const command = buildRemoteAttachCommand(
|
||||
{ ...baseRemote, port: 2222, identityFile: '/keys/id_ed25519', socksProxy: '127.0.0.1:1080' },
|
||||
'codeman-disco1'
|
||||
);
|
||||
expect(command).toContain('-p 2222');
|
||||
expect(command).toContain("-i '/keys/id_ed25519'");
|
||||
expect(command).toContain('ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p');
|
||||
// Port/identity/proxy belong to ssh, ahead of the target.
|
||||
expect(command).toMatch(/ssh[\s\S]*-p 2222[\s\S]*ubuntu@10\.0\.0\.42/);
|
||||
});
|
||||
|
||||
it('shell-escapes the session name so it stays a single token', () => {
|
||||
const command = buildRemoteAttachCommand(baseRemote, 'codeman-disco1');
|
||||
// Re-parse: stub ssh to dump argv, confirm the trailing tmux invocation is one arg.
|
||||
const dumpArgs = (name: string, prefix: string) =>
|
||||
`${name}() { for a in "$@"; do printf '${prefix}:%s\\n' "$a"; done; }`;
|
||||
const out = execFileSync('/bin/sh', ['-c', `${dumpArgs('ssh', 'A')}\n${command}`], { encoding: 'utf8' });
|
||||
const sshArgs = out
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('A:'))
|
||||
.map((l) => l.slice(2));
|
||||
expect(sshArgs).toContain('ubuntu@10.0.0.42');
|
||||
const tmuxArg = sshArgs.find((a) => a.includes('attach'));
|
||||
expect(tmuxArg).toBe("tmux -L codeman attach -t 'codeman-disco1'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('COD-105 killSession ownership gate (detach-not-kill)', () => {
|
||||
it('never issues a remote tmux kill-session for a non-owned remote session', async () => {
|
||||
const mgr = new TmuxManager();
|
||||
// Register a discovered+attached (non-owned) remote session.
|
||||
mgr.registerSession({
|
||||
sessionId: 'disco-1',
|
||||
muxName: 'codeman-disco-1',
|
||||
pid: 0,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/ubuntu/work',
|
||||
mode: 'shell',
|
||||
attached: false,
|
||||
remote: { ...baseRemote, owned: false },
|
||||
});
|
||||
|
||||
// killSession under VITEST is in-memory only (IS_TEST_MODE), so it physically
|
||||
// cannot run a remote kill-session. We assert the contract: the session's
|
||||
// ownership flag is the gate, and tearing it down removes only local state.
|
||||
const session = mgr.getSession('disco-1');
|
||||
expect(session?.remote?.owned).toBe(false);
|
||||
|
||||
const ok = await mgr.killSession('disco-1');
|
||||
expect(ok).toBe(true);
|
||||
// Local tracking removed; no remote kill was (or could be) issued.
|
||||
expect(mgr.getSession('disco-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats COD-104 launched remote sessions as owned by default', () => {
|
||||
const mgr = new TmuxManager();
|
||||
mgr.registerSession({
|
||||
sessionId: 'owned-1',
|
||||
muxName: 'codeman-owned-1',
|
||||
pid: 0,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/ubuntu/work',
|
||||
mode: 'shell',
|
||||
attached: false,
|
||||
remote: { ...baseRemote, owned: true },
|
||||
});
|
||||
expect(mgr.getSession('owned-1')?.remote?.owned).toBe(true);
|
||||
});
|
||||
});
|
||||