Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b1057d9e0 | ||
|
|
26cbbe0dcb | ||
|
|
1113d34ca8 | ||
|
|
8a31f10b7d | ||
|
|
2891ae0d6d | ||
|
|
17b86b1007 | ||
|
|
7e357691af | ||
|
|
80e7249a39 | ||
|
|
e0226f7186 | ||
|
|
e8681f575f | ||
|
|
64be4e3029 | ||
|
|
cb7d0ba565 | ||
|
|
22cb563f1e | ||
|
|
f0e13f9fc3 | ||
|
|
28c5b5c1eb | ||
|
|
af9db455ff | ||
|
|
a406aef2fa | ||
|
|
bba3d80971 | ||
|
|
67eb5b43eb | ||
|
|
4a4720cb62 | ||
|
|
3c903b36ca | ||
|
|
7c07284b95 | ||
|
|
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).
|
||||
@@ -52,12 +52,20 @@ jobs:
|
||||
OLD_TAG="aicodeman@${VERSION}"
|
||||
NEW_TAG="codeman@${VERSION}"
|
||||
|
||||
# Update the GitHub release BEFORE deleting the old tag
|
||||
# Update the GitHub release BEFORE deleting the old tag.
|
||||
# make_latest pins the "Latest" badge to the Codeman release. This repo
|
||||
# publishes TWO packages (aicodeman + xterm-zerolag-input), changesets
|
||||
# creates a GitHub release for each, and GitHub awards "Latest" to
|
||||
# whichever was published LAST. That is a race: 1.9.2 kept the badge,
|
||||
# 1.9.4 lost it to xterm-zerolag-input@0.1.7 by two seconds. All package
|
||||
# releases already exist by the time this step runs, so setting it here
|
||||
# is deterministic.
|
||||
RELEASE_ID=$(gh release view "$OLD_TAG" --json databaseId -q .databaseId 2>/dev/null || true)
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
||||
-f tag_name="$NEW_TAG" \
|
||||
-f name="$NEW_TAG"
|
||||
-f name="$NEW_TAG" \
|
||||
-f make_latest=true
|
||||
fi
|
||||
|
||||
# Retag
|
||||
|
||||
@@ -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,262 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.9.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Antigravity run mode, plus opt-in entrance animations.
|
||||
|
||||
**Antigravity CLI backend (#207).** Antigravity (`agy`) joins Claude Code, shell, OpenCode, Codex and Gemini as a sixth session backend, following the same pluggable-resolver pattern: `utils/antigravity-cli-resolver.ts` resolves the CLI and `GET /api/antigravity/status` reports availability and path. `ANTIGRAVITY_*` is added to the `ALLOWED_ENV_PREFIXES` allowlist so env overrides stay CLI-scoped rather than blanket-forwarded. Like the other external CLIs it requires tmux with no direct PTY fallback, because secrets are injected through socket-scoped `tmux setenv` and never on the spawn command line. The UI gains a Run-dropdown entry, an agent-type option, an `ag` tab badge and toolbar colours; `runAntigravity()` routes remote and docker cases through `POST /api/quick-start` and skips the local status probe for them.
|
||||
|
||||
**Entrance animations (opt-in, OFF by default).** Optional animations for the four things that appear when work starts: session tabs, the terminal pane a session's CLI runs in, floating agent windows, and the connection lines tying a window back to its parent tab. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. Choose a look in App Settings > Appearance > Entrance Animations (per-device, stored in localStorage rather than the settings payload); `?animlab=1` opens a per-surface picker with a live preview that fakes tabs, a pane, a window and a line so styles can be compared without spawning sessions.
|
||||
|
||||
Three implementation notes worth knowing if you touch this: tabs and connection lines are destroyed mid-animation on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML, `_updateConnectionLinesImmediate()` clears the SVG), so both are tracked by id and re-applied to the fresh element with a negative `animation-delay` that resumes rather than restarts them; terminal-pane styles animate transform, opacity and clip-path only, because xterm's FitAddon derives rows and columns from the untransformed layout box and animating width or height there would resize the PTY; and window styles that transform also move the rect their connection line aims at, which is why the `beam` style animates opacity and filter only.
|
||||
|
||||
Also fixes an agent window spawning hidden (its agent belongs to a background tab): being `display:none` it never ran its animation, so `animationend` never fired and the entrance class plus its inline custom property stuck to the window permanently. Hidden windows now skip the entrance entirely.
|
||||
|
||||
## 1.9.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Two fixes from community PRs (thanks @Lint111):
|
||||
- fix(transcripts): complete tools from user-entry results (#177). Claude transcripts record tool requests in assistant entries but commonly carry their results in user-role entries; the transcript watcher only completed tools from the older assistant-entry path, so Codeman could keep showing a tool as running after it had finished. The watcher now recognizes `tool_result` blocks in user entries, ends the active tool state, and emits `transcript:tool_end` with the correct tool name and error status. Watcher tests also moved from fixed sleeps to condition-based `vi.waitFor` assertions.
|
||||
- fix(notifications): quiet lifecycle hook noise (#178). Notification preferences move to schema version 5: the drawer-only "Response complete" (stop) default is now off, and the migration disables only the legacy drawer-only shape, preserving any explicit browser, audio, or push delivery the user opted into. Teammate-idle and task-completed hooks now map to the existing opt-in subagent categories instead of the broadly enabled idle/stop alerts, so normal agent activity no longer floods the drawer. Local and server-hydrated preferences are normalized through the same migration path (server hydration used to revive the retired default on fresh browsers), and the notification storage key now uses the stable handheld identity so an unfolded foldable keeps its mobile defaults and storage key (tablets and desktops unaffected).
|
||||
|
||||
## 1.9.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Background-Bash rewake hook, hooks self-heal that preserves user hooks, and test-harness isolation.
|
||||
- New `PostToolUse(Bash)` hook (PR #176): a self-contained `node -e` helper watches the session transcript for a background command's completion notification and uses Claude Code's `asyncRewake` to wake an idle agent (exit code 2), without injecting terminal input that could submit a user's draft. Works on Claude Code 2.1.207+; older CLIs strip the fields harmlessly.
|
||||
- Hooks self-heal (`refreshStaleHookSecret` renamed to `refreshStaleCodemanHooks`) now replaces only Codeman-owned handlers, preserving user events, matchers, and sibling handlers in mixed configurations; `writeHooksConfig` merges instead of clobbering the hooks key at case creation (PR #176).
|
||||
- Rewake helper hardening: self-terminates on its own 6h deadline and when orphaned; the marker is versioned (V2) with a version-agnostic ownership prefix so future script updates replace older handlers instead of duplicating them.
|
||||
- Hook timeout units fixed: the hook `timeout` field is seconds (the CLI multiplies by 1000), so `HOOK_TIMEOUT_MS = 10000` gave curl hooks a ~2.8-hour effective timeout; now `HOOK_TIMEOUT_SECONDS = 10`.
|
||||
- Test-harness isolation (PR #175): every test file gets a temporary `HOME`/`USERPROFILE` so tests cannot touch real Codeman state or delete real case directories, and `Session` attaches a raw-mode echo PTY instead of a real tmux client under Vitest. Fixes the quick-start suite deleting the real `~/codeman-cases/testcase`.
|
||||
- CI stability: drain console-log rpc forwards before worker teardown (fixes a run-failing `EnvironmentTeardownError` with all tests passing); `test/webview-proxy.test.ts` no longer accidentally runs under the jsdom environment via a directive named in a comment.
|
||||
- Release workflow pins the GitHub "Latest" badge to the Codeman release.
|
||||
|
||||
## 1.9.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix a latent bug where a partial settings PUT silently reset live service state, and trim the `xterm-zerolag-input` README callout.
|
||||
- **`PUT /api/settings` no longer resets watchers on a partial body.** The three `toggleService` calls (subagent watcher, workflow-run watcher, image watcher) read the raw request body with `??` defaults, so every key a caller omitted was treated as "apply the default". A body of just `{statusLineTelemetry:true}` would START the subagent watcher and STOP the workflow and image watchers, undoing the persisted config. They now resolve from `merged` (persisted settings + incoming), the same convention the `tmuxHistoryLimit` branch in that handler already used, so any PUT reconciles services to the effective stored state. Nothing triggered this in practice because every shipped client sends a full settings payload rebuilt from the DOM, but it was a trap for the next partial-update caller.
|
||||
- **Regression test**: `test/routes/system-routes-settings-partial-put.test.ts` (4 cases) pins both directions, omitted keys preserve state and explicit keys still take effect. Verified to fail against the pre-fix handler.
|
||||
- **CLAUDE.md** records the rule under "Adding Features → App setting": anything acting on a setting in that handler must resolve from `merged`, never the request body.
|
||||
- **`xterm-zerolag-input` README**: removed the links line (getcodeman.com / install one-liner / star link) from the Codeman callout above the demo GIF. The callout keeps its links in the heading and body.
|
||||
|
||||
## 1.9.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Plan-usage chip now defaults ON on desktop, plus the reworked `xterm-zerolag-input` README.
|
||||
- **Plan-usage chip defaults ON (desktop).** The `showPlanUsageLimits` chip (live 5-hour and weekly plan usage from the Claude statusline) used to be opt-in and default OFF, so most users never saw it. Desktop now defaults ON; handhelds still default OFF so the phone header stays minimal and the `mobile-header-buttons-policy` guard keeps passing. Devices with an explicitly stored preference keep whatever they chose, so nobody's OFF gets overridden.
|
||||
- **One resolver behind the chip.** Added `planUsageChipEnabled()` in settings-ui.js and routed all three call sites through it: the App Settings checkbox, the chip's visibility, and the create-time `statusLineTelemetry` flag in session-ui.js. Those three had independent `?? false` / `=== true` defaults, and a chip revealed without the telemetry flag renders `—` forever, so a default flip on one site alone would have shipped a permanently empty chip.
|
||||
- **Cron button comment corrected.** The App Settings comment claimed "Cron button defaults ON" while the code, the template (`btn-cron--hidden`) and the CSS all default it OFF. Verified against a fresh browser profile: the button is hidden and its checkbox unchecked out of the box. Comment now matches, and states why the two halves stay consistent.
|
||||
- **Docs.** CLAUDE.md, `docs/architecture-invariants.md` and `docs/usage-limits-display-plan.md` updated for the new default and the single-resolver rule; the stale `styles.css` comment claiming the server strips the chip's hidden class at render was corrected (display is per-device, so the client reveals it).
|
||||
- **`xterm-zerolag-input` README rework** (0.1.5 shipped the content; this republishes with the graphic and promo changes): replaced the misaligned 8-line keystroke-flow diagram with a two-line stock-vs-zerolag contrast, added a Codeman callout above the demo GIF with links to getcodeman.com and the repo, and rewrote the Origin section so it argues the extraction story instead of repeating the promo.
|
||||
|
||||
## 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)。
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
> Official documentation for Claude Code hooks system, extracted from [code.claude.com](https://code.claude.com/docs/en/hooks).
|
||||
|
||||
**Last Updated**: 2026-01-24
|
||||
**Last Updated**: 2026-07-25
|
||||
**Source**: [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks)
|
||||
|
||||
> This is a maintained summary, not an exhaustive copy of the upstream reference.
|
||||
> Check the source link for event-specific schemas before adding a new hook.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Hooks are automated scripts that execute at specific events during your Claude Code session. They allow you to:
|
||||
|
||||
- Validate, modify, or block tool usage
|
||||
- Add context to prompts
|
||||
- Implement custom workflows
|
||||
@@ -21,12 +25,12 @@ Hooks are automated scripts that execute at specific events during your Claude C
|
||||
|
||||
Hooks are configured in settings files:
|
||||
|
||||
| File | Scope |
|
||||
|------|-------|
|
||||
| `~/.claude/settings.json` | User (global) |
|
||||
| `.claude/settings.json` | Project |
|
||||
| File | Scope |
|
||||
| ----------------------------- | -------------------------- |
|
||||
| `~/.claude/settings.json` | User (global) |
|
||||
| `.claude/settings.json` | Project |
|
||||
| `.claude/settings.local.json` | Local project (gitignored) |
|
||||
| Plugin hook files | Plugin-specific |
|
||||
| Plugin hook files | Plugin-specific |
|
||||
|
||||
### Basic Structure
|
||||
|
||||
@@ -49,8 +53,9 @@ Hooks are configured in settings files:
|
||||
```
|
||||
|
||||
**Key Fields**:
|
||||
|
||||
- `matcher`: Pattern to match tool names (case-sensitive, supports regex like `Edit|Write` or `*` for all)
|
||||
- `type`: `"command"` for bash or `"prompt"` for LLM-based evaluation
|
||||
- `type`: `"command"`, `"http"`, `"mcp_tool"`, `"prompt"`, or `"agent"` where the event supports it
|
||||
- `command`: Bash command to execute
|
||||
- `prompt`: LLM prompt for evaluation (prompt-based hooks only)
|
||||
- `timeout`: Optional timeout in seconds (default: 60)
|
||||
@@ -59,6 +64,10 @@ Hooks are configured in settings files:
|
||||
|
||||
## Hook Events
|
||||
|
||||
Claude Code's current event surface is broader than the detailed subset below. In
|
||||
particular, `TeammateIdle` and `TaskCompleted` are supported lifecycle events used
|
||||
by Codeman; they are not stale or plugin-defined event names.
|
||||
|
||||
### PreToolUse
|
||||
|
||||
**When**: After Claude creates tool parameters, before processing the tool call.
|
||||
@@ -66,15 +75,17 @@ Hooks are configured in settings files:
|
||||
**Use Cases**: Approval, denial, or modification of tool calls.
|
||||
|
||||
**Common Matchers**:
|
||||
|
||||
- `Bash` - Shell commands
|
||||
- `Write` - File writing
|
||||
- `Edit` - File editing
|
||||
- `Read` - File reading
|
||||
- `Task` - Subagent tasks
|
||||
- `Agent` - Subagent tasks
|
||||
- `WebFetch`, `WebSearch` - Web operations
|
||||
- `mcp__<server>__<tool>` - MCP tools
|
||||
|
||||
**Output Control**:
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
@@ -96,13 +107,14 @@ Hooks are configured in settings files:
|
||||
**Use Cases**: Auto-approve or deny permissions.
|
||||
|
||||
**Output Control**:
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PermissionRequest",
|
||||
"decision": {
|
||||
"behavior": "allow|deny",
|
||||
"updatedInput": { },
|
||||
"updatedInput": {},
|
||||
"message": "deny reason",
|
||||
"interrupt": false
|
||||
}
|
||||
@@ -117,6 +129,7 @@ Hooks are configured in settings files:
|
||||
**Use Cases**: Provide feedback, run formatters/linters, log operations.
|
||||
|
||||
**Output Control**:
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "block",
|
||||
@@ -128,15 +141,30 @@ Hooks are configured in settings files:
|
||||
}
|
||||
```
|
||||
|
||||
#### Asynchronous Rewake
|
||||
|
||||
Command hooks can set `"asyncRewake": true` to run asynchronously and wake an
|
||||
idle Claude turn when the hook exits with code 2. The hook's stderr is delivered
|
||||
to Claude as a system reminder. This implies `"async": true`; ordinary async
|
||||
hooks do not wake an idle turn, and their output waits for the next interaction.
|
||||
|
||||
Codeman uses this on `PostToolUse(Bash)`: a self-contained Node helper extracts
|
||||
the background task ID from the Bash result, watches the session transcript for
|
||||
the matching completion notification, and exits 2. It does not send terminal
|
||||
input, so it cannot submit a user's partially written prompt.
|
||||
|
||||
### Notification
|
||||
|
||||
**When**: When Claude Code sends notifications.
|
||||
|
||||
**Matchers**:
|
||||
|
||||
- `permission_prompt`
|
||||
- `idle_prompt`
|
||||
- `auth_success`
|
||||
- `elicitation_dialog`
|
||||
- `elicitation_complete`
|
||||
- `elicitation_response`
|
||||
|
||||
### UserPromptSubmit
|
||||
|
||||
@@ -145,6 +173,7 @@ Hooks are configured in settings files:
|
||||
**Use Cases**: Add context, validate, or block prompts.
|
||||
|
||||
**Output Control**:
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "block",
|
||||
@@ -165,6 +194,7 @@ Hooks are configured in settings files:
|
||||
**Use Cases**: **Ralph Wiggum loops** - block exit and refeed prompt.
|
||||
|
||||
**Output Control**:
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": "block",
|
||||
@@ -173,6 +203,7 @@ Hooks are configured in settings files:
|
||||
```
|
||||
|
||||
Or to allow exit:
|
||||
|
||||
```json
|
||||
{
|
||||
"continue": true,
|
||||
@@ -184,15 +215,32 @@ Or to allow exit:
|
||||
|
||||
### SubagentStop
|
||||
|
||||
**When**: When a subagent (Task tool call) finishes responding.
|
||||
**When**: When a subagent (Agent tool call) finishes responding.
|
||||
|
||||
**Use Cases**: Control nested loops, verify subagent output.
|
||||
|
||||
### TeammateIdle
|
||||
|
||||
**When**: When an agent-team teammate is about to go idle.
|
||||
|
||||
**Use Cases**: Reassign work, continue a teammate loop, or notify an orchestrator.
|
||||
|
||||
**Matcher Support**: None. The hook fires for every occurrence.
|
||||
|
||||
### TaskCompleted
|
||||
|
||||
**When**: When a task is about to be marked completed.
|
||||
|
||||
**Use Cases**: Validate completion or forward team progress to an external UI.
|
||||
|
||||
**Matcher Support**: None. The hook fires for every occurrence.
|
||||
|
||||
### PreCompact
|
||||
|
||||
**When**: Before a compact operation.
|
||||
|
||||
**Matchers**:
|
||||
|
||||
- `manual` - Invoked from `/compact`
|
||||
- `auto` - Invoked from auto-compact
|
||||
|
||||
@@ -201,6 +249,7 @@ Or to allow exit:
|
||||
**When**: When Claude Code starts or resumes a session.
|
||||
|
||||
**Matchers**:
|
||||
|
||||
- `startup` - Fresh start
|
||||
- `resume` - From `--resume`, `--continue`, or `/resume`
|
||||
- `clear` - From `/clear`
|
||||
@@ -209,6 +258,7 @@ Or to allow exit:
|
||||
**Use Cases**: Load development context, set environment variables.
|
||||
|
||||
**Persisting Environment Variables**:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
if [ -n "$CLAUDE_ENV_FILE" ]; then
|
||||
@@ -219,6 +269,7 @@ exit 0
|
||||
```
|
||||
|
||||
**Output Control**:
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
@@ -233,6 +284,7 @@ exit 0
|
||||
**When**: When a session ends.
|
||||
|
||||
**Reason Values**:
|
||||
|
||||
- `clear`
|
||||
- `logout`
|
||||
- `prompt_input_exit`
|
||||
@@ -254,7 +306,7 @@ Hooks receive JSON via stdin with common fields:
|
||||
"permission_mode": "default",
|
||||
"hook_event_name": "PreToolUse",
|
||||
"tool_name": "Bash",
|
||||
"tool_input": { },
|
||||
"tool_input": {},
|
||||
"tool_use_id": "toolu_01ABC123..."
|
||||
}
|
||||
```
|
||||
@@ -262,6 +314,7 @@ Hooks receive JSON via stdin with common fields:
|
||||
### Tool-Specific Input
|
||||
|
||||
**Bash**:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
@@ -274,6 +327,7 @@ Hooks receive JSON via stdin with common fields:
|
||||
```
|
||||
|
||||
**Write**:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "Write",
|
||||
@@ -285,6 +339,7 @@ Hooks receive JSON via stdin with common fields:
|
||||
```
|
||||
|
||||
**Edit**:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "Edit",
|
||||
@@ -302,11 +357,11 @@ Hooks receive JSON via stdin with common fields:
|
||||
|
||||
### Exit Codes
|
||||
|
||||
| Code | Behavior |
|
||||
|------|----------|
|
||||
| 0 | Success. `stdout` processed (shown in verbose or added as context) |
|
||||
| 2 | Blocking error. Only `stderr` used. Blocks tool/prompt based on event |
|
||||
| Other | Non-blocking error. `stderr` shown in verbose, execution continues |
|
||||
| Code | Behavior |
|
||||
| ----- | --------------------------------------------------------------------- |
|
||||
| 0 | Success. `stdout` processed (shown in verbose or added as context) |
|
||||
| 2 | Blocking error. Only `stderr` used. Blocks tool/prompt based on event |
|
||||
| Other | Non-blocking error. `stderr` shown in verbose, execution continues |
|
||||
|
||||
### JSON Output (Exit Code 0)
|
||||
|
||||
@@ -323,7 +378,12 @@ Hooks receive JSON via stdin with common fields:
|
||||
|
||||
## Prompt-Based Hooks
|
||||
|
||||
For Stop and SubagentStop events, you can use LLM-based evaluation:
|
||||
Prompt and agent handlers are supported by decision-oriented events including
|
||||
`PreToolUse`, `PermissionRequest`, `PostToolUse`, `PostToolUseFailure`,
|
||||
`PostToolBatch`, `UserPromptSubmit`, `Stop`, `SubagentStop`, `TaskCreated`, and
|
||||
`TaskCompleted`. Check the upstream reference before choosing a handler type.
|
||||
|
||||
For example, a Stop event can use LLM-based evaluation:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -344,6 +404,7 @@ For Stop and SubagentStop events, you can use LLM-based evaluation:
|
||||
```
|
||||
|
||||
**LLM Response Format**:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
@@ -362,17 +423,18 @@ Hooks can be defined in Skills, Agents, and Slash Commands using frontmatter:
|
||||
name: secure-operations
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- matcher: "Bash"
|
||||
- matcher: 'Bash'
|
||||
hooks:
|
||||
- type: command
|
||||
command: "./scripts/security-check.sh"
|
||||
command: './scripts/security-check.sh'
|
||||
---
|
||||
```
|
||||
|
||||
These hooks:
|
||||
|
||||
- Are scoped to the component's lifecycle
|
||||
- Only run when that component is active
|
||||
- Support: PreToolUse, PostToolUse, Stop
|
||||
- Support all hook events; a subagent-scoped `Stop` is converted to `SubagentStop`
|
||||
|
||||
---
|
||||
|
||||
@@ -550,11 +612,11 @@ exit 0
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CLAUDE_PROJECT_DIR` | Project root directory |
|
||||
| `CLAUDE_CODE_REMOTE` | `"true"` for web, empty for CLI |
|
||||
| `CLAUDE_ENV_FILE` | Path to write persistent env vars (SessionStart) |
|
||||
| Variable | Description |
|
||||
| -------------------- | ------------------------------------------------ |
|
||||
| `CLAUDE_PROJECT_DIR` | Project root directory |
|
||||
| `CLAUDE_CODE_REMOTE` | `"true"` for web, empty for CLI |
|
||||
| `CLAUDE_ENV_FILE` | Path to write persistent env vars (SessionStart) |
|
||||
|
||||
---
|
||||
|
||||
@@ -593,4 +655,4 @@ Use `/hooks` command to view registered hooks and make changes.
|
||||
|
||||
---
|
||||
|
||||
*Source: [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks)*
|
||||
_Source: [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks)_
|
||||
|
||||
|
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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Plan Usage Limits Display — Design & As-Built
|
||||
|
||||
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** Opt-in via App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`, default OFF). Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
|
||||
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`). **Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved via `planUsageChipEnabled()`.** The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
|
||||
>
|
||||
> Two surfaces from one `statusLine` callback:
|
||||
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
|
||||
|
||||
@@ -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.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.5.1",
|
||||
"version": "1.9.7",
|
||||
"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.7",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"jsdom": "^24.1.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.5.1",
|
||||
"version": "1.9.7",
|
||||
"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,40 @@
|
||||
# xterm-zerolag-input
|
||||
|
||||
## 0.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix a latent bug where a partial settings PUT silently reset live service state, and trim the `xterm-zerolag-input` README callout.
|
||||
- **`PUT /api/settings` no longer resets watchers on a partial body.** The three `toggleService` calls (subagent watcher, workflow-run watcher, image watcher) read the raw request body with `??` defaults, so every key a caller omitted was treated as "apply the default". A body of just `{statusLineTelemetry:true}` would START the subagent watcher and STOP the workflow and image watchers, undoing the persisted config. They now resolve from `merged` (persisted settings + incoming), the same convention the `tmuxHistoryLimit` branch in that handler already used, so any PUT reconciles services to the effective stored state. Nothing triggered this in practice because every shipped client sends a full settings payload rebuilt from the DOM, but it was a trap for the next partial-update caller.
|
||||
- **Regression test**: `test/routes/system-routes-settings-partial-put.test.ts` (4 cases) pins both directions, omitted keys preserve state and explicit keys still take effect. Verified to fail against the pre-fix handler.
|
||||
- **CLAUDE.md** records the rule under "Adding Features → App setting": anything acting on a setting in that handler must resolve from `merged`, never the request body.
|
||||
- **`xterm-zerolag-input` README**: removed the links line (getcodeman.com / install one-liner / star link) from the Codeman callout above the demo GIF. The callout keeps its links in the heading and body.
|
||||
|
||||
## 0.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Plan-usage chip now defaults ON on desktop, plus the reworked `xterm-zerolag-input` README.
|
||||
- **Plan-usage chip defaults ON (desktop).** The `showPlanUsageLimits` chip (live 5-hour and weekly plan usage from the Claude statusline) used to be opt-in and default OFF, so most users never saw it. Desktop now defaults ON; handhelds still default OFF so the phone header stays minimal and the `mobile-header-buttons-policy` guard keeps passing. Devices with an explicitly stored preference keep whatever they chose, so nobody's OFF gets overridden.
|
||||
- **One resolver behind the chip.** Added `planUsageChipEnabled()` in settings-ui.js and routed all three call sites through it: the App Settings checkbox, the chip's visibility, and the create-time `statusLineTelemetry` flag in session-ui.js. Those three had independent `?? false` / `=== true` defaults, and a chip revealed without the telemetry flag renders `—` forever, so a default flip on one site alone would have shipped a permanently empty chip.
|
||||
- **Cron button comment corrected.** The App Settings comment claimed "Cron button defaults ON" while the code, the template (`btn-cron--hidden`) and the CSS all default it OFF. Verified against a fresh browser profile: the button is hidden and its checkbox unchecked out of the box. Comment now matches, and states why the two halves stay consistent.
|
||||
- **Docs.** CLAUDE.md, `docs/architecture-invariants.md` and `docs/usage-limits-display-plan.md` updated for the new default and the single-resolver rule; the stale `styles.css` comment claiming the server strips the chip's hidden class at render was corrected (display is per-device, so the client reveals it).
|
||||
- **`xterm-zerolag-input` README rework** (0.1.5 shipped the content; this republishes with the graphic and promo changes): replaced the misaligned 8-line keystroke-flow diagram with a two-line stock-vs-zerolag contrast, added a Codeman callout above the demo GIF with links to getcodeman.com and the repo, and rewrote the Origin section so it argues the extraction story instead of repeating the promo.
|
||||
|
||||
## 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,45 +1,64 @@
|
||||
<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>
|
||||
|
||||
> ### Made for [**Codeman**](https://getcodeman.com)
|
||||
>
|
||||
> This overlay is the local echo engine of [**Codeman**](https://github.com/Ark0N/Codeman), mission control for AI coding agents: run and monitor a dozen Claude Code, Codex, OpenCode and Gemini sessions at once, watch their subagents work in live floating windows, let them run autonomously overnight, and drive all of it from your phone.
|
||||
>
|
||||
> That last part is why this library exists. The demo below is a real Codeman session on two phones.
|
||||
|
||||
<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.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
```
|
||||
Keystroke Flow:
|
||||
┌─── DOM overlay (instant, 0ms)
|
||||
User types 'h' ─── onData('h') ───┤
|
||||
└─── Your app sends to PTY ──→ Server
|
||||
│
|
||||
Server echoes 'h' ←──────────────────────────────────────────────────┘
|
||||
│ (200-500ms RTT)
|
||||
└──→ terminal.write('h') ──→ overlay.clear()
|
||||
(server output replaces overlay — seamless transition)
|
||||
stock xterm.js keypress ─────── 300 ms ───────→ character appears
|
||||
with zerolag keypress → character appears · echo lands later, unseen
|
||||
```
|
||||
|
||||
**No changes to your backend needed.** The addon is purely client-side.
|
||||
Same keystroke, same link. The only difference is who you wait for: the server, or nobody.
|
||||
|
||||
## Origin
|
||||
`xterm-zerolag-input` paints your keystrokes **immediately**, as an absolutely-positioned DOM overlay locked to the terminal's character grid. The byte still goes to the PTY exactly as before, so nothing about your shell changes. When the server echo lands 300ms later, the overlay clears and the real terminal text takes over on the same pixels. The handoff is invisible.
|
||||
|
||||
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.
|
||||
**No backend changes. No protocol. No server support.** It is a client-side addon that never touches the wire.
|
||||
|
||||
## Why this one
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **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 [Codeman](https://getcodeman.com), 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 +66,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 +77,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 +91,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 +103,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 +137,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 +169,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 +258,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 +286,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 +312,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 +334,7 @@ if (detected && detected !== baseline) {
|
||||
}
|
||||
```
|
||||
|
||||
### Resize / Font / Reconnect
|
||||
### Resize, font, reconnect
|
||||
|
||||
```typescript
|
||||
fitAddon.fit();
|
||||
@@ -311,14 +343,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 +375,61 @@ 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
|
||||
|
||||
[Codeman](https://getcodeman.com) needed this before anyone else did. A coding agent you drive from your phone over a tunnel is unusable if every keystroke costs a round trip.
|
||||
|
||||
So the overlay was built there, ran in production for thousands of hours, and survived three deep code audits before being pulled out into this standalone library with its tests intact. Nothing was reimplemented for the extraction: the engine here is the one Codeman ships.
|
||||
|
||||
Want the whole thing? [**getcodeman.com**](https://getcodeman.com) · [github.com/Ark0N/Codeman](https://github.com/Ark0N/Codeman)
|
||||
|
||||
## 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.7",
|
||||
"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));
|
||||
});
|
||||
|
||||
@@ -31,5 +31,10 @@ export const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000;
|
||||
// Hooks
|
||||
// ============================================================================
|
||||
|
||||
/** Timeout for Claude Code hook curl commands (ms) */
|
||||
export const HOOK_TIMEOUT_MS = 10000;
|
||||
/**
|
||||
* Timeout for Claude Code hook curl commands, in SECONDS: the hook `timeout`
|
||||
* field is seconds (the CLI multiplies by 1000). The predecessor constant
|
||||
* `HOOK_TIMEOUT_MS = 10000` fed the same field, so those hooks effectively had a
|
||||
* ~2.8-hour timeout; 10 seconds is the originally intended budget.
|
||||
*/
|
||||
export const HOOK_TIMEOUT_SECONDS = 10;
|
||||
|
||||
@@ -98,6 +98,14 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [
|
||||
usedBy: ['Gemini sessions'],
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['gemini'], versionArg: '--version' } }],
|
||||
},
|
||||
{
|
||||
id: 'antigravity',
|
||||
label: 'Antigravity CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['Antigravity sessions'],
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['agy'], versionArg: '--version' } }],
|
||||
},
|
||||
{
|
||||
id: 'libreoffice',
|
||||
label: 'LibreOffice',
|
||||
|
||||
@@ -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';
|
||||
@@ -143,6 +143,7 @@ export function defaultDockerCommandForMode(mode: SessionMode): string {
|
||||
opencode: 'exec opencode',
|
||||
codex: 'exec codex',
|
||||
gemini: 'exec gemini',
|
||||
antigravity: 'exec agy',
|
||||
};
|
||||
return commands[mode as DockerCommandMode] || commands.shell;
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
* `stop`, `teammate_idle`, `task_completed`
|
||||
*
|
||||
* Hook categories: `Notification` (3 matchers), `Stop` (1), `TeammateIdle` (1),
|
||||
* `TaskCompleted` (1)
|
||||
* `TaskCompleted` (1), `PostToolUse` (1 self-contained background Bash rewake)
|
||||
*
|
||||
* @dependencies types (HookEventType), config/auth-config (HOOK_TIMEOUT_MS)
|
||||
* @dependencies types (HookEventType), config/auth-config (HOOK_TIMEOUT_SECONDS)
|
||||
* @consumedby web/server (session creation), session-cli-builder (env setup)
|
||||
*
|
||||
* @module hooks-config
|
||||
@@ -29,7 +29,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { HookEventType } from './types.js';
|
||||
import { HOOK_TIMEOUT_MS } from './config/auth-config.js';
|
||||
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||
|
||||
/**
|
||||
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
||||
@@ -40,6 +40,104 @@ import { HOOK_TIMEOUT_MS } from './config/auth-config.js';
|
||||
* are independent; the map self-prunes when a path's chain goes idle.
|
||||
*/
|
||||
const settingsWriteLocks = new Map<string, Promise<unknown>>();
|
||||
/**
|
||||
* Version-agnostic ownership prefix: every rewake script version embeds a marker
|
||||
* starting with this, and `isCodemanHookHandler` matches on the prefix. That way a
|
||||
* version bump replaces the old handler instead of duplicating it (matching on the
|
||||
* full versioned marker would disown every older script).
|
||||
*/
|
||||
const BACKGROUND_WAKE_MARKER_PREFIX = 'CODEMAN_BACKGROUND_REWAKE_V';
|
||||
/**
|
||||
* Current script version. Bump the suffix whenever `generateBackgroundWakeScript`
|
||||
* changes: `refreshStaleCodemanHooks` treats the absence of the CURRENT marker as
|
||||
* stale, so healed cases pick up the new script on next launch.
|
||||
*/
|
||||
const BACKGROUND_WAKE_MARKER = `${BACKGROUND_WAKE_MARKER_PREFIX}2`;
|
||||
const BACKGROUND_WAKE_TIMEOUT_SECONDS = 6 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Inline Node helper for Claude Code's `asyncRewake` hook.
|
||||
*
|
||||
* A background Bash tool returns immediately with a task ID, then Claude writes
|
||||
* its completion as a queue-operation in the transcript. Watching that durable
|
||||
* record avoids injecting terminal input (which could submit a user's draft).
|
||||
* The helper is embedded in settings via `node -e`, so it has no script path
|
||||
* that can go stale after an install or plugin-cache cleanup.
|
||||
*
|
||||
* Self-terminating: Claude Code enforces the hook timeout, but the helper does not
|
||||
* rely on it. It exits on its own deadline (same budget) and when orphaned
|
||||
* (`ppid === 1`), so a dead session cannot leave a poller stat-ing the transcript
|
||||
* forever. The ppid check misses subreaper setups; the deadline is the backstop.
|
||||
*/
|
||||
export function generateBackgroundWakeScript(): string {
|
||||
return [
|
||||
"const fs = require('node:fs');",
|
||||
`const ${BACKGROUND_WAKE_MARKER} = true;`,
|
||||
`const deadline = Date.now() + ${BACKGROUND_WAKE_TIMEOUT_SECONDS} * 1000;`,
|
||||
'let input = {};',
|
||||
"try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { process.exit(0); }",
|
||||
'function findTaskId(value) {',
|
||||
" const idKeys = new Set(['taskId', 'task_id', 'shellId', 'shell_id', 'backgroundTaskId', 'background_task_id']);",
|
||||
' const stack = [value];',
|
||||
' const seen = new Set();',
|
||||
' while (stack.length > 0) {',
|
||||
' const current = stack.pop();',
|
||||
" if (!current || typeof current !== 'object' || seen.has(current)) continue;",
|
||||
' seen.add(current);',
|
||||
' for (const [key, nested] of Object.entries(current)) {',
|
||||
" if (idKeys.has(key) && typeof nested === 'string' && /^[A-Za-z0-9_-]+$/.test(nested)) return nested;",
|
||||
" if (nested && typeof nested === 'object') stack.push(nested);",
|
||||
' }',
|
||||
' }',
|
||||
" const serialized = JSON.stringify(value ?? '');",
|
||||
' const messageMatch = serialized.match(/Command running in background with ID:\\s*([A-Za-z0-9_-]+)/i);',
|
||||
' if (messageMatch) return messageMatch[1];',
|
||||
' const pathMatch = serialized.match(/[\\\\/]tasks[\\\\/]([A-Za-z0-9_-]+)\\.output/i);',
|
||||
' return pathMatch ? pathMatch[1] : null;',
|
||||
'}',
|
||||
'const taskId = findTaskId(input.tool_response);',
|
||||
"const transcriptPath = typeof input.transcript_path === 'string' ? input.transcript_path : '';",
|
||||
'if (!taskId || !transcriptPath) process.exit(0);',
|
||||
'let position = 0;',
|
||||
'try { position = Math.max(0, fs.statSync(transcriptPath).size - 262144); } catch { process.exit(0); }',
|
||||
"let carry = '';",
|
||||
'function inspect(text) {',
|
||||
' for (const line of text.split(/\\r?\\n/)) {',
|
||||
' if (!line.includes(taskId)) continue;',
|
||||
' let entry;',
|
||||
' try { entry = JSON.parse(line); } catch { continue; }',
|
||||
" if (entry.type !== 'queue-operation' || typeof entry.content !== 'string') continue;",
|
||||
" if (!entry.content.includes('<task-id>' + taskId + '</task-id>')) continue;",
|
||||
' const status = entry.content.match(/<status>(completed|failed|killed|error)<\\/status>/i);',
|
||||
' if (!status) continue;',
|
||||
' const output = entry.content.match(/<output-file>([^<]+)<\\/output-file>/i);',
|
||||
" const location = output ? ' Read ' + output[1] + ' and' : '';",
|
||||
" console.error('Background command ' + taskId + ' ' + status[1].toLowerCase() + '.' + location + ' continue the task.');",
|
||||
' process.exit(2);',
|
||||
' }',
|
||||
'}',
|
||||
'function poll() {',
|
||||
' if (Date.now() > deadline || process.ppid === 1) process.exit(0);',
|
||||
' try {',
|
||||
' const size = fs.statSync(transcriptPath).size;',
|
||||
" if (size < position) { position = 0; carry = ''; }",
|
||||
' if (size > position) {',
|
||||
' const length = Math.min(size - position, 1048576);',
|
||||
' const buffer = Buffer.allocUnsafe(length);',
|
||||
" const fd = fs.openSync(transcriptPath, 'r');",
|
||||
' const bytes = fs.readSync(fd, buffer, 0, length, position);',
|
||||
' fs.closeSync(fd);',
|
||||
' position += bytes;',
|
||||
" carry = (carry + buffer.subarray(0, bytes).toString('utf8')).slice(-262144);",
|
||||
' inspect(carry);',
|
||||
' }',
|
||||
' } catch {}',
|
||||
' setTimeout(poll, 1000);',
|
||||
'}',
|
||||
'poll();',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function withSettingsLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = settingsWriteLocks.get(path) ?? Promise.resolve();
|
||||
const run = prev.then(fn, fn); // run after the prior writer, regardless of its outcome
|
||||
@@ -86,36 +184,118 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||
Notification: [
|
||||
{
|
||||
matcher: 'idle_prompt',
|
||||
hooks: [{ type: 'command', command: curlCmd('idle_prompt'), timeout: HOOK_TIMEOUT_MS }],
|
||||
hooks: [{ type: 'command', command: curlCmd('idle_prompt'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
},
|
||||
{
|
||||
matcher: 'permission_prompt',
|
||||
hooks: [{ type: 'command', command: curlCmd('permission_prompt'), timeout: HOOK_TIMEOUT_MS }],
|
||||
hooks: [{ type: 'command', command: curlCmd('permission_prompt'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
},
|
||||
{
|
||||
matcher: 'elicitation_dialog',
|
||||
hooks: [{ type: 'command', command: curlCmd('elicitation_dialog'), timeout: HOOK_TIMEOUT_MS }],
|
||||
hooks: [{ type: 'command', command: curlCmd('elicitation_dialog'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
},
|
||||
],
|
||||
Stop: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: curlCmd('stop'), timeout: HOOK_TIMEOUT_MS }],
|
||||
hooks: [{ type: 'command', command: curlCmd('stop'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
},
|
||||
],
|
||||
TeammateIdle: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: curlCmd('teammate_idle'), timeout: HOOK_TIMEOUT_MS }],
|
||||
hooks: [{ type: 'command', command: curlCmd('teammate_idle'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
},
|
||||
],
|
||||
TaskCompleted: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: curlCmd('task_completed'), timeout: HOOK_TIMEOUT_MS }],
|
||||
hooks: [{ type: 'command', command: curlCmd('task_completed'), timeout: HOOK_TIMEOUT_SECONDS }],
|
||||
},
|
||||
],
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node',
|
||||
args: ['-e', generateBackgroundWakeScript()],
|
||||
asyncRewake: true,
|
||||
timeout: BACKGROUND_WAKE_TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isCodemanHookHandler(value: unknown): boolean {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
// Prefix, not the versioned marker: older script versions must still be ours.
|
||||
return serialized.includes('/api/hook-event') || serialized.includes(BACKGROUND_WAKE_MARKER_PREFIX);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace only Codeman-owned command handlers while preserving user events,
|
||||
* matcher entries, and sibling handlers in mixed entries.
|
||||
*/
|
||||
function mergeCodemanHooks(existingValue: unknown, generated: Record<string, unknown[]>): Record<string, unknown[]> {
|
||||
const existing =
|
||||
existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue)
|
||||
? (existingValue as Record<string, unknown>)
|
||||
: {};
|
||||
const merged: Record<string, unknown[]> = {};
|
||||
|
||||
for (const eventName of new Set([...Object.keys(existing), ...Object.keys(generated)])) {
|
||||
const existingEntries = Array.isArray(existing[eventName]) ? (existing[eventName] as unknown[]) : [];
|
||||
const generatedEntries = generated[eventName];
|
||||
if (!generatedEntries) {
|
||||
merged[eventName] = existingEntries;
|
||||
continue;
|
||||
}
|
||||
|
||||
const entries: unknown[] = [];
|
||||
let insertedGenerated = false;
|
||||
for (const entry of existingEntries) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
if (!isCodemanHookHandler(entry)) entries.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = entry as Record<string, unknown>;
|
||||
if (!Array.isArray(record.hooks)) {
|
||||
if (isCodemanHookHandler(record)) {
|
||||
if (!insertedGenerated) {
|
||||
entries.push(...generatedEntries);
|
||||
insertedGenerated = true;
|
||||
}
|
||||
} else {
|
||||
entries.push(entry);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const retainedHandlers = record.hooks.filter((handler) => !isCodemanHookHandler(handler));
|
||||
const removedCodemanHandler = retainedHandlers.length !== record.hooks.length;
|
||||
if (removedCodemanHandler && !insertedGenerated) {
|
||||
entries.push(...generatedEntries);
|
||||
insertedGenerated = true;
|
||||
}
|
||||
if (retainedHandlers.length > 0 || !removedCodemanHandler) {
|
||||
entries.push(retainedHandlers.length === record.hooks.length ? entry : { ...record, hooks: retainedHandlers });
|
||||
}
|
||||
}
|
||||
|
||||
if (!insertedGenerated) entries.push(...generatedEntries);
|
||||
merged[eventName] = entries;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a subset of env keys from .claude/settings.local.json.env if present.
|
||||
* Used during the disk→tmux-setenv migration: when the caller is actively setting
|
||||
@@ -237,29 +417,31 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
|
||||
}
|
||||
|
||||
const hooksConfig = generateHooksConfig();
|
||||
const merged = { ...existing, ...hooksConfig };
|
||||
const merged = {
|
||||
...existing,
|
||||
hooks: mergeCodemanHooks(existing.hooks, hooksConfig.hooks),
|
||||
};
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-heal a case's hooks block so the COD-91 unconditional hook-secret gate keeps
|
||||
* accepting its hook events.
|
||||
* Self-heal a case's Codeman-owned hooks block.
|
||||
*
|
||||
* `writeHooksConfig` only runs when a case is first CREATED. Cases created before the
|
||||
* X-Codeman-Hook-Secret header was added (COD-54, 2026-06-10) keep hook curls in their
|
||||
* settings.local.json that POST to /api/hook-event WITHOUT the secret — which, once the
|
||||
* gate requires it unconditionally (COD-91), silently 401 on a password-protected
|
||||
* install. This refreshes the hooks block so those stale curls regain the header.
|
||||
* gate requires it unconditionally (COD-91), silently 401 on a password-protected install.
|
||||
* Older Codeman blocks also lack the background Bash async-rewake hook. Refresh either
|
||||
* stale shape on launch so existing cases gain both current behaviors.
|
||||
*
|
||||
* Deliberately surgical: regenerates ONLY when settings.local.json already contains
|
||||
* Codeman's own hook curls (they target `/api/hook-event`) that lack the secret header.
|
||||
* No-op when the file/hooks are absent (we never impose hooks on a user who removed
|
||||
* them), when the hooks aren't ours, or when the secret is already present — so it never
|
||||
* clobbers a user's customizations and is cheap enough to call on every Claude spawn.
|
||||
* Codeman's own hook curls (they target `/api/hook-event`) and they are stale. No-op
|
||||
* when the file/hooks are absent (we never impose hooks on a user who removed them) or
|
||||
* when the hooks aren't ours, so it is cheap enough to call on every Claude spawn.
|
||||
*/
|
||||
export async function refreshStaleHookSecret(casePath: string): Promise<void> {
|
||||
export async function refreshStaleCodemanHooks(casePath: string): Promise<void> {
|
||||
const settingsPath = join(casePath, '.claude', 'settings.local.json');
|
||||
if (!existsSync(settingsPath)) return;
|
||||
await withSettingsLock(settingsPath, async () => {
|
||||
@@ -274,8 +456,13 @@ export async function refreshStaleHookSecret(casePath: string): Promise<void> {
|
||||
// The generated curl carries this header literal (see generateHooksConfig); its
|
||||
// absence on our own hooks means they predate COD-54 and need regenerating.
|
||||
const hasSecret = hooksJson.includes('X-Codeman-Hook-Secret');
|
||||
if (!isOurs || hasSecret) return;
|
||||
const merged = { ...existing, ...generateHooksConfig() };
|
||||
const hasBackgroundWake = hooksJson.includes(BACKGROUND_WAKE_MARKER);
|
||||
if (!isOurs || (hasSecret && hasBackgroundWake)) return;
|
||||
const generated = generateHooksConfig();
|
||||
const merged = {
|
||||
...existing,
|
||||
hooks: mergeCodemanHooks(existing.hooks, generated.hooks),
|
||||
};
|
||||
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
CodexConfig,
|
||||
EffortLevel,
|
||||
GeminiConfig,
|
||||
AntigravityConfig,
|
||||
SessionRemote,
|
||||
SessionDocker,
|
||||
} from './types.js';
|
||||
@@ -74,6 +75,7 @@ export interface CreateSessionOptions {
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
codexConfig?: CodexConfig;
|
||||
geminiConfig?: GeminiConfig;
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
|
||||
@@ -102,6 +104,7 @@ export interface RespawnPaneOptions {
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
codexConfig?: CodexConfig;
|
||||
geminiConfig?: GeminiConfig;
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
/** Resume a previous Claude conversation when respawning */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
RemoteCase,
|
||||
RemoteCommandMode,
|
||||
RemoteHost,
|
||||
RemoteSessionInfo,
|
||||
RemoteSshOptions,
|
||||
SessionMode,
|
||||
SessionRemote,
|
||||
@@ -67,6 +68,7 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string {
|
||||
opencode: 'exec opencode',
|
||||
codex: 'exec codex',
|
||||
gemini: 'exec gemini',
|
||||
antigravity: 'exec agy',
|
||||
};
|
||||
return commands[mode as RemoteCommandMode] || commands.shell;
|
||||
}
|
||||
@@ -173,6 +175,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 +212,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 +331,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 +343,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;
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
type CodexConfig,
|
||||
type EffortLevel,
|
||||
type GeminiConfig,
|
||||
type AntigravityConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
} from './types.js';
|
||||
@@ -140,7 +141,7 @@ const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
||||
|
||||
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
|
||||
export function isExternalCliMode(mode: SessionMode): boolean {
|
||||
return mode === 'opencode' || mode === 'codex' || mode === 'gemini';
|
||||
return mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity';
|
||||
}
|
||||
|
||||
function getModeLabel(mode: SessionMode): string {
|
||||
@@ -151,6 +152,8 @@ function getModeLabel(mode: SessionMode): string {
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'antigravity':
|
||||
return 'Antigravity';
|
||||
case 'shell':
|
||||
return 'Shell';
|
||||
case 'claude':
|
||||
@@ -180,6 +183,13 @@ export function isAltScreenStripMode(mode: SessionMode): boolean {
|
||||
const DEFAULT_PTY_COLS = 120;
|
||||
const DEFAULT_PTY_ROWS = 40;
|
||||
const TMUX_DISPLAY_TIMEOUT_MS = 2000;
|
||||
const IS_TEST_MODE = !!process.env.VITEST;
|
||||
/**
|
||||
* Echo transport for the test-mode PTY attach. Raw mode disables the tty line
|
||||
* discipline, so each input byte flows back exactly once and immediately; without
|
||||
* it, tty echo doubles every line and canonical buffering holds bytes until Enter.
|
||||
*/
|
||||
const TEST_PTY_SCRIPT = 'if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.pipe(process.stdout);';
|
||||
/** Delay before the in-container Claude CLI version probe (lets the container start). */
|
||||
const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000;
|
||||
|
||||
@@ -342,6 +352,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;
|
||||
|
||||
@@ -391,6 +406,8 @@ export class Session extends EventEmitter {
|
||||
private _codexConfig: CodexConfig | undefined;
|
||||
// Gemini configuration (only for mode === 'gemini')
|
||||
private _geminiConfig: GeminiConfig | undefined;
|
||||
// Antigravity configuration (only for mode === 'antigravity')
|
||||
private _antigravityConfig: AntigravityConfig | undefined;
|
||||
private _resumeSessionId: string | undefined;
|
||||
|
||||
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
||||
@@ -477,6 +494,8 @@ export class Session extends EventEmitter {
|
||||
codexConfig?: CodexConfig;
|
||||
/** Gemini configuration (only for mode === 'gemini') */
|
||||
geminiConfig?: GeminiConfig;
|
||||
/** Antigravity configuration (only for mode === 'antigravity') */
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
/** Resume a previous Claude conversation (used after server reboot) */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
||||
@@ -550,6 +569,11 @@ export class Session extends EventEmitter {
|
||||
this._geminiConfig = config.geminiConfig;
|
||||
}
|
||||
|
||||
// Apply Antigravity configuration
|
||||
if (config.antigravityConfig) {
|
||||
this._antigravityConfig = config.antigravityConfig;
|
||||
}
|
||||
|
||||
// Apply env overrides (exported at spawn, not persisted to disk).
|
||||
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
|
||||
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
|
||||
@@ -992,6 +1016,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 +1102,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,
|
||||
@@ -1077,6 +1123,7 @@ export class Session extends EventEmitter {
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
geminiConfig: this._geminiConfig,
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
effort: this._effort,
|
||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||
@@ -1221,23 +1268,30 @@ export class Session extends EventEmitter {
|
||||
// No extra sleep — createSession() already waits for tmux readiness
|
||||
}
|
||||
|
||||
// Attach to the mux session via PTY
|
||||
// Prevent tmux from letting the newest browser attach dictate global window
|
||||
// size; accepted Codeman resize events update it explicitly below.
|
||||
mux.setManualWindowSize?.(this._muxSession!.muxName);
|
||||
// Integration tests need a live input/output transport without attaching to
|
||||
// the host's tmux server or agent CLI. Production still uses the real mux.
|
||||
if (!IS_TEST_MODE) {
|
||||
// Prevent tmux from letting the newest browser attach dictate global window
|
||||
// size; accepted Codeman resize events update it explicitly below.
|
||||
mux.setManualWindowSize?.(this._muxSession!.muxName);
|
||||
}
|
||||
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default).
|
||||
// MUST go through the dedicated socket (mux.muxSocket); a bare `tmux display` hits the
|
||||
// default server, always fails for our socketed sessions, and silently falls back to 120x40.
|
||||
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
|
||||
const { cols: ptyCols, rows: ptyRows } = IS_TEST_MODE
|
||||
? { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS }
|
||||
: queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
|
||||
const attachCommand = IS_TEST_MODE ? process.execPath : mux.getAttachCommand();
|
||||
const attachArgs = IS_TEST_MODE ? ['-e', TEST_PTY_SCRIPT] : mux.getAttachArgs(this._muxSession!.muxName);
|
||||
try {
|
||||
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
|
||||
this.ptyProcess = pty.spawn(attachCommand, attachArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols: ptyCols,
|
||||
rows: ptyRows,
|
||||
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
|
||||
// COD-75: codex/gemini get COLORTERM=truecolor — mirrors buildEnvExports()
|
||||
// COD-75: codex/gemini/antigravity get COLORTERM=truecolor — mirrors buildEnvExports()
|
||||
// in tmux-manager.ts so the attach client and the tmux session agree.
|
||||
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini'),
|
||||
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini' || this.mode === 'antigravity'),
|
||||
});
|
||||
} catch (spawnErr) {
|
||||
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
||||
@@ -1248,6 +1302,71 @@ 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,
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
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 +1515,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,
|
||||
@@ -1427,6 +1529,7 @@ export class Session extends EventEmitter {
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
codexConfig: this._codexConfig,
|
||||
geminiConfig: this._geminiConfig,
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
@@ -1508,6 +1611,10 @@ export class Session extends EventEmitter {
|
||||
if (this.mode === 'gemini') {
|
||||
throw new Error('Gemini sessions require tmux. Direct PTY fallback is not supported.');
|
||||
}
|
||||
// Antigravity sessions require tmux for env override injection via setenv
|
||||
if (this.mode === 'antigravity') {
|
||||
throw new Error('Antigravity sessions require tmux. Direct PTY fallback is not supported.');
|
||||
}
|
||||
try {
|
||||
// Pass --session-id to use the SAME ID as the Codeman session
|
||||
// This ensures subagents can be directly matched to the correct tab
|
||||
@@ -2609,7 +2716,7 @@ export class Session extends EventEmitter {
|
||||
if (this.ptyProcess && (dimsChanged || options.force)) {
|
||||
this._ptyCols = cols;
|
||||
this._ptyRows = rows;
|
||||
if (this._mux && this._muxSession) {
|
||||
if (!IS_TEST_MODE && this._mux && this._muxSession) {
|
||||
this._mux.resizeWindow?.(this._muxSession.muxName, cols, rows);
|
||||
}
|
||||
this.ptyProcess.resize(cols, rows);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type CodexConfig,
|
||||
type EffortLevel,
|
||||
type GeminiConfig,
|
||||
type AntigravityConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
type DockerCommandMode,
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
resolveOpenCodeDir,
|
||||
resolveCodexDir,
|
||||
resolveGeminiDir,
|
||||
resolveAntigravityDir,
|
||||
} from './utils/index.js';
|
||||
import type {
|
||||
TerminalMultiplexer,
|
||||
@@ -78,6 +80,13 @@ import type {
|
||||
RespawnPaneOptions,
|
||||
PaneCaptureOptions,
|
||||
} from './mux-interface.js';
|
||||
import {
|
||||
decideReconnect,
|
||||
advanceBackoff,
|
||||
freshReconnectState,
|
||||
resetReconnectState,
|
||||
type RemoteReconnectState,
|
||||
} from './remote-reconnect.js';
|
||||
|
||||
// ============================================================================
|
||||
// Timing Constants
|
||||
@@ -110,6 +119,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 +146,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-]+$/;
|
||||
|
||||
@@ -658,6 +684,34 @@ function buildGeminiCommand(config?: GeminiConfig): string {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Antigravity CLI (agy) command with appropriate flags.
|
||||
*
|
||||
* Unlike gemini's yolo default, `--dangerously-skip-permissions` is only added
|
||||
* when the config explicitly asks for it (the frontend sends it for parity with
|
||||
* Codeman's Claude default; the multi-user clamp strips it for non-granted owners,
|
||||
* and an ABSENT config stays at agy's own prompting default — safe like Codex).
|
||||
*/
|
||||
function buildAntigravityCommand(config?: AntigravityConfig): string {
|
||||
const parts = ['agy'];
|
||||
|
||||
if (config?.dangerouslySkipPermissions) {
|
||||
parts.push('--dangerously-skip-permissions');
|
||||
}
|
||||
|
||||
if (config?.model) {
|
||||
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
|
||||
if (safeModel) parts.push('--model', safeModel);
|
||||
}
|
||||
|
||||
if (config?.resumeConversationId) {
|
||||
const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeConversationId) ? config.resumeConversationId : undefined;
|
||||
if (safeId) parts.push('--conversation', safeId);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the spawn command for any session mode.
|
||||
* Shared by createSession() and respawnPane() to avoid duplication.
|
||||
@@ -685,6 +739,7 @@ export function buildSpawnCommand(options: {
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
codexConfig?: CodexConfig;
|
||||
geminiConfig?: GeminiConfig;
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
resumeSessionId?: string;
|
||||
effort?: EffortLevel;
|
||||
}): string {
|
||||
@@ -715,6 +770,9 @@ export function buildSpawnCommand(options: {
|
||||
if (options.mode === 'gemini') {
|
||||
return buildGeminiCommand(options.geminiConfig);
|
||||
}
|
||||
if (options.mode === 'antigravity') {
|
||||
return buildAntigravityCommand(options.antigravityConfig);
|
||||
}
|
||||
return '$SHELL';
|
||||
}
|
||||
|
||||
@@ -812,6 +870,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
|
||||
@@ -886,6 +951,8 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
|
||||
return `${modeCommand} --resume ${resumeId}`;
|
||||
case 'codex':
|
||||
return `${modeCommand} resume ${resumeId}`;
|
||||
case 'antigravity':
|
||||
return `${modeCommand} --conversation ${resumeId}`;
|
||||
default:
|
||||
return modeCommand; // shell / opencode: no resume
|
||||
}
|
||||
@@ -1132,6 +1199,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 +1402,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() {
|
||||
@@ -1393,8 +1521,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const exports = [
|
||||
'export LANG=en_US.UTF-8',
|
||||
'export LC_ALL=en_US.UTF-8',
|
||||
mode === 'codex' || mode === 'gemini' ? 'export COLORTERM=truecolor' : 'unset COLORTERM',
|
||||
...(mode === 'codex' || mode === 'gemini' ? ['unset NO_COLOR'] : []),
|
||||
mode === 'codex' || mode === 'gemini' || mode === 'antigravity'
|
||||
? 'export COLORTERM=truecolor'
|
||||
: 'unset COLORTERM',
|
||||
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' ? ['unset NO_COLOR'] : []),
|
||||
// Stamp each Codex pane with a unique originator so the response-viewer
|
||||
// can locate THIS pane's rollout exactly — codex writes the value into
|
||||
// session_meta.originator of every rollout it creates. Without it,
|
||||
@@ -1477,6 +1607,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const dir = resolveGeminiDir();
|
||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||
}
|
||||
if (mode === 'antigravity') {
|
||||
const dir = resolveAntigravityDir();
|
||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||
}
|
||||
return { pathExport: '', dir: null };
|
||||
}
|
||||
|
||||
@@ -1524,6 +1658,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
@@ -1575,6 +1710,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (mode === 'gemini' && !cliDir) {
|
||||
throw new Error('Gemini CLI not found. Install with: npm install -g @google/gemini-cli');
|
||||
}
|
||||
if (mode === 'antigravity' && !cliDir) {
|
||||
throw new Error(
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
}
|
||||
|
||||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||
|
||||
@@ -1587,6 +1727,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
});
|
||||
@@ -1600,7 +1741,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)
|
||||
@@ -1809,6 +1950,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
@@ -1846,6 +1988,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
});
|
||||
@@ -1855,7 +1998,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 +2114,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 +2135,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 +2273,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 +2340,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 +2612,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 {
|
||||
|
||||
@@ -40,6 +40,7 @@ interface TranscriptContentBlock {
|
||||
text?: string;
|
||||
name?: string;
|
||||
input?: Record<string, unknown>;
|
||||
tool_use_id?: string;
|
||||
content?: string;
|
||||
is_error?: boolean;
|
||||
}
|
||||
@@ -328,10 +329,7 @@ export class TranscriptWatcher extends EventEmitter {
|
||||
this.handleResultEntry(entry);
|
||||
break;
|
||||
case 'user':
|
||||
// User message means new turn, reset some state
|
||||
this.state.isComplete = false;
|
||||
this.state.hasError = false;
|
||||
this.state.errorMessage = null;
|
||||
this.handleUserEntry(entry);
|
||||
break;
|
||||
case 'system':
|
||||
// System messages are informational
|
||||
@@ -360,23 +358,42 @@ export class TranscriptWatcher extends EventEmitter {
|
||||
this.state.currentTool = block.name;
|
||||
this.emit('transcript:tool_start', block.name);
|
||||
} else if (block.type === 'tool_result') {
|
||||
// Tool completed
|
||||
const wasError = block.is_error === true;
|
||||
const toolName = this.state.currentTool;
|
||||
this.state.toolExecuting = false;
|
||||
this.state.currentTool = null;
|
||||
if (toolName) {
|
||||
this.emit('transcript:tool_end', toolName, wasError);
|
||||
}
|
||||
if (wasError && block.content) {
|
||||
this.state.hasError = true;
|
||||
this.state.errorMessage = String(block.content).slice(0, 200);
|
||||
}
|
||||
this.handleToolResult(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleUserEntry(entry: TranscriptEntry): void {
|
||||
// A user-authored prompt starts a turn, while Claude tool results also use
|
||||
// user entries. Reset turn state first, then close any completed tool.
|
||||
this.state.isComplete = false;
|
||||
this.state.hasError = false;
|
||||
this.state.errorMessage = null;
|
||||
|
||||
const content = entry.message?.content;
|
||||
if (!Array.isArray(content)) return;
|
||||
for (const block of content) {
|
||||
if (block.type === 'tool_result') {
|
||||
this.handleToolResult(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleToolResult(block: TranscriptContentBlock): void {
|
||||
const wasError = block.is_error === true;
|
||||
const toolName = this.state.currentTool;
|
||||
this.state.toolExecuting = false;
|
||||
this.state.currentTool = null;
|
||||
if (toolName) {
|
||||
this.emit('transcript:tool_end', toolName, wasError);
|
||||
}
|
||||
if (wasError && block.content) {
|
||||
this.state.hasError = true;
|
||||
this.state.errorMessage = String(block.content).slice(0, 200);
|
||||
}
|
||||
}
|
||||
|
||||
private handleResultEntry(entry: TranscriptEntry): void {
|
||||
// Result entry indicates completion
|
||||
this.state.isComplete = true;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||
* - SessionOutput — captured stdout/stderr/exitCode
|
||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend)
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' (which CLI backend)
|
||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
|
||||
* - SessionColor — visual differentiation color
|
||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||
* - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId)
|
||||
* - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession)
|
||||
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
|
||||
@@ -42,9 +43,12 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
||||
export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
|
||||
|
||||
/** Session mode: which CLI backend a session runs */
|
||||
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini';
|
||||
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity';
|
||||
|
||||
export type RemoteCommandMode = Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
|
||||
export type RemoteCommandMode = Extract<
|
||||
SessionMode,
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Advanced SSH connection options shared by RemoteHost and SessionRemote.
|
||||
@@ -99,6 +103,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) ==========
|
||||
@@ -111,7 +154,10 @@ export interface SessionRemote extends RemoteSshOptions {
|
||||
// into the same long-lived container. See `docs/docker-cases-plan.md`.
|
||||
|
||||
/** Which CLI backends a Docker case can run (same set as remote). */
|
||||
export type DockerCommandMode = Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
|
||||
export type DockerCommandMode = Extract<
|
||||
SessionMode,
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity'
|
||||
>;
|
||||
|
||||
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
|
||||
export type DockerEngine = 'docker' | 'podman';
|
||||
@@ -273,6 +319,16 @@ export interface GeminiConfig {
|
||||
resumeSession?: string;
|
||||
}
|
||||
|
||||
/** Antigravity CLI (agy) session configuration */
|
||||
export interface AntigravityConfig {
|
||||
/** Model identifier. Passed via --model. */
|
||||
model?: string;
|
||||
/** Auto-approve all tool permission requests (passes --dangerously-skip-permissions). Absent = agy's default prompting. */
|
||||
dangerouslySkipPermissions?: boolean;
|
||||
/** Resume a previous conversation by ID (passed via --conversation). */
|
||||
resumeConversationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for creating a new session
|
||||
*/
|
||||
@@ -366,6 +422,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 */
|
||||
@@ -410,6 +470,8 @@ export interface SessionState {
|
||||
codexConfig?: CodexConfig;
|
||||
/** Gemini-specific configuration (only for mode === 'gemini') */
|
||||
geminiConfig?: GeminiConfig;
|
||||
/** Antigravity-specific configuration (only for mode === 'antigravity') */
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
||||
resumeSessionId?: string;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @fileoverview Resolve the Antigravity CLI (`agy`) binary across common install paths.
|
||||
*
|
||||
* Mirrors gemini-cli-resolver.ts. Google's installer (antigravity.google/cli/install.sh)
|
||||
* places the binary at ~/.local/bin/agy; the other locations cover manual installs.
|
||||
*
|
||||
* @module utils/antigravity-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
|
||||
/** Common directories where the Antigravity CLI binary may be installed */
|
||||
const ANTIGRAVITY_SEARCH_DIRS = [
|
||||
join(homedir(), '.local', 'bin'),
|
||||
join(homedir(), '.antigravity', 'bin'),
|
||||
'/usr/local/bin',
|
||||
join(homedir(), 'bin'),
|
||||
];
|
||||
|
||||
/** Cached directory containing the agy binary (empty string = searched but not found) */
|
||||
let _antigravityDir: string | null = null;
|
||||
|
||||
/**
|
||||
* Finds the directory containing the `agy` binary.
|
||||
* Checks `which agy` first, then falls back to common install locations.
|
||||
*
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveAntigravityDir(): string | null {
|
||||
if (_antigravityDir !== null) return _antigravityDir || null;
|
||||
|
||||
try {
|
||||
const result = execSync('which agy', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_antigravityDir = dirname(result);
|
||||
return _antigravityDir;
|
||||
}
|
||||
} catch {
|
||||
// agy not in PATH, will check common locations
|
||||
}
|
||||
|
||||
for (const dir of ANTIGRAVITY_SEARCH_DIRS) {
|
||||
if (existsSync(join(dir, 'agy'))) {
|
||||
_antigravityDir = dir;
|
||||
return _antigravityDir;
|
||||
}
|
||||
}
|
||||
|
||||
_antigravityDir = '';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Antigravity CLI is available on the system.
|
||||
*/
|
||||
export function isAntigravityAvailable(): boolean {
|
||||
return resolveAntigravityDir() !== null;
|
||||
}
|
||||
@@ -30,3 +30,4 @@ export { findClaudeDir, getAugmentedPath, getClaudeCliVersion } from './claude-c
|
||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
||||
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
|
||||
export { resolveAntigravityDir, isAntigravityAvailable } from './antigravity-cli-resolver.js';
|
||||
|
||||
@@ -60,7 +60,7 @@ export function stripAnsi(text: string): string {
|
||||
*/
|
||||
export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/;
|
||||
|
||||
export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_/\-. ~]+$/;
|
||||
export const SAFE_PATH_PATTERN = /^[\p{L}\p{N}_/\-. ~]+$/u;
|
||||
|
||||
/**
|
||||
* Execute a global regex pattern against data, calling the callback for each match.
|
||||
|
||||
@@ -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,8 +797,13 @@ class CodemanApp {
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.restorePlanUsageChip();
|
||||
this.applySkin();
|
||||
this.applyLocalization();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
// Must run before the first session:created can arrive: markSessionTabEntering()
|
||||
// ignores ids until this sets up its state, which is what keeps the tabs
|
||||
// restored on page load from animating.
|
||||
this.initEntranceAnimations?.();
|
||||
// Remove mobile-init class now that JS has applied visibility settings.
|
||||
// The inline <script> in <head> added this to prevent flash-of-content on mobile.
|
||||
document.documentElement.classList.remove('mobile-init');
|
||||
@@ -813,6 +828,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 +861,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 +1015,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 +1326,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 +1344,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 +1499,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 +1527,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();
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1516,6 +1567,12 @@ class CodemanApp {
|
||||
this.sessionOrder.push(data.id);
|
||||
this.saveSessionOrder();
|
||||
}
|
||||
// Idempotent per id: the POST response and the session:created event both
|
||||
// land here, and a batch launched together cascades in creation order.
|
||||
this.markSessionTabEntering?.(data.id);
|
||||
// The pane is one shared element, so it is only marked here and played when
|
||||
// this session is actually selected (see selectSession).
|
||||
this.markTerminalEntering?.(data.id);
|
||||
this.renderSessionTabs();
|
||||
this.updateCost();
|
||||
// Start stats polling when first session appears
|
||||
@@ -1888,7 +1945,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
|
||||
@@ -1925,7 +1984,7 @@ class CodemanApp {
|
||||
// Render conversation thread
|
||||
const mode = this.sessions.get(this.activeSessionId)?.mode;
|
||||
const agentLabel =
|
||||
mode === 'codex' ? 'Codex' : mode === 'gemini' ? 'Gemini' : mode === 'opencode' ? 'OpenCode' : 'Claude';
|
||||
mode === 'codex' ? 'Codex' : mode === 'gemini' ? 'Gemini' : mode === 'antigravity' ? 'Antigravity' : mode === 'opencode' ? 'OpenCode' : 'Claude';
|
||||
body.innerHTML = '';
|
||||
for (const msg of messages) {
|
||||
const div = document.createElement('div');
|
||||
@@ -3019,6 +3078,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 +3295,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 +3317,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 };
|
||||
@@ -3370,6 +3453,9 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
this.updateTabOverflowMode();
|
||||
// After the wrap measurement: the `unroll` style starts tabs at max-width 0,
|
||||
// so measuring mid-animation would decide the wrap on collapsed widths.
|
||||
this._applyTabEntrances?.();
|
||||
}
|
||||
|
||||
// Auto-wrap desktop session tabs to a second row when they overflow one row,
|
||||
@@ -3426,7 +3512,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';
|
||||
@@ -3457,7 +3545,7 @@ class CodemanApp {
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : ''}
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
|
||||
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||
</span>
|
||||
@@ -3473,6 +3561,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
|
||||
@@ -3489,6 +3582,9 @@ class CodemanApp {
|
||||
// toggle (applyTabWrapSettings calls this) which would otherwise leave a stale
|
||||
// tabs-auto-wrap class until the next content render.
|
||||
this.updateTabOverflowMode();
|
||||
// Newly created tabs animate in; a re-render mid-cascade resumes them rather
|
||||
// than restarting, since this rebuild just destroyed the animating elements.
|
||||
this._applyTabEntrances?.();
|
||||
}
|
||||
|
||||
// Set up arrow key navigation for session tabs (accessibility)
|
||||
@@ -3583,13 +3679,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 +4103,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 {}
|
||||
@@ -3988,6 +4116,10 @@ class CodemanApp {
|
||||
// selectSession or reconnect catches up.
|
||||
this._updateSseSubscription(sessionId);
|
||||
this.hideWelcome();
|
||||
// Terminal-pane entrance: plays for a freshly created session, and on every
|
||||
// switch when that option is on. Transform/opacity/clip-path only, xterm's
|
||||
// FitAddon reads the untransformed layout box, so this cannot reach the PTY.
|
||||
this.playTerminalEntrance?.(sessionId);
|
||||
// Clear idle hooks on view, but keep action hooks until user interacts
|
||||
this.clearPendingHooks(sessionId, 'idle_prompt');
|
||||
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
|
||||
@@ -4498,7 +4630,9 @@ class CodemanApp {
|
||||
? 'Kill Tmux & Codex'
|
||||
: session.mode === 'gemini'
|
||||
? 'Kill Tmux & Gemini'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
: session.mode === 'antigravity'
|
||||
? 'Kill Tmux & Antigravity'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
}
|
||||
|
||||
document.getElementById('closeConfirmModal').classList.add('active');
|
||||
|
||||
@@ -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,754 @@
|
||||
/**
|
||||
* @fileoverview Entrance animations for the four things that appear when work
|
||||
* starts: session TABS, the main TERMINAL pane a session's CLI runs in, floating
|
||||
* agent WINDOWS, and the CONNECTION LINES tying a window back to its parent tab.
|
||||
* One picker per surface, plus themes that set all four to a matching look.
|
||||
*
|
||||
* Everything is OFF by default (the `legacy` theme), so an untouched install
|
||||
* behaves exactly as it did before this module existed. Opt in via App Settings
|
||||
* → Appearance → Entrance Animations.
|
||||
*
|
||||
* Four constraints shape the design:
|
||||
*
|
||||
* 1. `_fullRenderSessionTabs()` replaces the tab strip's entire innerHTML, and
|
||||
* `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''` and rebuilds
|
||||
* every path. Both run constantly while sessions and agents are spawning, so
|
||||
* an animating tab or line element is DESTROYED mid-flight. Those two are
|
||||
* therefore tracked by id in `_tabEnterActive` / `_lineEnterActive` and
|
||||
* re-applied to the fresh element with a NEGATIVE animation-delay, resuming at
|
||||
* the same offset instead of restarting or snapping to the end. Windows are
|
||||
* stable DOM and need none of this.
|
||||
* 2. Connection-line geometry comes from `getBoundingClientRect()` on the window.
|
||||
* A window entrance that starts with a transform would move that rect, so the
|
||||
* `beam` style (which must hold still while its line draws toward it) animates
|
||||
* opacity and filter only. Every other style refreshes the lines when it ends.
|
||||
* 3. The terminal pane is ONE shared element, so its entrance is marked at
|
||||
* session creation but played at selection: a session created in the
|
||||
* background must not animate the pane the user is currently looking at. Its
|
||||
* styles are also restricted to transform/opacity/clip-path (see below).
|
||||
* 4. Nothing may animate on page load or reconnect replay. Only ids that pass
|
||||
* through `markSessionTabEntering()` animate, and `_tabEnterSeen` makes that
|
||||
* once-per-id even though the POST response and the SSE event both call
|
||||
* `_onSessionCreated`.
|
||||
*
|
||||
* Styles are selected by `data-tab-anim` / `data-term-anim` / `data-win-anim` /
|
||||
* `data-line-anim` on <html>; the keyframes live in styles.css. `?animlab=1`
|
||||
* opens a floating picker that fakes tabs, a pane replay, a window and a line,
|
||||
* so styles can be compared without spawning real sessions or agents.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks)
|
||||
* @dependency constants.js (escapeHtml)
|
||||
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
|
||||
*/
|
||||
|
||||
/** Tab entrance styles. `key` doubles as the `data-tab-anim` value. */
|
||||
const TAB_ANIM_STYLES = [
|
||||
{ key: 'slide', label: 'Slide', blurb: 'Drifts in from the right.', duration: 380 },
|
||||
{ key: 'pop', label: 'Pop', blurb: 'Springs past full size, then settles.', duration: 460 },
|
||||
{ key: 'crt', label: 'CRT', blurb: 'Snaps open as a hot line, then unfolds.', duration: 520 },
|
||||
{ key: 'unroll', label: 'Unroll', blurb: 'The strip makes room and the tab widens in.', duration: 480 },
|
||||
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 720 },
|
||||
{ key: 'flip', label: 'Flip', blurb: 'Drops in as a card hinged on its top edge.', duration: 520 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Tabs just appear.', duration: 0 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Window entrance styles. `fly` is the pre-existing behaviour (the window flies
|
||||
* out of its parent tab via a JS transition in subagent-windows.js); every other
|
||||
* style positions the window at its resting spot and runs a CSS animation there.
|
||||
*/
|
||||
const WIN_ANIM_STYLES = [
|
||||
{ key: 'fly', label: 'Fly from tab', blurb: 'Current behaviour: flies out of the tab, scaling up.', duration: 400 },
|
||||
{ key: 'crt', label: 'CRT', blurb: 'Bursts open as a hot line, then unfolds vertically.', duration: 560 },
|
||||
{ key: 'materialize', label: 'Materialize', blurb: 'Resolves out of a blur with a short glitch.', duration: 620 },
|
||||
{ key: 'unfold', label: 'Unfold', blurb: 'Hinges down from its top edge in 3D.', duration: 560 },
|
||||
{ key: 'beam', label: 'Beam down', blurb: 'Waits for its line to reach it, then materializes.', duration: 620 },
|
||||
{ key: 'pop', label: 'Pop', blurb: 'Springs open from its centre.', duration: 460 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Windows just appear.', duration: 0 },
|
||||
];
|
||||
|
||||
/** Connection-line entrance styles. `key` doubles as the `data-line-anim` value. */
|
||||
const LINE_ANIM_STYLES = [
|
||||
{ key: 'draw', label: 'Draw', blurb: 'Draws itself from the tab down to the window.', duration: 420 },
|
||||
{ key: 'packet', label: 'Packet', blurb: 'Line fades in, then a bright packet runs down it.', duration: 700 },
|
||||
{ key: 'fade', label: 'Fade', blurb: 'Simply fades in.', duration: 300 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Lines just appear.', duration: 0 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Main-terminal entrance styles: the pane a session's CLI actually runs in.
|
||||
*
|
||||
* ⚠ These may only animate transform, opacity and clip-path. xterm's FitAddon
|
||||
* derives rows/cols from `getComputedStyle(parent).width/height`, which reports
|
||||
* the untransformed layout box, so transforms are safe, but animating width,
|
||||
* height or padding would feed wrong dimensions into `resize()` and through to
|
||||
* the PTY. Colour washes go on `.terminal-container::before`, never a `filter`
|
||||
* on the container: that would blur a full-screen WebGL canvas every frame.
|
||||
*/
|
||||
const TERM_ANIM_STYLES = [
|
||||
{ key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 },
|
||||
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 760 },
|
||||
{ key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 },
|
||||
{ key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 },
|
||||
{ key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
|
||||
];
|
||||
|
||||
/** How long a `beam` window waits before materializing. Just under the line draw. */
|
||||
const BEAM_HOLD_MS = 360;
|
||||
|
||||
/** One-click combinations that read as a single look. */
|
||||
const ANIM_THEMES = [
|
||||
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
|
||||
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' },
|
||||
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
|
||||
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
|
||||
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Defaults are the `legacy` theme: every entrance OFF, and agent windows on the
|
||||
* `fly` behaviour Codeman already had before this module existed. So a user who
|
||||
* never opens the picker sees exactly the pre-existing UI, and each mark/apply
|
||||
* hook short-circuits on its first line. Opt in via App Settings → Appearance →
|
||||
* Entrance Animations, which persists to the localStorage keys below.
|
||||
*/
|
||||
const TAB_ANIM_DEFAULT = 'off';
|
||||
const WIN_ANIM_DEFAULT = 'fly';
|
||||
const LINE_ANIM_DEFAULT = 'off';
|
||||
const TERM_ANIM_DEFAULT = 'off';
|
||||
const TAB_ANIM_STAGGER_DEFAULT = 90;
|
||||
/** A new id joins the current cascade if it arrives within this of the last one. */
|
||||
const TAB_ANIM_BATCH_WINDOW_MS = 600;
|
||||
|
||||
const ANIM_KEYS = {
|
||||
tab: 'codeman:tabAnim',
|
||||
win: 'codeman:winAnim',
|
||||
line: 'codeman:lineAnim',
|
||||
term: 'codeman:termAnim',
|
||||
termSwitch: 'codeman:termAnimOnSwitch',
|
||||
stagger: 'codeman:tabAnimStagger',
|
||||
speed: 'codeman:tabAnimSpeed',
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ── Setup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve every style/timing and stamp them on <html>. Called once at startup. */
|
||||
initEntranceAnimations() {
|
||||
this._tabEnterSeen = new Set();
|
||||
this._tabEnterActive = new Map();
|
||||
this._lineEnterActive = new Map();
|
||||
this._tabEnterBatchIndex = 0;
|
||||
this._tabEnterLastMarkTs = 0;
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
// ?tabanim= / ?winanim= / ?lineanim= win for a single load, so a style can be
|
||||
// tried without persisting over whatever is saved.
|
||||
const pick = (param, styles, storeKey, fallback) => {
|
||||
const fromUrl = params.get(param);
|
||||
if (styles.some((s) => s.key === fromUrl)) return fromUrl;
|
||||
return this._animRead(storeKey, fallback);
|
||||
};
|
||||
|
||||
this.setTabAnimStyle(pick('tabanim', TAB_ANIM_STYLES, ANIM_KEYS.tab, TAB_ANIM_DEFAULT), { persist: false });
|
||||
this.setWinAnimStyle(pick('winanim', WIN_ANIM_STYLES, ANIM_KEYS.win, WIN_ANIM_DEFAULT), { persist: false });
|
||||
this.setLineAnimStyle(pick('lineanim', LINE_ANIM_STYLES, ANIM_KEYS.line, LINE_ANIM_DEFAULT), { persist: false });
|
||||
this.setTermAnimStyle(pick('termanim', TERM_ANIM_STYLES, ANIM_KEYS.term, TERM_ANIM_DEFAULT), { persist: false });
|
||||
this.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false });
|
||||
|
||||
this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { persist: false });
|
||||
this.setAnimSpeed(Number(this._animRead(ANIM_KEYS.speed, 1)), { persist: false });
|
||||
|
||||
if (params.get('animlab') === '1' || params.get('tabanimlab') === '1') this.openAnimLab();
|
||||
},
|
||||
|
||||
_animRead(key, fallback) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw === null || raw === '' ? fallback : raw;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
|
||||
_animWrite(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, String(value));
|
||||
} catch {
|
||||
/* private mode / quota, the in-memory value still applies for this load */
|
||||
}
|
||||
},
|
||||
|
||||
_setAnimStyle(prop, key, styles, fallback, attr, storeKey, persist) {
|
||||
const style = styles.some((s) => s.key === key) ? key : fallback;
|
||||
this[prop] = style;
|
||||
document.documentElement.setAttribute(attr, style);
|
||||
if (persist) this._animWrite(storeKey, style);
|
||||
// Keep the lab's highlight honest when a style is set from anywhere other
|
||||
// than the lab's own buttons (a theme, a URL param, the console).
|
||||
this._syncAnimLab?.();
|
||||
},
|
||||
|
||||
setTabAnimStyle(key, { persist = true } = {}) {
|
||||
// prettier-ignore
|
||||
this._setAnimStyle('_tabAnimStyle', key, TAB_ANIM_STYLES, TAB_ANIM_DEFAULT, 'data-tab-anim', ANIM_KEYS.tab, persist);
|
||||
},
|
||||
|
||||
setWinAnimStyle(key, { persist = true } = {}) {
|
||||
// prettier-ignore
|
||||
this._setAnimStyle('_winAnimStyle', key, WIN_ANIM_STYLES, WIN_ANIM_DEFAULT, 'data-win-anim', ANIM_KEYS.win, persist);
|
||||
},
|
||||
|
||||
setLineAnimStyle(key, { persist = true } = {}) {
|
||||
// prettier-ignore
|
||||
this._setAnimStyle('_lineAnimStyle', key, LINE_ANIM_STYLES, LINE_ANIM_DEFAULT, 'data-line-anim', ANIM_KEYS.line, persist);
|
||||
},
|
||||
|
||||
setTermAnimStyle(key, { persist = true } = {}) {
|
||||
// prettier-ignore
|
||||
this._setAnimStyle('_termAnimStyle', key, TERM_ANIM_STYLES, TERM_ANIM_DEFAULT, 'data-term-anim', ANIM_KEYS.term, persist);
|
||||
},
|
||||
|
||||
/** Replay the terminal entrance on every tab switch, not just on a new session. */
|
||||
setTermAnimOnSwitch(on, { persist = true } = {}) {
|
||||
this._termAnimOnSwitch = !!on;
|
||||
if (persist) this._animWrite(ANIM_KEYS.termSwitch, on ? '1' : '0');
|
||||
this._syncAnimLab?.();
|
||||
},
|
||||
|
||||
/** Apply a theme: one look across tabs, windows, lines and the terminal. */
|
||||
setAnimTheme(themeKey) {
|
||||
const theme = ANIM_THEMES.find((t) => t.key === themeKey);
|
||||
if (!theme) return;
|
||||
this.setTabAnimStyle(theme.tab);
|
||||
this.setWinAnimStyle(theme.win);
|
||||
this.setLineAnimStyle(theme.line);
|
||||
this.setTermAnimStyle(theme.term);
|
||||
this._syncEntranceAnimSetting?.();
|
||||
},
|
||||
|
||||
/** The theme matching the four current styles, or 'custom' for a lab mix. */
|
||||
currentAnimTheme() {
|
||||
const match = ANIM_THEMES.find(
|
||||
(t) =>
|
||||
t.tab === this._tabAnimStyle &&
|
||||
t.win === this._winAnimStyle &&
|
||||
t.line === this._lineAnimStyle &&
|
||||
t.term === this._termAnimStyle
|
||||
);
|
||||
return match ? match.key : 'custom';
|
||||
},
|
||||
|
||||
// ── App Settings picker ───────────────────────────────────────────────────
|
||||
//
|
||||
// Wired straight to setAnimTheme() rather than through saveAppSettings(): the
|
||||
// styles live in their own localStorage keys, so they stay per-device and never
|
||||
// reach `PUT /api/settings`, whose schema is .strict() and would reject them.
|
||||
|
||||
_syncEntranceAnimSetting() {
|
||||
const sel = document.getElementById('appSettingsEntranceAnim');
|
||||
if (!sel) return;
|
||||
sel.value = this.currentAnimTheme();
|
||||
if (!sel.dataset.bound) {
|
||||
sel.dataset.bound = '1';
|
||||
sel.addEventListener('change', () => {
|
||||
// 'custom' is a readout of a lab mix, not something you can select into.
|
||||
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
|
||||
else this.setAnimTheme(sel.value);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setTabAnimStagger(ms, { persist = true } = {}) {
|
||||
const value = Number.isFinite(ms) ? Math.max(0, Math.min(400, Math.round(ms))) : TAB_ANIM_STAGGER_DEFAULT;
|
||||
this._tabAnimStagger = value;
|
||||
if (persist) this._animWrite(ANIM_KEYS.stagger, value);
|
||||
},
|
||||
|
||||
setAnimSpeed(multiplier, { persist = true } = {}) {
|
||||
const value = Number.isFinite(multiplier) ? Math.max(0.25, Math.min(3, multiplier)) : 1;
|
||||
this._animSpeed = value;
|
||||
// Every keyframe block reads this, so one variable retimes all of them.
|
||||
document.documentElement.style.setProperty('--anim-enter-scale', String(1 / value));
|
||||
if (persist) this._animWrite(ANIM_KEYS.speed, value);
|
||||
},
|
||||
|
||||
_styleDuration(styles, key) {
|
||||
return (styles.find((s) => s.key === key)?.duration || 0) / (this._animSpeed || 1);
|
||||
},
|
||||
|
||||
_tabAnimDuration() {
|
||||
return this._styleDuration(TAB_ANIM_STYLES, this._tabAnimStyle);
|
||||
},
|
||||
|
||||
_winAnimDuration() {
|
||||
return this._styleDuration(WIN_ANIM_STYLES, this._winAnimStyle);
|
||||
},
|
||||
|
||||
_lineAnimDuration() {
|
||||
return this._styleDuration(LINE_ANIM_STYLES, this._lineAnimStyle);
|
||||
},
|
||||
|
||||
_termAnimDuration() {
|
||||
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
|
||||
},
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Queue a session id to animate on its next render. Idempotent per id. */
|
||||
markSessionTabEntering(id) {
|
||||
if (!id || this._tabAnimStyle === 'off') return;
|
||||
if (!this._tabEnterSeen) return; // initEntranceAnimations() has not run yet
|
||||
if (this._tabEnterSeen.has(id)) return;
|
||||
this._tabEnterSeen.add(id);
|
||||
|
||||
const now = performance.now();
|
||||
// A launch landing well after the previous one starts its own cascade rather
|
||||
// than inheriting a large stale offset.
|
||||
if (now - this._tabEnterLastMarkTs > TAB_ANIM_BATCH_WINDOW_MS) this._tabEnterBatchIndex = 0;
|
||||
this._tabEnterLastMarkTs = now;
|
||||
|
||||
this._tabEnterActive.set(id, {
|
||||
startTs: now,
|
||||
staggerMs: this._tabEnterBatchIndex * this._tabAnimStagger,
|
||||
});
|
||||
this._tabEnterBatchIndex += 1;
|
||||
},
|
||||
|
||||
/** Attach (or resume) the entrance animation on freshly rendered tabs. */
|
||||
_applyTabEntrances() {
|
||||
const active = this._tabEnterActive;
|
||||
if (!active || active.size === 0) return;
|
||||
if (this._tabAnimStyle === 'off') {
|
||||
active.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
|
||||
const now = performance.now();
|
||||
const duration = this._tabAnimDuration();
|
||||
|
||||
for (const [id, state] of active) {
|
||||
const elapsed = now - state.startTs;
|
||||
// Ran to completion while the element was detached, nothing left to show.
|
||||
if (elapsed > state.staggerMs + duration + 50) {
|
||||
active.delete(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tab = container.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||
if (!tab) continue; // not rendered yet; a later render picks it up
|
||||
// Already running on this element. Re-stamping the delay would jump it, and
|
||||
// the incremental render path can reach here for the same element.
|
||||
if (tab.classList.contains('tab-enter')) continue;
|
||||
|
||||
// Negative delay resumes mid-animation, so an unrelated re-render mid-cascade
|
||||
// does not restart the tab or make it snap.
|
||||
tab.style.setProperty('--tab-enter-delay', `${state.staggerMs - elapsed}ms`);
|
||||
tab.classList.add('tab-enter');
|
||||
const done = () => {
|
||||
tab.classList.remove('tab-enter');
|
||||
tab.style.removeProperty('--tab-enter-delay');
|
||||
this._tabEnterActive?.delete(id);
|
||||
};
|
||||
tab.addEventListener('animationend', done, { once: true });
|
||||
tab.addEventListener('animationcancel', done, { once: true });
|
||||
}
|
||||
},
|
||||
|
||||
// ── Main terminal ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Queue the terminal pane to animate the first time this session is shown.
|
||||
* Marked at creation but PLAYED at selection, because the pane is one shared
|
||||
* element: a session created in the background must not animate the pane the
|
||||
* user is currently looking at.
|
||||
*/
|
||||
markTerminalEntering(id) {
|
||||
if (!id || this._termAnimStyle === 'off') return;
|
||||
if (!this._termEnterPending) this._termEnterPending = new Set();
|
||||
this._termEnterPending.add(id);
|
||||
},
|
||||
|
||||
/** Play the terminal entrance for `sessionId`, if it is owed one. */
|
||||
playTerminalEntrance(sessionId) {
|
||||
const style = this._termAnimStyle || TERM_ANIM_DEFAULT;
|
||||
if (style === 'off') return;
|
||||
|
||||
const owed = sessionId && this._termEnterPending?.delete(sessionId);
|
||||
if (!owed && !this._termAnimOnSwitch) return;
|
||||
|
||||
const el = this.$('terminalContainer');
|
||||
if (!el) return;
|
||||
|
||||
// Restart cleanly when switching tabs faster than the animation runs.
|
||||
el.classList.remove('term-enter');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('term-enter');
|
||||
|
||||
clearTimeout(this._termEnterTimer);
|
||||
const done = () => {
|
||||
el.classList.remove('term-enter');
|
||||
clearTimeout(this._termEnterTimer);
|
||||
};
|
||||
el.addEventListener('animationend', done, { once: true });
|
||||
el.addEventListener('animationcancel', done, { once: true });
|
||||
// Backstop: a backgrounded tab never fires animationend, which would leave
|
||||
// the pane stuck at its 0% keyframe (invisible) when you come back to it.
|
||||
this._termEnterTimer = setTimeout(done, this._termAnimDuration() + 900);
|
||||
},
|
||||
|
||||
// ── Windows ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* True when the window should be parked on its parent tab and flown to its
|
||||
* resting spot by subagent-windows.js. False for every CSS-animated style,
|
||||
* which needs the window to start at its final position.
|
||||
*/
|
||||
windowEntranceFliesFromTab() {
|
||||
return (this._winAnimStyle || WIN_ANIM_DEFAULT) === 'fly';
|
||||
},
|
||||
|
||||
/**
|
||||
* Run the window entrance on an already-positioned window.
|
||||
* @param {HTMLElement} win a .subagent-window or .ultracode-window
|
||||
*/
|
||||
applyWindowEntrance(win) {
|
||||
if (!win) return;
|
||||
const style = this._winAnimStyle || WIN_ANIM_DEFAULT;
|
||||
if (style === 'off' || style === 'fly') return;
|
||||
|
||||
// `beam` holds the window still (opacity/filter only) while its line draws
|
||||
// toward it, then materializes. Everything else starts immediately.
|
||||
if (style === 'beam') win.style.setProperty('--win-enter-delay', `${BEAM_HOLD_MS / (this._animSpeed || 1)}ms`);
|
||||
|
||||
win.classList.add('win-enter');
|
||||
const done = () => {
|
||||
win.classList.remove('win-enter');
|
||||
win.style.removeProperty('--win-enter-delay');
|
||||
// A transformed window reports a transformed rect, so lines drawn while it
|
||||
// was animating are slightly off. Redraw once it has settled.
|
||||
this.updateConnectionLines?.();
|
||||
};
|
||||
win.addEventListener('animationend', done, { once: true });
|
||||
win.addEventListener('animationcancel', done, { once: true });
|
||||
},
|
||||
|
||||
// ── Connection lines ──────────────────────────────────────────────────────
|
||||
|
||||
/** Queue an agent's connection line to draw itself on the next line rebuild. */
|
||||
markConnectionLineEntering(agentId) {
|
||||
if (!agentId || this._lineAnimStyle === 'off') return;
|
||||
if (!this._lineEnterActive) return;
|
||||
if (this._lineEnterActive.has(agentId)) return;
|
||||
this._lineEnterActive.set(agentId, { startTs: performance.now() });
|
||||
},
|
||||
|
||||
/**
|
||||
* Attach (or resume) the draw-in animation on freshly rebuilt paths. Called at
|
||||
* the end of `_updateConnectionLinesImmediate()`, which has just thrown away
|
||||
* and recreated every path element.
|
||||
*/
|
||||
_applyLineEntrances(svg) {
|
||||
const active = this._lineEnterActive;
|
||||
if (!active || active.size === 0 || !svg) return;
|
||||
const style = this._lineAnimStyle || LINE_ANIM_DEFAULT;
|
||||
if (style === 'off') {
|
||||
active.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const duration = this._lineAnimDuration();
|
||||
|
||||
for (const [agentId, state] of active) {
|
||||
const elapsed = now - state.startTs;
|
||||
if (elapsed > duration + 50) {
|
||||
active.delete(agentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = svg.querySelector(`path[data-agent-id="${CSS.escape(agentId)}"]`);
|
||||
if (!path) continue;
|
||||
|
||||
const len = Math.max(1, Math.round(path.getTotalLength()));
|
||||
path.style.setProperty('--line-len', `${len}px`);
|
||||
path.style.setProperty('--line-enter-delay', `${-elapsed}ms`);
|
||||
path.classList.add('line-enter');
|
||||
|
||||
// `packet` rides a bright dash ON TOP of the normal dashed line, so the base
|
||||
// line keeps its look instead of being taken over by the animation.
|
||||
if (style === 'packet') {
|
||||
const packet = path.cloneNode(false);
|
||||
packet.removeAttribute('data-agent-id');
|
||||
packet.setAttribute('class', 'connection-line-packet');
|
||||
packet.style.setProperty('--line-len', `${len}px`);
|
||||
packet.style.setProperty('--line-enter-delay', `${-elapsed}ms`);
|
||||
svg.appendChild(packet);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Lab (compare styles without spawning sessions or agents) ───────────────
|
||||
|
||||
/** Floating picker: switch styles per surface and replay fake entrances. */
|
||||
openAnimLab() {
|
||||
if (document.getElementById('animLab')) return;
|
||||
|
||||
const group = (title, styles, attr) => `
|
||||
<div class="anim-lab-group">
|
||||
<div class="anim-lab-group-title">${title}</div>
|
||||
${styles
|
||||
.map(
|
||||
(s) => `<button type="button" class="anim-lab-style" data-attr="${attr}" data-style="${s.key}">
|
||||
<strong>${escapeHtml(s.label)}</strong><em>${escapeHtml(s.blurb)}</em>
|
||||
</button>`
|
||||
)
|
||||
.join('')}
|
||||
</div>`;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'animLab';
|
||||
panel.className = 'anim-lab';
|
||||
panel.innerHTML = `
|
||||
<div class="anim-lab-head">
|
||||
<span>Entrance lab</span>
|
||||
<button type="button" class="anim-lab-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="anim-lab-themes">
|
||||
${ANIM_THEMES.map((t) => `<button type="button" data-theme="${t.key}">${escapeHtml(t.label)}</button>`).join('')}
|
||||
</div>
|
||||
<div class="anim-lab-scroll">
|
||||
${group('Tabs', TAB_ANIM_STYLES, 'tab')}
|
||||
${group('Terminal pane', TERM_ANIM_STYLES, 'term')}
|
||||
<label class="anim-lab-check">
|
||||
<input type="checkbox" data-check="termSwitch"> Also on every tab switch
|
||||
</label>
|
||||
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
|
||||
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
|
||||
</div>
|
||||
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
|
||||
<input type="range" data-range="stagger" min="0" max="260" step="10">
|
||||
</label>
|
||||
<label class="anim-lab-range">Speed <output data-out="speed"></output>
|
||||
<input type="range" data-range="speed" min="0.5" max="2" step="0.1">
|
||||
</label>
|
||||
<div class="anim-lab-demo">
|
||||
<span>Replay</span>
|
||||
<button type="button" data-demo="tabs">Tabs</button>
|
||||
<button type="button" data-demo="term">Pane</button>
|
||||
<button type="button" data-demo="window">Window</button>
|
||||
<button type="button" data-demo="all">All</button>
|
||||
</div>
|
||||
<p class="anim-lab-hint">Fake tabs, window and line, removed after the run. Real launches use the same timing.</p>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
|
||||
panel.querySelector('.anim-lab-close').addEventListener('click', () => this.closeAnimLab());
|
||||
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
this.setAnimTheme(btn.dataset.theme);
|
||||
this._syncAnimLab();
|
||||
this.demoEntrance('all');
|
||||
});
|
||||
});
|
||||
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const { attr, style } = btn.dataset;
|
||||
if (attr === 'tab') this.setTabAnimStyle(style);
|
||||
else if (attr === 'win') this.setWinAnimStyle(style);
|
||||
else if (attr === 'term') this.setTermAnimStyle(style);
|
||||
else this.setLineAnimStyle(style);
|
||||
this._syncAnimLab();
|
||||
this.demoEntrance({ tab: 'tabs', term: 'term' }[attr] || 'all');
|
||||
});
|
||||
});
|
||||
panel.querySelector('input[data-check="termSwitch"]').addEventListener('change', (e) => {
|
||||
this.setTermAnimOnSwitch(e.target.checked);
|
||||
});
|
||||
panel.querySelectorAll('input[data-range]').forEach((input) => {
|
||||
input.addEventListener('input', () => {
|
||||
if (input.dataset.range === 'stagger') this.setTabAnimStagger(Number(input.value));
|
||||
else this.setAnimSpeed(Number(input.value));
|
||||
this._syncAnimLab();
|
||||
});
|
||||
input.addEventListener('change', () => this.demoEntrance('all'));
|
||||
});
|
||||
panel.querySelectorAll('button[data-demo]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => this.demoEntrance(btn.dataset.demo));
|
||||
});
|
||||
|
||||
this._syncAnimLab();
|
||||
},
|
||||
|
||||
closeAnimLab() {
|
||||
document.getElementById('animLab')?.remove();
|
||||
this._clearEntranceDemo();
|
||||
},
|
||||
|
||||
_syncAnimLab() {
|
||||
const panel = document.getElementById('animLab');
|
||||
if (!panel) return;
|
||||
const current = {
|
||||
tab: this._tabAnimStyle,
|
||||
win: this._winAnimStyle,
|
||||
line: this._lineAnimStyle,
|
||||
term: this._termAnimStyle,
|
||||
};
|
||||
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
|
||||
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
|
||||
});
|
||||
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
|
||||
const t = ANIM_THEMES.find((x) => x.key === btn.dataset.theme);
|
||||
btn.classList.toggle('selected', !!t && ['tab', 'win', 'line', 'term'].every((k) => t[k] === current[k]));
|
||||
});
|
||||
const check = panel.querySelector('input[data-check="termSwitch"]');
|
||||
if (check) check.checked = !!this._termAnimOnSwitch;
|
||||
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
|
||||
panel.querySelector('output[data-out="stagger"]').textContent = `${this._tabAnimStagger}ms`;
|
||||
panel.querySelector('input[data-range="speed"]').value = String(this._animSpeed);
|
||||
panel.querySelector('output[data-out="speed"]').textContent = `${this._animSpeed.toFixed(1)}x`;
|
||||
},
|
||||
|
||||
_clearEntranceDemo() {
|
||||
clearTimeout(this._animDemoTimer);
|
||||
clearTimeout(this._animDemoWindowTimer);
|
||||
document.querySelectorAll('.session-tab[data-demo]').forEach((el) => el.remove());
|
||||
document.querySelectorAll('.subagent-window[data-demo]').forEach((el) => el.remove());
|
||||
document.getElementById('animLabLines')?.remove();
|
||||
},
|
||||
|
||||
/**
|
||||
* The demo line gets its OWN svg overlay rather than sharing #connectionLines.
|
||||
* That overlay is rebuilt from real windows via `svg.innerHTML = ''`, so a fake
|
||||
* path dropped into it is erased the moment anything triggers a redraw -
|
||||
* including the demo window's own entrance finishing.
|
||||
*/
|
||||
_demoLineSvg() {
|
||||
let svg = document.getElementById('animLabLines');
|
||||
if (!svg) {
|
||||
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.id = 'animLabLines';
|
||||
svg.setAttribute('class', 'connection-lines-svg');
|
||||
document.body.appendChild(svg);
|
||||
}
|
||||
return svg;
|
||||
},
|
||||
|
||||
/** @param {'tabs'|'term'|'window'|'all'} what */
|
||||
demoEntrance(what = 'all') {
|
||||
this._clearEntranceDemo();
|
||||
|
||||
// The pane is a real, shared element rather than a throwaway, so replay it
|
||||
// through the same entry point a real launch uses (bypassing the owed-id
|
||||
// check, which only exists to keep background sessions from hijacking it).
|
||||
if (what === 'term' || what === 'all') {
|
||||
const wasOnSwitch = this._termAnimOnSwitch;
|
||||
this._termAnimOnSwitch = true;
|
||||
this.playTerminalEntrance(null);
|
||||
this._termAnimOnSwitch = wasOnSwitch;
|
||||
}
|
||||
if (what === 'term') return;
|
||||
|
||||
const tabCount = what === 'window' ? 1 : 4;
|
||||
const tabs = this._demoTabs(tabCount);
|
||||
let total = Math.max(this._tabAnimDuration() + tabCount * this._tabAnimStagger, this._termAnimDuration());
|
||||
|
||||
if (what !== 'tabs') {
|
||||
// Give the tab cascade a beat, so the window reads as coming out of it.
|
||||
const lead = what === 'all' ? Math.min(320, this._tabAnimStagger * 2) : 0;
|
||||
this._animDemoWindowTimer = setTimeout(() => this._demoWindow(tabs[0]), lead);
|
||||
total = Math.max(total, lead + this._winAnimDuration() + this._lineAnimDuration() + BEAM_HOLD_MS);
|
||||
}
|
||||
|
||||
this._animDemoTimer = setTimeout(() => this._clearEntranceDemo(), total + 1800);
|
||||
},
|
||||
|
||||
/** Append `count` throwaway tabs and run the tab entrance on them. */
|
||||
_demoTabs(count) {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return [];
|
||||
const made = [];
|
||||
const base = this.sessions?.size || 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const tab = document.createElement('div');
|
||||
tab.className = 'session-tab';
|
||||
tab.dataset.demo = '1';
|
||||
tab.dataset.color = ['green', 'blue', 'purple', 'orange', 'pink', 'yellow', 'red'][i % 7];
|
||||
tab.innerHTML = `
|
||||
<span class="tab-number">${base + i + 1}</span>
|
||||
<span class="tab-status idle" aria-hidden="true"></span>
|
||||
<span class="tab-info"><span class="tab-name-row">
|
||||
<span class="tab-name">w${base + i + 1}-demo</span>
|
||||
</span></span>`;
|
||||
container.appendChild(tab);
|
||||
made.push(tab);
|
||||
if (this._tabAnimStyle !== 'off') {
|
||||
tab.style.setProperty('--tab-enter-delay', `${i * this._tabAnimStagger}ms`);
|
||||
void tab.offsetWidth; // force layout so the class add starts a fresh run
|
||||
tab.classList.add('tab-enter');
|
||||
}
|
||||
}
|
||||
return made;
|
||||
},
|
||||
|
||||
/** Append a throwaway agent window under `originTab`, plus its connection line. */
|
||||
_demoWindow(originTab) {
|
||||
const tabRect = originTab?.getBoundingClientRect();
|
||||
const win = document.createElement('div');
|
||||
win.className = 'subagent-window';
|
||||
win.dataset.demo = '1';
|
||||
win.style.width = '360px';
|
||||
win.style.height = '220px';
|
||||
win.style.left = `${Math.max(24, (tabRect?.left ?? 120) - 40)}px`;
|
||||
win.style.top = `${(tabRect?.bottom ?? 60) + 160}px`;
|
||||
win.style.zIndex = '1001';
|
||||
win.innerHTML = `
|
||||
<div class="subagent-window-header">
|
||||
<div class="subagent-window-title"><span class="icon">🤖</span><span class="id">demo-agent</span>
|
||||
<span class="status running">running</span></div>
|
||||
</div>
|
||||
<div class="subagent-window-body"><div class="subagent-empty">Preview window</div></div>`;
|
||||
document.body.appendChild(win);
|
||||
this.applyWindowEntrance(win);
|
||||
this._demoLine(tabRect, win);
|
||||
},
|
||||
|
||||
/** Draw a fake tab→window line into the lab's own overlay and animate it. */
|
||||
_demoLine(tabRect, win) {
|
||||
if (!tabRect) return;
|
||||
const svg = this._demoLineSvg();
|
||||
const winRect = win.getBoundingClientRect();
|
||||
const x1 = tabRect.left + tabRect.width / 2;
|
||||
const y1 = tabRect.bottom;
|
||||
const x2 = winRect.left + winRect.width / 2;
|
||||
const y2 = winRect.top;
|
||||
const midY = (y1 + y2) / 2;
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`);
|
||||
path.setAttribute('class', 'connection-line');
|
||||
path.dataset.demo = '1';
|
||||
svg.appendChild(path);
|
||||
|
||||
if ((this._lineAnimStyle || LINE_ANIM_DEFAULT) === 'off') return;
|
||||
const len = Math.max(1, Math.round(path.getTotalLength()));
|
||||
path.style.setProperty('--line-len', `${len}px`);
|
||||
path.style.setProperty('--line-enter-delay', '0ms');
|
||||
path.classList.add('line-enter');
|
||||
|
||||
if (this._lineAnimStyle === 'packet') {
|
||||
const packet = path.cloneNode(false);
|
||||
packet.setAttribute('class', 'connection-line-packet');
|
||||
packet.dataset.demo = '1';
|
||||
packet.style.setProperty('--line-len', `${len}px`);
|
||||
packet.style.setProperty('--line-enter-delay', '0ms');
|
||||
svg.appendChild(packet);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,958 @@
|
||||
/**
|
||||
* @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 Antigravity': '运行 Antigravity',
|
||||
'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">
|
||||
@@ -449,6 +464,21 @@
|
||||
<button class="run-mode-option" data-mode="gemini" onclick="app.setRunMode('gemini')">
|
||||
<span class="run-mode-dot gemini"></span>Gemini
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="antigravity" onclick="app.setRunMode('antigravity')">
|
||||
<span class="run-mode-dot antigravity"></span>Antigravity
|
||||
</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>
|
||||
@@ -465,6 +495,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 +594,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 +655,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>
|
||||
@@ -650,6 +736,7 @@
|
||||
<option value="opencode">OpenCode</option>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="antigravity">Antigravity</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
|
||||
@@ -1161,14 +1248,50 @@
|
||||
<!-- 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 settings-item-multiline" title="How new session tabs, the terminal pane, agent windows and their connection lines appear. This device only. For per-surface control and a live preview, add ?animlab=1 to the URL.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Entrance Animations</span>
|
||||
<span class="settings-item-desc">How new tabs, panes and agent windows arrive</span>
|
||||
</div>
|
||||
<select id="appSettingsEntranceAnim" class="form-select settings-inline-select">
|
||||
<option value="legacy">Off (default)</option>
|
||||
<option value="terminal">Terminal (CRT)</option>
|
||||
<option value="beamdown">Beam down</option>
|
||||
<option value="quiet">Quiet</option>
|
||||
<option value="playful">Playful</option>
|
||||
<option value="custom">Custom (set in the lab)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-item" id="appSettingsWebglRendererItem" title="Use the GPU-accelerated WebGL terminal renderer (desktop only). Turn off to force the DOM renderer if you hit GPU glitches. Codeman also auto-falls-back to the DOM renderer after repeated GPU stalls.">
|
||||
@@ -1501,6 +1624,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">
|
||||
@@ -1735,7 +1866,7 @@
|
||||
<input type="checkbox" id="eventIdleAudio">
|
||||
|
||||
<div class="event-label">Response complete</div>
|
||||
<input type="checkbox" id="eventStopEnabled" checked>
|
||||
<input type="checkbox" id="eventStopEnabled">
|
||||
<input type="checkbox" id="eventStopBrowser">
|
||||
<input type="checkbox" id="eventStopPush">
|
||||
<input type="checkbox" id="eventStopAudio">
|
||||
@@ -1930,8 +2061,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 +2127,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 +2624,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 +2643,8 @@
|
||||
<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="entrance-animations.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 {
|
||||
@@ -823,6 +839,20 @@ html.mobile-init .file-browser-panel {
|
||||
border-color: rgba(96, 165, 250, 0.5);
|
||||
}
|
||||
|
||||
/* Antigravity mode colors on mobile */
|
||||
.btn-toolbar.btn-run.mode-antigravity,
|
||||
.btn-toolbar.btn-run-gear.mode-antigravity {
|
||||
background: #0b2b33;
|
||||
border-color: rgba(34, 211, 238, 0.3);
|
||||
color: #cffafe;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-run.mode-antigravity:active,
|
||||
.btn-toolbar.btn-run-gear.mode-antigravity:active {
|
||||
background: #0e7490;
|
||||
border-color: rgba(34, 211, 238, 0.5);
|
||||
}
|
||||
|
||||
/* Run mode dropdown menu — positioned above toolbar on mobile */
|
||||
.run-mode-menu {
|
||||
bottom: 100%;
|
||||
@@ -839,6 +869,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 +900,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 +945,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 +2261,74 @@ 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"]) :is(.btn-toolbar.btn-run.mode-antigravity, .btn-toolbar.btn-run-gear.mode-antigravity) {
|
||||
background: linear-gradient(135deg, #0e7490, #0891b2);
|
||||
border-color: #155e75;
|
||||
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).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 5. Audio alerts (Web Audio API beep, user-opt-in)
|
||||
*
|
||||
* Features:
|
||||
* - Per-event-type preferences (enabled, browser, audio, push) with v1→v4 migration
|
||||
* - Per-event-type preferences (enabled, browser, audio, push) with v1→v5 migration
|
||||
* - Device-specific defaults (notifications disabled on mobile by default)
|
||||
* - 5s notification grouping window to batch rapid-fire events
|
||||
* - 100-notification cap with oldest eviction
|
||||
@@ -21,7 +21,7 @@
|
||||
* @param {CodemanApp} app - Reference to the main app instance
|
||||
*
|
||||
* @dependency constants.js (STUCK_THRESHOLD_DEFAULT_MS, timing constants)
|
||||
* @dependency mobile-handlers.js (MobileDetection.getDeviceType for device-specific defaults)
|
||||
* @dependency mobile-handlers.js (MobileDetection stable handheld identity/device type)
|
||||
* @loadorder 4 of 15 — loaded after voice-input.js, before keyboard-accessory.js
|
||||
*/
|
||||
|
||||
@@ -65,12 +65,19 @@ class NotificationManager {
|
||||
});
|
||||
}
|
||||
|
||||
loadPreferences() {
|
||||
_usesMobilePreferences() {
|
||||
return (
|
||||
MobileDetection.isHandheldDevice?.() ??
|
||||
MobileDetection.getDeviceType() === 'mobile'
|
||||
);
|
||||
}
|
||||
|
||||
getDefaultPreferences() {
|
||||
const defaultEventTypes = {
|
||||
permission_prompt: { enabled: true, browser: true, audio: true, push: false },
|
||||
elicitation_dialog: { enabled: true, browser: true, audio: true, push: false },
|
||||
idle_prompt: { enabled: true, browser: true, audio: false, push: false },
|
||||
stop: { enabled: true, browser: false, audio: false, push: false },
|
||||
stop: { enabled: false, browser: false, audio: false, push: false },
|
||||
session_error: { enabled: true, browser: true, audio: false, push: false },
|
||||
respawn_cycle: { enabled: true, browser: false, audio: false, push: false },
|
||||
token_milestone: { enabled: true, browser: false, audio: false, push: false },
|
||||
@@ -80,8 +87,8 @@ class NotificationManager {
|
||||
};
|
||||
|
||||
// Device-specific defaults: mobile has notifications disabled by default
|
||||
const isMobile = MobileDetection.getDeviceType() === 'mobile';
|
||||
const defaults = {
|
||||
const isMobile = this._usesMobilePreferences();
|
||||
return {
|
||||
enabled: !isMobile, // Disabled on mobile by default
|
||||
browserNotifications: !isMobile,
|
||||
audioAlerts: false,
|
||||
@@ -92,51 +99,97 @@ class NotificationManager {
|
||||
muteInfo: false,
|
||||
// Per-event-type preferences
|
||||
eventTypes: defaultEventTypes,
|
||||
_version: 4,
|
||||
_version: 5,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the complete v1→v5 migration to either local or server-hydrated
|
||||
* preferences. Keeping one normalization path prevents fresh browsers from
|
||||
* reviving retired drawer-only hook defaults.
|
||||
*/
|
||||
normalizePreferences(rawPreferences) {
|
||||
const defaults = this.getDefaultPreferences();
|
||||
if (
|
||||
!rawPreferences ||
|
||||
typeof rawPreferences !== 'object' ||
|
||||
Array.isArray(rawPreferences)
|
||||
) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
const prefs = {
|
||||
...rawPreferences,
|
||||
eventTypes:
|
||||
rawPreferences.eventTypes &&
|
||||
typeof rawPreferences.eventTypes === 'object' &&
|
||||
!Array.isArray(rawPreferences.eventTypes)
|
||||
? Object.fromEntries(
|
||||
Object.entries(rawPreferences.eventTypes).map(([key, value]) => [
|
||||
key,
|
||||
value && typeof value === 'object' ? { ...value } : value,
|
||||
])
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
const version = Number.isInteger(prefs._version) ? prefs._version : 0;
|
||||
|
||||
// Migrate: v1 had browserNotifications defaulting to false
|
||||
if (version < 2) {
|
||||
prefs.browserNotifications = true;
|
||||
}
|
||||
// Migrate: v2 -> v3 adds eventTypes
|
||||
if (version < 3) {
|
||||
prefs.eventTypes = { ...defaults.eventTypes };
|
||||
}
|
||||
// Migrate: v3 -> v4 adds push field to all eventTypes
|
||||
if (version < 4 && prefs.eventTypes) {
|
||||
for (const key of Object.keys(prefs.eventTypes)) {
|
||||
if (prefs.eventTypes[key] && prefs.eventTypes[key].push === undefined) {
|
||||
prefs.eventTypes[key].push = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Migrate: v4 -> v5 removes the drawer-only Response Complete default.
|
||||
// Preserve users who opted into any external delivery channel.
|
||||
if (version < 5) {
|
||||
const stopPref = prefs.eventTypes?.stop;
|
||||
if (
|
||||
stopPref?.enabled === true &&
|
||||
!stopPref.browser &&
|
||||
!stopPref.audio &&
|
||||
!stopPref.push
|
||||
) {
|
||||
stopPref.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
...prefs,
|
||||
eventTypes: { ...defaults.eventTypes, ...prefs.eventTypes },
|
||||
_version: 5,
|
||||
};
|
||||
}
|
||||
|
||||
loadPreferences() {
|
||||
try {
|
||||
const storageKey = this.getStorageKey();
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
const prefs = JSON.parse(saved);
|
||||
// Migrate: v1 had browserNotifications defaulting to false
|
||||
if (!prefs._version || prefs._version < 2) {
|
||||
prefs.browserNotifications = true;
|
||||
prefs._version = 2;
|
||||
}
|
||||
// Migrate: v2 -> v3 adds eventTypes
|
||||
if (prefs._version < 3) {
|
||||
prefs.eventTypes = defaultEventTypes;
|
||||
prefs._version = 3;
|
||||
localStorage.setItem(storageKey, JSON.stringify(prefs));
|
||||
}
|
||||
// Migrate: v3 -> v4 adds push field to all eventTypes
|
||||
if (prefs._version < 4) {
|
||||
if (prefs.eventTypes) {
|
||||
for (const key of Object.keys(prefs.eventTypes)) {
|
||||
if (prefs.eventTypes[key] && prefs.eventTypes[key].push === undefined) {
|
||||
prefs.eventTypes[key].push = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
prefs._version = 4;
|
||||
localStorage.setItem(storageKey, JSON.stringify(prefs));
|
||||
}
|
||||
// Merge with defaults to ensure all eventTypes exist
|
||||
return {
|
||||
...defaults,
|
||||
...prefs,
|
||||
eventTypes: { ...defaultEventTypes, ...prefs.eventTypes },
|
||||
};
|
||||
const normalized = this.normalizePreferences(JSON.parse(saved));
|
||||
localStorage.setItem(storageKey, JSON.stringify(normalized));
|
||||
return normalized;
|
||||
}
|
||||
} catch (_e) { /* ignore */ }
|
||||
return defaults;
|
||||
return this.getDefaultPreferences();
|
||||
}
|
||||
|
||||
// Get storage key for notification prefs (device-specific)
|
||||
getStorageKey() {
|
||||
const isMobile = MobileDetection.getDeviceType() === 'mobile';
|
||||
return isMobile ? 'codeman-notification-prefs-mobile' : 'codeman-notification-prefs';
|
||||
return this._usesMobilePreferences()
|
||||
? 'codeman-notification-prefs-mobile'
|
||||
: 'codeman-notification-prefs';
|
||||
}
|
||||
|
||||
savePreferences() {
|
||||
@@ -163,8 +216,10 @@ class NotificationManager {
|
||||
'exit-gate': 'ralph_complete',
|
||||
'subagent-spawn': 'subagent_spawn',
|
||||
'subagent-complete': 'subagent_complete',
|
||||
'hook-teammate-idle': 'idle_prompt',
|
||||
'hook-task-completed': 'stop',
|
||||
// Team lifecycle hooks are agent activity, not session-idle/stop alerts.
|
||||
// Reuse the existing opt-in agent categories instead of making them noisy.
|
||||
'hook-teammate-idle': 'subagent_spawn',
|
||||
'hook-task-completed': 'subagent_complete',
|
||||
};
|
||||
const eventTypeKey = categoryToEventType[category] || category;
|
||||
|
||||
@@ -330,8 +385,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) {
|
||||
@@ -399,7 +426,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
_buildCommandPaletteNewSessionItem(query = '') {
|
||||
const mode = this.runMode || this._runMode || 'claude';
|
||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini' };
|
||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity' };
|
||||
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
|
||||
return {
|
||||
id: 'new-session',
|
||||
@@ -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,66 @@ Object.assign(CodemanApp.prototype, {
|
||||
return this.run();
|
||||
},
|
||||
|
||||
/** Run using the selected mode (Claude Code, OpenCode, Codex, or Gemini) */
|
||||
/** 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, Gemini, or Antigravity) */
|
||||
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 === 'antigravity') {
|
||||
return await this.runAntigravity();
|
||||
}
|
||||
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 +485,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 +506,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 === 'antigravity' ? 'Run AG' : 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 +665,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`);
|
||||
@@ -649,7 +719,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// is shared by sibling sessions, so create-with-false must not yank it
|
||||
// — see the comment in session-routes create). Disabling the setting
|
||||
// removes it via the App Settings toggle path (system-routes), not here.
|
||||
statusLineTelemetry: globalSettings.showPlanUsageLimits === true,
|
||||
statusLineTelemetry: this.planUsageChipEnabled(globalSettings),
|
||||
})
|
||||
}).then(r => r.json())
|
||||
);
|
||||
@@ -659,6 +729,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 +845,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 +879,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 +957,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 +1014,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 +1067,58 @@ 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);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||
}
|
||||
},
|
||||
|
||||
async runAntigravity() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run agy on the OTHER side — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Antigravity session in ${caseName}...\x1b[0m`);
|
||||
this.terminal.writeln('');
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/antigravity/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Antigravity CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash\x1b[0m');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'antigravity',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
antigravityConfig: { dangerouslySkipPermissions: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start Antigravity');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
|
||||
if (data.data.sessionId) {
|
||||
await this.selectSession(data.data.sessionId);
|
||||
@@ -1015,7 +1142,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.editingSessionId = sessionId;
|
||||
|
||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini';
|
||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity';
|
||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||
|
||||
// Update respawn status display and buttons
|
||||
@@ -1045,7 +1172,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Hide Claude-specific options for external CLI sessions
|
||||
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini';
|
||||
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity';
|
||||
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
||||
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
|
||||
|
||||
@@ -1815,6 +1942,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 +2214,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)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -2296,6 +2592,8 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
|
||||
},
|
||||
set(mode) {
|
||||
this._runMode =
|
||||
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'claude' ? mode : 'claude';
|
||||
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'claude'
|
||||
? mode
|
||||
: 'claude';
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,11 +307,15 @@ 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';
|
||||
// Entrance animations. Deliberately NOT part of the settings payload: the
|
||||
// styles persist to their own localStorage keys via setAnimTheme(), which
|
||||
// keeps them per-device without touching the .strict() SettingsUpdateSchema.
|
||||
this._syncEntranceAnimSetting?.();
|
||||
// WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide
|
||||
// the toggle there so it can't promise something that won't apply).
|
||||
document.getElementById('appSettingsWebglRenderer').checked = settings.webglRendererEnabled ?? defaults.webglRendererEnabled ?? true;
|
||||
@@ -323,12 +329,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Session Manager + Away Digest buttons default OFF; Cron button defaults ON.
|
||||
// Session Manager, Away Digest and Cron buttons all default OFF (opt-in under
|
||||
// Display → Header Displays; the Cron button also ships with btn-cron--hidden
|
||||
// in the template, so an unchecked box and a hidden button stay consistent).
|
||||
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 +372,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 || {};
|
||||
@@ -405,7 +414,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('eventIdleAudio').checked = idlePref.audio ?? false;
|
||||
// Response complete (stop)
|
||||
const stopPref = eventTypes.stop || {};
|
||||
document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? true;
|
||||
document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? false;
|
||||
document.getElementById('eventStopBrowser').checked = stopPref.browser ?? false;
|
||||
document.getElementById('eventStopPush').checked = stopPref.push ?? false;
|
||||
document.getElementById('eventStopAudio').checked = stopPref.audio ?? false;
|
||||
@@ -1419,6 +1428,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 +1477,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: {
|
||||
@@ -1577,7 +1593,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
audio: document.getElementById('eventSubagentAudio').checked,
|
||||
},
|
||||
},
|
||||
_version: 4,
|
||||
_version: 5,
|
||||
};
|
||||
if (this.notificationManager) {
|
||||
this.notificationManager.preferences = notifPrefsToSave;
|
||||
@@ -1590,6 +1606,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 +1635,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
cjkInputEnabled: _cjk,
|
||||
extendedKeyboardBar: _ekb,
|
||||
skin: _skin,
|
||||
language: _language,
|
||||
showPlanUsageLimits: _pul,
|
||||
showAttachmentsButton: _ahb,
|
||||
showFileViewerButton: _fvb,
|
||||
@@ -1756,17 +1774,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
|
||||
@@ -1782,13 +1804,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
showUltracodeAgents: false,
|
||||
ultracodeFloatingWindows: false,
|
||||
showMultiMonitorButton: false,
|
||||
// Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it
|
||||
// OFF so the phone header stays minimal and the mobile-header-buttons
|
||||
// policy guard keeps passing.
|
||||
showPlanUsageLimits: false,
|
||||
showAttachmentsButton: false,
|
||||
showFileViewerButton: false,
|
||||
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 +1875,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 +1885,40 @@ 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;
|
||||
}
|
||||
},
|
||||
|
||||
// Resolved per-device state of the plan-usage chip. Desktop defaults ON,
|
||||
// handhelds default OFF (the mobile block in getDefaultSettings() sets false,
|
||||
// and the mobile-header-buttons-policy guard depends on that staying false).
|
||||
// Single source of truth for THREE call sites that must never disagree: the
|
||||
// App Settings checkbox, the chip's visibility, and the statusLineTelemetry
|
||||
// flag sent on session create. A chip shown without telemetry renders "—"
|
||||
// forever, which is exactly the drift this helper prevents.
|
||||
planUsageChipEnabled(settings = null) {
|
||||
const s = settings ?? this.loadAppSettingsFromStorage();
|
||||
return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true;
|
||||
},
|
||||
|
||||
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 +1935,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 +1961,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);
|
||||
@@ -1928,11 +1988,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
ultracodeBtn.classList.toggle('btn-ultracode-agents--hidden', !showUltracodeAgents);
|
||||
}
|
||||
|
||||
// Plan-usage chip — hidden by default (App Settings → Display → "Plan Usage
|
||||
// Limits"). Server renders the initial state on reload; this handles a live
|
||||
// toggle from a settings save. Marker class (base is display:inline-flex
|
||||
// !important), matching the response-viewer/multimonitor pattern.
|
||||
const showPlanUsageLimits = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
// Plan-usage chip — shown by default on desktop, OFF on handhelds (App
|
||||
// Settings → Display → "Plan Usage Limits"). The template always ships it
|
||||
// hidden because display is per-device and the server cannot know a
|
||||
// localStorage value, so THIS is what reveals it on every load as well as
|
||||
// on a live toggle. Marker class (base is display:inline-flex !important),
|
||||
// matching the response-viewer/multimonitor pattern.
|
||||
const showPlanUsageLimits = this.planUsageChipEnabled(settings);
|
||||
const planUsageChip = document.getElementById('planUsageChip');
|
||||
if (planUsageChip) {
|
||||
planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits);
|
||||
@@ -1960,8 +2022,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 +2238,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,12 +2266,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
|
||||
// can show it while mobile stays hidden. It used to sync, so an older
|
||||
// server.json may still carry `true` — drop it so the server value is NEVER
|
||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||
// handheld default OFF): desktop can show it while mobile stays hidden. It
|
||||
// used to sync, so an older server.json may still carry a value — drop it
|
||||
// so the server value is NEVER
|
||||
// seeded into a device that didn't explicitly enable it (collection is handled
|
||||
// separately via the statusLineTelemetry action, not this display flag).
|
||||
delete appSettings.showPlanUsageLimits;
|
||||
@@ -2235,7 +2299,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (notificationPreferences && this.notificationManager) {
|
||||
const localNotifPrefs = localStorage.getItem(this.notificationManager.getStorageKey());
|
||||
if (!localNotifPrefs) {
|
||||
this.notificationManager.preferences = notificationPreferences;
|
||||
this.notificationManager.preferences =
|
||||
this.notificationManager.normalizePreferences(notificationPreferences);
|
||||
this.notificationManager.savePreferences();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +465,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (typeof this._appendUltracodeAgentConnectionLines === 'function') {
|
||||
this._appendUltracodeAgentConnectionLines(svg, rects);
|
||||
}
|
||||
|
||||
// Every path above was just created from scratch, so any line entrance in
|
||||
// flight has to be re-attached here (resumed via a negative animation-delay).
|
||||
this._applyLineEntrances?.(svg);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -729,12 +733,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Only the `fly` entrance style parks the window on its parent tab; every
|
||||
// CSS-animated style (entrance-animations.js) needs it to start at its
|
||||
// resting position, or the animation would play at the wrong place.
|
||||
const flyFromTab = !!parentTab && !isMobile && this.windowEntranceFliesFromTab?.() !== false;
|
||||
|
||||
// If we have a parent tab, start window at tab position for spawn animation
|
||||
if (isMobile) {
|
||||
// Mobile: position using top (keyboard-aware positioning calculated above)
|
||||
win.style.top = `${finalY}px`;
|
||||
win.style.bottom = 'auto';
|
||||
} else if (parentTab) {
|
||||
} else if (flyFromTab) {
|
||||
const tabRect = parentTab.getBoundingClientRect();
|
||||
win.style.left = `${tabRect.left}px`;
|
||||
win.style.top = `${tabRect.bottom}px`;
|
||||
@@ -812,7 +821,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.subagentWindows.get(agentId).resizeObserver = resizeObserver;
|
||||
|
||||
// Animate to final position if spawning from tab (desktop only)
|
||||
if (parentTab && !isMobile) {
|
||||
if (flyFromTab) {
|
||||
requestAnimationFrame(() => {
|
||||
win.style.transition = 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
||||
win.style.left = `${finalX}px`;
|
||||
@@ -824,11 +833,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
setTimeout(() => {
|
||||
win.style.transition = '';
|
||||
win.classList.remove('spawning');
|
||||
// The line only becomes meaningful once the window has landed, so its
|
||||
// draw-in starts here rather than at spawn.
|
||||
this.markConnectionLineEntering?.(agentId);
|
||||
this.updateConnectionLines();
|
||||
}, 400);
|
||||
});
|
||||
} else {
|
||||
// No animation (mobile uses CSS positioning), just update connection lines
|
||||
// CSS-animated entrance styles (and mobile) run in place. The window is
|
||||
// already at its resting position, so the connection line can be drawn
|
||||
// against a correct rect right away and animate alongside it.
|
||||
//
|
||||
// Skipped when the window spawns hidden (its agent belongs to a background
|
||||
// tab): a display:none element never runs its animation, so `animationend`
|
||||
// would never fire and the entrance class plus its inline custom property
|
||||
// would stick to the window forever. Nothing is visible to animate anyway,
|
||||
// and revealing it later is a tab switch, not a spawn.
|
||||
if (!shouldHide) {
|
||||
this.applyWindowEntrance?.(win);
|
||||
this.markConnectionLineEntering?.(agentId);
|
||||
}
|
||||
this.updateConnectionLines();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -210,6 +210,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.body.appendChild(win);
|
||||
// Drop the spawn class on the next frame so the transition runs.
|
||||
requestAnimationFrame(() => win.classList.remove('spawning'));
|
||||
// Then hand over to the chosen entrance style (no-op for `fly`/`off`, which
|
||||
// leave the small scale-in transition above as the whole animation).
|
||||
this.applyWindowEntrance?.(win);
|
||||
|
||||
const header = win.querySelector('.ultracode-window-header');
|
||||
const dragListeners = this.makeWindowDraggable(win, header);
|
||||
@@ -586,6 +589,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
document.body.appendChild(win);
|
||||
requestAnimationFrame(() => win.classList.remove('spawning'));
|
||||
this.applyWindowEntrance?.(win);
|
||||
|
||||
const header = win.querySelector('.ultracode-window-header');
|
||||
const dragListeners = this.makeWindowDraggable(win, header);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type SessionColor,
|
||||
type CodexConfig,
|
||||
type GeminiConfig,
|
||||
type AntigravityConfig,
|
||||
} from '../../types.js';
|
||||
import { Session, isAltScreenStripMode } from '../../session.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -32,12 +33,15 @@ import {
|
||||
AutoClearSchema,
|
||||
AutoCompactSchema,
|
||||
AutoResumeSchema,
|
||||
PinSessionSchema,
|
||||
ImageWatcherSchema,
|
||||
FlickerFilterSchema,
|
||||
QuickRunSchema,
|
||||
QuickStartSchema,
|
||||
InteractiveStartSchema,
|
||||
SessionOrderUpdateSchema,
|
||||
} from '../schemas.js';
|
||||
import { mergeSessionOrder } from '../../session-order.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
canAccessOwned,
|
||||
@@ -62,7 +66,7 @@ import {
|
||||
updateCaseModel,
|
||||
stripCaseEnvKeys,
|
||||
applyStatusLineConfig,
|
||||
refreshStaleHookSecret,
|
||||
refreshStaleCodemanHooks,
|
||||
} from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
@@ -83,7 +87,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,
|
||||
@@ -273,25 +283,35 @@ export function _resetPasteRateBuckets(): void {
|
||||
|
||||
/**
|
||||
* Security (multi-user §6.3): the Claude-only permission-mode downgrade does not
|
||||
* cover the other CLIs' bypass switches. Codex `--dangerously-bypass-approvals-and-sandbox`
|
||||
* and Gemini `--approval-mode yolo` disable the safety classifier the non-granted-user
|
||||
* downgrade is meant to keep on, so clamp them for a non-granted owner. buildGeminiCommand
|
||||
* defaults an ABSENT approvalMode to yolo, so the gemini config must be MATERIALIZED
|
||||
* (auto_edit) even when the request sent none. No-op in single-user mode / for a granted
|
||||
* cover the other CLIs' bypass switches. Codex `--dangerously-bypass-approvals-and-sandbox`,
|
||||
* Gemini `--approval-mode yolo`, and Antigravity `--dangerously-skip-permissions` disable
|
||||
* the safety classifier the non-granted-user downgrade is meant to keep on, so clamp them
|
||||
* for a non-granted owner. buildGeminiCommand defaults an ABSENT approvalMode to yolo, so
|
||||
* the gemini config must be MATERIALIZED (auto_edit) even when the request sent none.
|
||||
* Antigravity is like Codex: an ABSENT config already defaults safe (no bypass flag), so
|
||||
* only a sent config needs the flag forced off. No-op in single-user mode / for a granted
|
||||
* owner (canUsernameRunPrivilegedCommands returns true when !isMultiUserMode()).
|
||||
*/
|
||||
async function clampExternalCliBypassForOwner(
|
||||
owner: string | undefined,
|
||||
codexConfig: CodexConfig | undefined,
|
||||
geminiConfig: GeminiConfig | undefined
|
||||
): Promise<{ codexConfig: CodexConfig | undefined; geminiConfig: GeminiConfig | undefined }> {
|
||||
geminiConfig: GeminiConfig | undefined,
|
||||
antigravityConfig: AntigravityConfig | undefined
|
||||
): Promise<{
|
||||
codexConfig: CodexConfig | undefined;
|
||||
geminiConfig: GeminiConfig | undefined;
|
||||
antigravityConfig: AntigravityConfig | undefined;
|
||||
}> {
|
||||
const granted = await canUsernameRunPrivilegedCommands(owner);
|
||||
if (granted) return { codexConfig, geminiConfig };
|
||||
// Non-granted: force codex bypass off (only meaningful when a config was sent) and
|
||||
// materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default).
|
||||
if (granted) return { codexConfig, geminiConfig, antigravityConfig };
|
||||
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
|
||||
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default).
|
||||
const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig;
|
||||
const clampedGemini: GeminiConfig = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' };
|
||||
return { codexConfig: clampedCodex, geminiConfig: clampedGemini };
|
||||
const clampedAntigravity = antigravityConfig
|
||||
? { ...antigravityConfig, dangerouslySkipPermissions: false }
|
||||
: antigravityConfig;
|
||||
return { codexConfig: clampedCodex, geminiConfig: clampedGemini, antigravityConfig: clampedAntigravity };
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
@@ -328,6 +348,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 +369,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
|
||||
@@ -379,6 +425,7 @@ export function registerSessionRoutes(
|
||||
body.mode !== 'opencode' &&
|
||||
body.mode !== 'codex' &&
|
||||
body.mode !== 'gemini' &&
|
||||
body.mode !== 'antigravity' &&
|
||||
body.envOverrides &&
|
||||
Object.keys(body.envOverrides).length > 0 &&
|
||||
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
|
||||
@@ -410,7 +457,7 @@ export function registerSessionRoutes(
|
||||
// unconditional hook-secret gate keeps accepting its hook events. No-op for fresh
|
||||
// cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks.
|
||||
if ((body.mode ?? 'claude') === 'claude') {
|
||||
await refreshStaleHookSecret(workingDir).catch(() => {});
|
||||
await refreshStaleCodemanHooks(workingDir).catch(() => {});
|
||||
}
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
@@ -445,6 +492,15 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
}
|
||||
if (body.mode === 'antigravity') {
|
||||
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
|
||||
if (!isAntigravityAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-validate resumeSessionId: check that the conversation file actually exists
|
||||
// in Claude's projects directory. If not, skip resume to avoid confusing
|
||||
@@ -486,18 +542,20 @@ export function registerSessionRoutes(
|
||||
? body.codexConfig?.model
|
||||
: mode === 'gemini'
|
||||
? body.geminiConfig?.model
|
||||
: mode !== 'shell'
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
: mode === 'antigravity'
|
||||
? body.antigravityConfig?.model
|
||||
: mode !== 'shell'
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
// Section 6.3: force non-granted users to a classifier-guarded mode.
|
||||
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
|
||||
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
const { codexConfig: gatedCodexConfig, geminiConfig: gatedGeminiConfig } = await clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
body.codexConfig,
|
||||
body.geminiConfig
|
||||
);
|
||||
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
const {
|
||||
codexConfig: gatedCodexConfig,
|
||||
geminiConfig: gatedGeminiConfig,
|
||||
antigravityConfig: gatedAntigravityConfig,
|
||||
} = await clampExternalCliBypassForOwner(owner, body.codexConfig, body.geminiConfig, body.antigravityConfig);
|
||||
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
@@ -512,10 +570,12 @@ export function registerSessionRoutes(
|
||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
|
||||
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
|
||||
resumeSessionId: validatedResumeId,
|
||||
envOverrides: body.envOverrides,
|
||||
effort: body.effort,
|
||||
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
|
||||
remote,
|
||||
owner,
|
||||
});
|
||||
|
||||
@@ -724,11 +784,12 @@ export function registerSessionRoutes(
|
||||
|
||||
try {
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
|
||||
// Ralph tracker is not supported for opencode / codex / gemini sessions
|
||||
// Ralph tracker is not supported for opencode / codex / gemini / antigravity sessions
|
||||
if (
|
||||
session.mode !== 'opencode' &&
|
||||
session.mode !== 'codex' &&
|
||||
session.mode !== 'gemini' &&
|
||||
session.mode !== 'antigravity' &&
|
||||
ctx.store.getConfig().ralphEnabled &&
|
||||
!session.ralphTracker.autoEnableDisabled
|
||||
) {
|
||||
@@ -980,6 +1041,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 +1244,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 +1790,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) => {
|
||||
@@ -1765,6 +1968,7 @@ export function registerSessionRoutes(
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
envOverrides,
|
||||
effort,
|
||||
} = parseBody(QuickStartSchema, req.body);
|
||||
@@ -1810,6 +2014,7 @@ export function registerSessionRoutes(
|
||||
modelOverride !== undefined ||
|
||||
codexConfig ||
|
||||
geminiConfig ||
|
||||
antigravityConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
@@ -1840,6 +2045,7 @@ export function registerSessionRoutes(
|
||||
effort ||
|
||||
codexConfig ||
|
||||
geminiConfig ||
|
||||
antigravityConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
@@ -1932,6 +2138,17 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Check Antigravity availability if requested
|
||||
if (mode === 'antigravity') {
|
||||
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
|
||||
if (!isAntigravityAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
|
||||
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
|
||||
// external project directories are honoured by quick-start just like regular case routes.
|
||||
@@ -1978,8 +2195,8 @@ export function registerSessionRoutes(
|
||||
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
|
||||
|
||||
// Write .claude/settings.local.json with hooks for desktop notifications
|
||||
// (Claude-specific — OpenCode, Codex, and Gemini use their own systems)
|
||||
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
|
||||
// (Claude-specific — OpenCode, Codex, Gemini, and Antigravity use their own systems)
|
||||
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini' && mode !== 'antigravity') {
|
||||
await writeHooksConfig(resolvedCasePath);
|
||||
}
|
||||
|
||||
@@ -1992,14 +2209,21 @@ export function registerSessionRoutes(
|
||||
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
|
||||
// the hooks aren't ours or already carry the secret. Skipped for remote cases —
|
||||
// resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem.
|
||||
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
|
||||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
||||
}
|
||||
|
||||
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
|
||||
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
|
||||
// hook-idle detection fire (decision: wire hooks now). Never clobbers an existing
|
||||
// configured project. Skipped for external CLIs (they use their own systems).
|
||||
if (docker && docker.hooksEnabled && mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
|
||||
if (
|
||||
docker &&
|
||||
docker.hooksEnabled &&
|
||||
mode !== 'opencode' &&
|
||||
mode !== 'codex' &&
|
||||
mode !== 'gemini' &&
|
||||
mode !== 'antigravity'
|
||||
) {
|
||||
try {
|
||||
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
|
||||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||||
@@ -2008,7 +2232,7 @@ export function registerSessionRoutes(
|
||||
if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) {
|
||||
await writeHooksConfig(resolvedCasePath);
|
||||
} else {
|
||||
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
|
||||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal — the session still runs, hooks may be degraded */
|
||||
@@ -2028,6 +2252,7 @@ export function registerSessionRoutes(
|
||||
mode !== 'opencode' &&
|
||||
mode !== 'codex' &&
|
||||
mode !== 'gemini' &&
|
||||
mode !== 'antigravity' &&
|
||||
!remote &&
|
||||
envOverrides &&
|
||||
Object.keys(envOverrides).length > 0
|
||||
@@ -2046,17 +2271,19 @@ export function registerSessionRoutes(
|
||||
? codexConfig?.model
|
||||
: mode === 'gemini'
|
||||
? geminiConfig?.model
|
||||
: mode !== 'shell'
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
: mode === 'antigravity'
|
||||
? antigravityConfig?.model
|
||||
: mode !== 'shell'
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
|
||||
// Section 6.3: clamp Codex/Gemini bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
const { codexConfig: qsGatedCodexConfig, geminiConfig: qsGatedGeminiConfig } = await clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
codexConfig,
|
||||
geminiConfig
|
||||
);
|
||||
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
const {
|
||||
codexConfig: qsGatedCodexConfig,
|
||||
geminiConfig: qsGatedGeminiConfig,
|
||||
antigravityConfig: qsGatedAntigravityConfig,
|
||||
} = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig);
|
||||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const session = new Session({
|
||||
workingDir: resolvedCasePath,
|
||||
@@ -2072,6 +2299,7 @@ export function registerSessionRoutes(
|
||||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
|
||||
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
|
||||
envOverrides,
|
||||
effort,
|
||||
remote,
|
||||
@@ -2228,6 +2456,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 +2647,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 +2693,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 +2711,7 @@ export function registerSessionRoutes(
|
||||
sizeBytes: fileStat.size,
|
||||
lastModified: fileStat.mtime.toISOString(),
|
||||
firstPrompt,
|
||||
lastPrompt,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -2493,6 +2787,8 @@ export function registerSessionRoutes(
|
||||
createdAt: st.createdAt,
|
||||
lastActivityAt: st.lastActivityAt,
|
||||
claudeSessionId: s.claudeSessionId ?? undefined,
|
||||
pinned: st.pinned,
|
||||
pinnedAt: st.pinnedAt,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2508,6 +2804,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 +2839,7 @@ export function registerSessionRoutes(
|
||||
sizeBytes: h.sizeBytes,
|
||||
lastModified: h.lastModified,
|
||||
firstPrompt: h.firstPrompt,
|
||||
lastPrompt: h.lastPrompt,
|
||||
projectKey: h.projectKey,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CLI Integrations (OpenCode, Codex, Gemini)
|
||||
// CLI Integrations (OpenCode, Codex, Gemini, Antigravity)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// ========== OpenCode ==========
|
||||
@@ -405,6 +405,16 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Antigravity ==========
|
||||
|
||||
app.get('/api/antigravity/status', async () => {
|
||||
const { isAntigravityAvailable, resolveAntigravityDir } = await import('../../utils/antigravity-cli-resolver.js');
|
||||
return {
|
||||
available: isAntigravityAvailable(),
|
||||
path: resolveAntigravityDir(),
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// State & Lifecycle (cleanup, lifecycle log, stats)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -692,20 +702,27 @@ export function registerSystemRoutes(
|
||||
await ctx.mux.setHistoryLimit(resolveTerminalHistoryConfig(merged).tmuxHistoryLimit);
|
||||
}
|
||||
|
||||
// Service toggles resolve from `merged` (existing + incoming), NEVER from the
|
||||
// raw request body. A PARTIAL PUT omits keys it does not intend to change, and
|
||||
// reading the body directly turned every omission into "apply the default":
|
||||
// a body of just `{statusLineTelemetry:true}` would START the subagent watcher
|
||||
// (`?? true`) and STOP the workflow + image watchers (`?? false`), silently
|
||||
// undoing the user's persisted config. Reading `merged` makes any PUT reconcile
|
||||
// services to the effective stored settings instead, which also self-heals
|
||||
// drift. Same convention as the tmuxHistoryLimit block above.
|
||||
// Handle subagent tracking toggle dynamically
|
||||
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
||||
toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
||||
|
||||
// Handle ultracode/workflow run watcher toggle dynamically (default OFF).
|
||||
// Either the docked panel OR the floating windows keep the watcher running.
|
||||
toggleService(
|
||||
((settings.showUltracodeAgents as boolean) ?? false) ||
|
||||
((settings.ultracodeFloatingWindows as boolean) ?? false),
|
||||
((merged.showUltracodeAgents as boolean) ?? false) || ((merged.ultracodeFloatingWindows as boolean) ?? false),
|
||||
workflowRunWatcher,
|
||||
'Workflow run watcher'
|
||||
);
|
||||
|
||||
// Handle image watcher toggle dynamically
|
||||
toggleService((settings.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
|
||||
toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
|
||||
// Re-watch all active sessions that have image watcher enabled
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.imageWatcherEnabled) {
|
||||
|
||||
@@ -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,10 +50,43 @@ 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 */
|
||||
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_'];
|
||||
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'ANTIGRAVITY_'];
|
||||
|
||||
/** Env var keys that are always blocked (security-sensitive) */
|
||||
const BLOCKED_ENV_KEYS = new Set([
|
||||
@@ -82,7 +116,7 @@ const safeEnvOverridesSchema = z
|
||||
},
|
||||
{
|
||||
message:
|
||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, and GOOGLE_* keys are allowed.',
|
||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, and ANTIGRAVITY_* keys are allowed.',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -172,9 +206,26 @@ const GeminiConfigSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
/** Schema for Antigravity CLI (agy)-specific configuration */
|
||||
const AntigravityConfigSchema = z
|
||||
.object({
|
||||
model: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._\-/]+$/)
|
||||
.optional(),
|
||||
dangerouslySkipPermissions: z.boolean().optional(),
|
||||
resumeConversationId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._-]+$/)
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const CreateSessionSchema = z.object({
|
||||
workingDir: safePathSchema.optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']).optional(),
|
||||
name: z.string().max(100).optional(),
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
@@ -186,12 +237,29 @@ export const CreateSessionSchema = z.object({
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
geminiConfig: GeminiConfigSchema,
|
||||
antigravityConfig: AntigravityConfigSchema,
|
||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||
resumeSessionId: z
|
||||
.string()
|
||||
.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(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -278,6 +346,7 @@ const RemoteCommandOverridesSchema = z
|
||||
opencode: z.string().min(1).max(300).optional(),
|
||||
codex: z.string().min(1).max(300).optional(),
|
||||
gemini: z.string().min(1).max(300).optional(),
|
||||
antigravity: z.string().min(1).max(300).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
@@ -550,10 +619,11 @@ export const QuickStartSchema = z.object({
|
||||
* a real host dir, so the settings file crosses the bind mount); rejected for
|
||||
* remote cases (the file would be written on the WRONG machine). */
|
||||
modelOverride: z.string().max(50).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']).optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
geminiConfig: GeminiConfigSchema,
|
||||
antigravityConfig: AntigravityConfigSchema,
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort: effortLevelSchema,
|
||||
@@ -634,6 +704,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 +744,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 +937,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(),
|
||||
@@ -882,7 +977,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
|
||||
/** Shared field shape for creating/updating a scheduled job. */
|
||||
const CronJobBaseSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']),
|
||||
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity']),
|
||||
workingDir: safePathSchema,
|
||||
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
|
||||
promptMode: z.enum(['inline_text', 'prompt_file_path']),
|
||||
@@ -949,6 +1044,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 +1238,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 };
|
||||
@@ -2419,6 +2461,7 @@ export class WebServer extends EventEmitter {
|
||||
openCodeConfig: muxSession.mode === 'opencode' ? savedState?.openCodeConfig : undefined,
|
||||
codexConfig: muxSession.mode === 'codex' ? savedState?.codexConfig : undefined,
|
||||
geminiConfig: muxSession.mode === 'gemini' ? savedState?.geminiConfig : undefined,
|
||||
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
|
||||
envOverrides: savedEnvOverrides,
|
||||
effort: savedState?.effort,
|
||||
attachmentHistory: savedAttachmentHistory,
|
||||
@@ -2603,6 +2646,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 +2661,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;
|
||||
}
|
||||