From dae82388ed5b9b574d4266d72af48d7b9033f1e5 Mon Sep 17 00:00:00 2001
From: codeman-local
Date: Tue, 21 Jul 2026 00:39:10 +0800
Subject: [PATCH 01/25] feat(ui): add light skin themes
---
CLAUDE.md | 2 +
src/web/public/index.html | 28 ++--
src/web/public/mobile.css | 62 ++++++++
src/web/public/panels-ui.js | 1 +
src/web/public/settings-ui.js | 2 +
src/web/public/styles.css | 269 ++++++++++++++++++++++++++--------
src/web/public/terminal-ui.js | 18 ++-
test/skin-themes.test.ts | 101 +++++++++++++
8 files changed, 413 insertions(+), 70 deletions(-)
create mode 100644 test/skin-themes.test.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index 56ad327c..9001c804 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -212,6 +212,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
+**Skins**: App chrome tokens live at the top of `public/styles.css`; the matching xterm ANSI palettes and light-skin contrast policy live in `public/terminal-ui.js`. Every new skin must also be added to the pre-paint allowlist and Settings picker in `public/index.html`. `test/skin-themes.test.ts` guards that four-way parity so reloads do not flash/fall back and teammate terminals match the main terminal.
+
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over.
**WebGL renderer toggle** (#140, `webglRendererEnabled`): per-device (`displayKeys` set, stripped from the server payload — NOT in `SettingsUpdateSchema`, which is `.strict()`). The GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads; it's cleared only by an explicit OFF→ON save transition or `?webgl=force` (`shouldSkipWebGL` in constants.js). `?nowebgl` still forces the DOM renderer per-load.
diff --git a/src/web/public/index.html b/src/web/public/index.html
index b51c96d1..8fb809db 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -45,20 +45,20 @@
-
+
@@ -1190,9 +1190,17 @@
Skin
- Daylight Blue
- Daylight Green
- OG Codeman
+
+ Paper Gray
+ Solarized Light
+ Catppuccin Latte
+ Rosé Pine Dawn
+
+
+ Daylight Blue
+ Daylight Green
+ OG Codeman
+
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css
index 67cf181d..2dfcf41d 100644
--- a/src/web/public/mobile.css
+++ b/src/web/public/mobile.css
@@ -2226,6 +2226,68 @@ html.mobile-init .file-browser-panel {
}
}
+/* Light-skin compatibility for mobile-only chrome. These components predate
+ the shared skin system and intentionally retain their original dark values
+ for the three dark skins above. */
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.header, .toolbar, .keyboard-accessory-bar) {
+ background: var(--glass-bg);
+ border-color: var(--glass-border);
+ color: var(--text);
+}
+
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .toolbar .btn-case-add, .accessory-btn) {
+ background: var(--control-bg);
+ border-color: var(--control-border);
+ color: var(--text-dim);
+}
+
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .btn-case-add:hover, .btn-case-add:active, .accessory-btn:active) {
+ background: var(--control-bg-hover);
+ border-color: var(--control-border-hover);
+ color: var(--text);
+}
+
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-claude, .btn-toolbar.btn-run-gear.mode-claude) {
+ background: linear-gradient(135deg, var(--accent-grad-a), var(--accent-grad-b));
+ border-color: var(--accent);
+ color: var(--accent-ink);
+}
+
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-opencode, .btn-toolbar.btn-run-gear.mode-opencode) {
+ background: linear-gradient(135deg, var(--accent-d), var(--accent-grad-b));
+ border-color: var(--accent);
+ color: var(--accent-ink);
+}
+
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-gemini, .btn-toolbar.btn-run-gear.mode-gemini) {
+ background: linear-gradient(135deg, #174ea6, #4f46e5);
+ border-color: #315fc3;
+ color: #ffffff;
+}
+
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
+ border-left-color: var(--control-border-hover) !important;
+}
+
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.case-settings-popover-mobile, .mobile-case-picker-sheet) {
+ background: var(--floating-bg);
+ border-color: var(--control-border);
+ color: var(--text);
+ box-shadow: var(--elevated-shadow);
+}
+
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.case-settings-popover-mobile .checkbox-inline, #createCaseModal .form-row label) {
+ color: var(--text);
+}
+
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .case-settings-popover-mobile .form-hint {
+ color: var(--text-muted);
+}
+
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .mobile-case-picker .modal-backdrop {
+ background: var(--modal-backdrop);
+}
+
/* Keyboard accessory bar + paste overlay base styles moved to styles.css
(always loaded — covers iPad landscape where mobile.css doesn't load).
diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js
index 7c520853..eb69a823 100644
--- a/src/web/public/panels-ui.js
+++ b/src/web/public/panels-ui.js
@@ -2267,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,
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index 6a3a7e25..9e0c0d0c 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -1866,6 +1866,8 @@ Object.assign(CodemanApp.prototype, {
const skin = settings.skin ?? defaults.skin ?? 'daylight-blue';
document.documentElement.setAttribute('data-skin', skin);
window.__codemanSkin = skin;
+ const themeColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-dark').trim();
+ if (themeColor) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', themeColor);
try {
localStorage.setItem('codeman:skin', skin);
} catch (_e) {
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index a29a8606..2d0b475b 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -18,9 +18,8 @@
}
:root {
- /* All three skins are dark — tell the UA to render native controls (select
- option popups, date/time pickers, scrollbars) in dark mode so they don't
- flash as white OS widgets. */
+ /* Dark is the safe fallback. Light skins override this so native selects,
+ date/time pickers, form controls, and scrollbars match the chosen skin. */
color-scheme: dark;
/* Carbon Aurora · Daylight — deep-but-not-black slate, surfaces step UP into the light */
--bg-dark: #11151c;
@@ -52,6 +51,15 @@
--toolbar-height: 42px;
--glass-bg: rgba(31, 38, 48, 0.85);
--glass-border: rgba(255, 255, 255, 0.08);
+ --control-bg: rgba(255, 255, 255, 0.045);
+ --control-bg-hover: rgba(255, 255, 255, 0.08);
+ --control-border: rgba(255, 255, 255, 0.09);
+ --control-border-hover: rgba(255, 255, 255, 0.14);
+ --floating-bg: rgba(31, 38, 48, 0.96);
+ --banner-bg-a: rgba(31, 38, 48, 0.92);
+ --banner-bg-b: rgba(22, 27, 35, 0.92);
+ --modal-backdrop: rgba(0, 0, 0, 0.6);
+ --elevated-shadow: 0 16px 64px rgba(0, 0, 0, 0.5), 0 4px 16px rgba(0, 0, 0, 0.3);
--subtle-shadow: 0 -1px 3px rgba(0, 0, 0, 0.3), 0 -4px 16px rgba(0, 0, 0, 0.15);
--btn-radius: 6px;
--transition-smooth: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
@@ -147,6 +155,150 @@ html[data-skin="og"] {
--ui-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
+/* ===== Light skins =====
+ Each palette covers both the application chrome and the xterm palette in
+ terminal-ui.js. The names reference their source palettes; Paper Gray is a
+ Codeman-specific neutral theme informed by GitHub Primer Light. */
+html[data-skin="paper-gray"] {
+ color-scheme: light;
+ --bg-dark: #f3f4f6; --bg-card: #ffffff; --bg-input: #eef1f4; --bg-hover: #e4e9ef;
+ --border: #d0d7de; --border-light: #afb8c1;
+ --text: #1f2328; --text-dim: #59636e; --text-muted: #6e7781;
+ --accent: #0969da; --accent-hover: #0550ae;
+ --term-bg: #f6f8fa;
+ --green: #1a7f37; --yellow: #9a6700; --red: #cf222e;
+ --glass-bg: rgba(255, 255, 255, 0.9); --glass-border: rgba(31, 35, 40, 0.15);
+ --control-bg: rgba(31, 35, 40, 0.045); --control-bg-hover: rgba(31, 35, 40, 0.08);
+ --control-border: rgba(31, 35, 40, 0.12); --control-border-hover: rgba(31, 35, 40, 0.2);
+ --floating-bg: rgba(255, 255, 255, 0.97);
+ --banner-bg-a: rgba(255, 255, 255, 0.96); --banner-bg-b: rgba(238, 241, 244, 0.96);
+ --modal-backdrop: rgba(31, 35, 40, 0.32);
+ --elevated-shadow: 0 16px 48px rgba(31, 35, 40, 0.18), 0 3px 12px rgba(31, 35, 40, 0.1);
+ --accent-d: #0550ae; --emerald: #0969da; --teal: #218bff;
+ --accent-soft: #1a7f37; --accent-ink: #ffffff;
+ --accent-rgb: 9, 105, 218;
+ --accent-grad-a: #218bff; --accent-grad-b: #0969da;
+ --run-hover-a: #0969da; --run-hover-b: #0550ae; --gear-hover: #033d8b;
+ --ring-glow: 0 0 10px -2px rgba(9, 105, 218, 0.35);
+ --session-red: #cf222e; --session-orange: #bc4c00; --session-yellow: #9a6700;
+ --session-green: #1a7f37; --session-blue: #0969da; --session-purple: #8250df; --session-pink: #bf3989;
+ --ui-font: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+}
+
+html[data-skin="solarized-light"] {
+ color-scheme: light;
+ --bg-dark: #fdf6e3; --bg-card: #fffaf0; --bg-input: #eee8d5; --bg-hover: #e5deca;
+ --border: #d7cfb9; --border-light: #b8ad91;
+ --text: #073642; --text-dim: #586e75; --text-muted: #6f8185;
+ --accent: #147ba3; --accent-hover: #07658b;
+ --term-bg: #fdf6e3;
+ --green: #6f8100; --yellow: #8f7000; --red: #c43c39;
+ --glass-bg: rgba(255, 250, 240, 0.9); --glass-border: rgba(88, 110, 117, 0.2);
+ --control-bg: rgba(88, 110, 117, 0.07); --control-bg-hover: rgba(88, 110, 117, 0.12);
+ --control-border: rgba(88, 110, 117, 0.18); --control-border-hover: rgba(88, 110, 117, 0.3);
+ --floating-bg: rgba(255, 250, 240, 0.97);
+ --banner-bg-a: rgba(255, 250, 240, 0.96); --banner-bg-b: rgba(238, 232, 213, 0.96);
+ --modal-backdrop: rgba(7, 54, 66, 0.28);
+ --elevated-shadow: 0 16px 48px rgba(7, 54, 66, 0.16), 0 3px 12px rgba(7, 54, 66, 0.09);
+ --accent-d: #07658b; --emerald: #2aa198; --teal: #147ba3;
+ --accent-soft: #6f8100; --accent-ink: #ffffff;
+ --accent-rgb: 20, 123, 163;
+ --accent-grad-a: #2aa198; --accent-grad-b: #147ba3;
+ --run-hover-a: #168f86; --run-hover-b: #07658b; --gear-hover: #075676;
+ --ring-glow: 0 0 10px -2px rgba(20, 123, 163, 0.32);
+ --session-red: #dc322f; --session-orange: #cb4b16; --session-yellow: #9b7800;
+ --session-green: #758600; --session-blue: #147ba3; --session-purple: #6c71c4; --session-pink: #d33682;
+ --ui-font: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+}
+
+html[data-skin="catppuccin-latte"] {
+ color-scheme: light;
+ --bg-dark: #eff1f5; --bg-card: #f8f9fb; --bg-input: #e6e9ef; --bg-hover: #dce0e8;
+ --border: #ccd0da; --border-light: #acb0be;
+ --text: #4c4f69; --text-dim: #5c5f77; --text-muted: #6c6f85;
+ --accent: #1e66f5; --accent-hover: #174fbf;
+ --term-bg: #eff1f5;
+ --green: #3b8f2b; --yellow: #a86605; --red: #d20f39;
+ --glass-bg: rgba(248, 249, 251, 0.9); --glass-border: rgba(76, 79, 105, 0.16);
+ --control-bg: rgba(76, 79, 105, 0.055); --control-bg-hover: rgba(76, 79, 105, 0.1);
+ --control-border: rgba(76, 79, 105, 0.14); --control-border-hover: rgba(76, 79, 105, 0.24);
+ --floating-bg: rgba(248, 249, 251, 0.97);
+ --banner-bg-a: rgba(248, 249, 251, 0.96); --banner-bg-b: rgba(230, 233, 239, 0.96);
+ --modal-backdrop: rgba(76, 79, 105, 0.28);
+ --elevated-shadow: 0 16px 48px rgba(76, 79, 105, 0.17), 0 3px 12px rgba(76, 79, 105, 0.09);
+ --accent-d: #174fbf; --emerald: #179299; --teal: #1e66f5;
+ --accent-soft: #3b8f2b; --accent-ink: #ffffff;
+ --accent-rgb: 30, 102, 245;
+ --accent-grad-a: #209fb5; --accent-grad-b: #1e66f5;
+ --run-hover-a: #17889c; --run-hover-b: #174fbf; --gear-hover: #153f99;
+ --ring-glow: 0 0 10px -2px rgba(30, 102, 245, 0.32);
+ --session-red: #d20f39; --session-orange: #d65d0e; --session-yellow: #a86605;
+ --session-green: #3b8f2b; --session-blue: #1e66f5; --session-purple: #8839ef; --session-pink: #c63c91;
+ --ui-font: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+}
+
+html[data-skin="rose-pine-dawn"] {
+ color-scheme: light;
+ --bg-dark: #faf4ed; --bg-card: #fffaf3; --bg-input: #f2e9e1; --bg-hover: #ebe1da;
+ --border: #dfdad9; --border-light: #c8c1c0;
+ --text: #575279; --text-dim: #6e6a86; --text-muted: #797593;
+ --accent: #286983; --accent-hover: #1f5266;
+ --term-bg: #faf4ed;
+ --green: #286983; --yellow: #96681f; --red: #b4637a;
+ --glass-bg: rgba(255, 250, 243, 0.9); --glass-border: rgba(87, 82, 121, 0.16);
+ --control-bg: rgba(87, 82, 121, 0.055); --control-bg-hover: rgba(87, 82, 121, 0.1);
+ --control-border: rgba(87, 82, 121, 0.14); --control-border-hover: rgba(87, 82, 121, 0.24);
+ --floating-bg: rgba(255, 250, 243, 0.97);
+ --banner-bg-a: rgba(255, 250, 243, 0.96); --banner-bg-b: rgba(242, 233, 225, 0.96);
+ --modal-backdrop: rgba(87, 82, 121, 0.28);
+ --elevated-shadow: 0 16px 48px rgba(87, 82, 121, 0.17), 0 3px 12px rgba(87, 82, 121, 0.09);
+ --accent-d: #1f5266; --emerald: #56949f; --teal: #286983;
+ --accent-soft: #907aa9; --accent-ink: #ffffff;
+ --accent-rgb: 40, 105, 131;
+ --accent-grad-a: #56949f; --accent-grad-b: #286983;
+ --run-hover-a: #3f7f8b; --run-hover-b: #1f5266; --gear-hover: #193f4f;
+ --ring-glow: 0 0 10px -2px rgba(40, 105, 131, 0.32);
+ --session-red: #b4637a; --session-orange: #c06f45; --session-yellow: #96681f;
+ --session-green: #286983; --session-blue: #477f91; --session-purple: #907aa9; --session-pink: #b4637a;
+ --ui-font: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+}
+
+/* Components that predate skin tokens used literal dark glass fills. Keep
+ their structure, but make those elevated surfaces coherent in light mode. */
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) {
+ scrollbar-color: var(--border-light) var(--bg-input);
+}
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(
+ .modal-content,
+ .ralph-dropdown,
+ .ralph-panel.detached,
+ .mobile-case-picker-sheet,
+ .command-palette,
+ .case-combobox-list
+) {
+ 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(
+ .settings-item,
+ .cron-job-row,
+ #cronModal .form-row-switch.cron-switch-row,
+ .cron-weekdays label
+) {
+ background: var(--control-bg);
+ border-color: var(--control-border);
+}
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(
+ .settings-item:hover,
+ .cron-job-row:hover,
+ .cron-weekdays label:hover
+) {
+ background: var(--control-bg-hover);
+ border-color: var(--control-border-hover);
+}
+
* { box-sizing: border-box; margin: 0; padding: 0; }
/* ========== Accessibility: Focus Styles ========== */
@@ -2146,10 +2298,10 @@ body.solo-mode .btn-lifecycle-log {
position: absolute;
top: 100%;
right: 0;
- background: rgba(22, 22, 28, 0.95);
+ background: var(--floating-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
- border: 1px solid rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--control-border);
border-radius: 10px;
min-width: 170px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
@@ -2197,10 +2349,10 @@ body.solo-mode .btn-lifecycle-log {
max-width: 90vw;
max-height: 80vh;
z-index: 1000;
- border: 1px solid rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--control-border);
border-radius: 12px;
- box-shadow: 0 16px 64px rgba(0, 0, 0, 0.5), 0 4px 16px rgba(0, 0, 0, 0.3);
- background: rgba(19, 19, 22, 0.95);
+ box-shadow: var(--elevated-shadow);
+ background: var(--floating-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
overflow: hidden;
@@ -3323,10 +3475,10 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
bottom: 100%;
left: 0;
margin-bottom: 6px;
- background: rgba(22, 22, 28, 0.95);
+ background: var(--floating-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
- border: 1px solid rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--control-border);
border-radius: 10px;
padding: 4px;
z-index: 1000;
@@ -3965,10 +4117,10 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
bottom: 100%;
right: 0;
margin-bottom: 6px;
- background: rgba(22, 22, 28, 0.95);
+ background: var(--floating-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
- border: 1px solid rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--control-border);
border-radius: 10px;
padding: 0.6rem 0.7rem;
width: 220px;
@@ -4293,10 +4445,9 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
transition: border-color var(--transition-smooth), box-shadow var(--transition-smooth);
}
-/* Selects: strip the native (light) control so the field follows the active
- skin instead of rendering as a white OS widget. Opaque --bg-input fill +
- --border, a custom chevron, and color-scheme:dark (set on :root) for a dark
- option popup. Shared by App Settings, Cron, and every other .form-select. */
+/* Selects: strip the native control so the field follows the active skin.
+ Opaque --bg-input fill + --border, a custom chevron, and the skin's
+ color-scheme keep option popups coherent in both modes. */
.form-select {
padding-right: 2rem;
background-color: var(--bg-input);
@@ -4749,21 +4900,21 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
.modal-backdrop {
position: absolute;
inset: 0;
- background: rgba(0, 0, 0, 0.6);
+ background: var(--modal-backdrop);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.modal-content {
position: relative;
- background: rgba(19, 19, 22, 0.95);
+ background: var(--floating-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
- border: 1px solid rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--control-border);
border-radius: 12px;
width: 90%;
max-width: 400px;
- box-shadow: 0 16px 64px rgba(0, 0, 0, 0.5), 0 4px 16px rgba(0, 0, 0, 0.3);
+ box-shadow: var(--elevated-shadow);
/* Performance: isolate modal paint operations */
contain: layout style paint;
}
@@ -4797,7 +4948,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
bottom: 0;
left: 0;
right: 0;
- background: #1a1a1a;
+ background: var(--floating-bg);
border-radius: 16px 16px 0 0;
max-height: 70vh;
display: flex;
@@ -5717,8 +5868,8 @@ kbd {
display: flex;
flex-direction: column;
overflow: hidden;
- background: rgba(19, 19, 22, 0.97);
- border: 1px solid rgba(255, 255, 255, 0.1);
+ background: var(--floating-bg);
+ border: 1px solid var(--control-border);
border-radius: 10px;
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.55), 0 4px 18px rgba(0, 0, 0, 0.35);
}
@@ -5893,7 +6044,7 @@ kbd {
flex-direction: column;
gap: 2px;
padding: 4px;
- background: rgba(22, 22, 28, 0.97);
+ background: var(--floating-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border);
@@ -8036,10 +8187,10 @@ kbd {
height: 350px;
min-width: 300px;
min-height: 200px;
- background: rgba(19, 19, 22, 0.95);
+ background: var(--floating-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
- border: 1px solid rgba(255, 255, 255, 0.07);
+ border: 1px solid var(--control-border);
border-radius: 12px;
box-shadow: 0 16px 64px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.2);
z-index: 1000;
@@ -8188,10 +8339,10 @@ kbd {
max-height: 600px;
min-width: 200px;
min-height: 300px;
- background: rgba(19, 19, 22, 0.95);
+ background: var(--floating-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
- border: 1px solid rgba(255, 255, 255, 0.07);
+ border: 1px solid var(--control-border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 100;
@@ -8391,7 +8542,7 @@ kbd {
.file-preview-overlay {
position: fixed;
inset: 0;
- background: rgba(0, 0, 0, 0.6);
+ background: var(--modal-backdrop);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 2000;
@@ -8409,12 +8560,12 @@ kbd {
max-width: 900px;
height: 80vh;
max-height: 700px;
- background: rgba(19, 19, 22, 0.95);
+ background: var(--floating-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
- border: 1px solid rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--control-border);
border-radius: 12px;
- box-shadow: 0 16px 64px rgba(0, 0, 0, 0.5), 0 4px 16px rgba(0, 0, 0, 0.3);
+ box-shadow: var(--elevated-shadow);
display: flex;
flex-direction: column;
overflow: hidden;
@@ -10668,8 +10819,8 @@ body.touch-device.cjk-input-visible .main {
.keyboard-accessory-bar {
display: none;
height: 44px;
- background: #1a1a1a;
- border-top: 1px solid rgba(255, 255, 255, 0.1);
+ background: var(--floating-bg);
+ border-top: 1px solid var(--control-border);
padding: 6px 8px;
gap: 8px;
align-items: center;
@@ -10769,7 +10920,7 @@ body.touch-device.cjk-input-visible .main {
.paste-overlay {
position: fixed;
inset: 0;
- background: rgba(0, 0, 0, 0.6);
+ background: var(--modal-backdrop);
z-index: 10000;
display: flex;
align-items: flex-start;
@@ -11124,7 +11275,7 @@ body.touch-device.cjk-input-visible .main {
padding: 3px 10px;
border: 1px solid var(--border-light);
border-radius: 5px;
- background: rgba(19, 19, 22, 0.96);
+ background: var(--floating-bg);
color: var(--text-dim);
font-size: 0.68rem;
font-weight: 600;
@@ -11148,9 +11299,9 @@ body.touch-device.cjk-input-visible .main {
gap: 10px;
align-items: center;
padding: 10px;
- border: 1px solid rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--control-border);
border-radius: 8px;
- background: rgba(19, 19, 22, 0.96);
+ background: var(--floating-bg);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
pointer-events: auto;
}
@@ -11186,7 +11337,7 @@ body.touch-device.cjk-input-visible .main {
color: var(--text);
font-size: 0.7rem;
font-weight: 700;
- background: #20202a;
+ background: var(--bg-input);
}
.attachment-thumbnail-fallback.visible {
@@ -11200,7 +11351,7 @@ body.touch-device.cjk-input-visible .main {
width: 44px;
height: 44px;
border-radius: 6px;
- background: #20202a;
+ background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border-light);
font-size: 0.7rem;
@@ -11309,7 +11460,7 @@ body.touch-device.cjk-input-visible .main {
max-width: calc(100vw - 24px);
height: calc(100vh - var(--header-height) - var(--toolbar-height));
height: calc(100dvh - var(--header-height) - var(--toolbar-height));
- background: rgba(19, 19, 22, 0.98);
+ background: var(--floating-bg);
border-left: 1px solid var(--border);
z-index: 10000;
display: flex;
@@ -11431,7 +11582,7 @@ body.touch-device.cjk-input-visible .main {
justify-content: center;
position: absolute;
inset: 0;
- background: #20202a;
+ background: var(--bg-input);
color: var(--text);
font-size: 0.72rem;
font-weight: 700;
@@ -11518,8 +11669,8 @@ html:not([data-skin="og"]) {
box-shadow: none;
}
.session-tab .tab-number {
- background: rgba(255, 255, 255, 0.08);
- border: 1px solid rgba(255, 255, 255, 0.18);
+ background: var(--control-bg-hover);
+ border: 1px solid var(--control-border-hover);
color: var(--text-dim);
}
.session-tab.active .tab-number {
@@ -11540,7 +11691,7 @@ html:not([data-skin="og"]) {
/* ---- Timer banner: emerald-teal, flat ---- */
.timer-banner {
- background: linear-gradient(90deg, rgba(31, 38, 48, 0.92), rgba(22, 27, 35, 0.92));
+ background: linear-gradient(90deg, var(--banner-bg-a), var(--banner-bg-b));
border-bottom: 1px solid var(--glass-border);
}
.timer-value { color: var(--accent); }
@@ -11548,7 +11699,7 @@ html:not([data-skin="og"]) {
/* ---- Respawn banner: lifted slate, calm green hairline ---- */
.respawn-banner {
- background: linear-gradient(90deg, rgba(31, 38, 48, 0.9), rgba(22, 27, 35, 0.9));
+ background: linear-gradient(90deg, var(--banner-bg-a), var(--banner-bg-b));
border-bottom: 1px solid var(--glass-border);
}
.respawn-countdown-timer .respawn-timer-value,
@@ -11563,7 +11714,7 @@ html:not([data-skin="og"]) {
-webkit-background-clip: text;
background-clip: text;
}
-.ralph-ring-bg, .ralph-ring-track { stroke: rgba(255, 255, 255, 0.1); }
+.ralph-ring-bg, .ralph-ring-track { stroke: var(--control-border); }
/* Ralph status badges: collapse the off-token Material palette onto the system */
.ralph-status-badge { color: var(--text-dim); }
.ralph-status-dot { box-shadow: none; }
@@ -11572,13 +11723,13 @@ html:not([data-skin="og"]) {
/* ---- Bottom toolbar + neutral buttons ---- */
.toolbar { box-shadow: 0 -1px 3px rgba(0, 0, 0, 0.22), 0 -4px 14px rgba(0, 0, 0, 0.14); }
.btn-toolbar {
- background: rgba(255, 255, 255, 0.045);
- border-color: rgba(255, 255, 255, 0.09);
+ background: var(--control-bg);
+ border-color: var(--control-border);
color: var(--text-dim);
}
.btn-toolbar:hover {
- background: rgba(255, 255, 255, 0.08);
- border-color: rgba(255, 255, 255, 0.14);
+ background: var(--control-bg-hover);
+ border-color: var(--control-border-hover);
color: var(--text);
box-shadow: none;
}
@@ -11634,8 +11785,8 @@ html:not([data-skin="og"]) {
/* ---- Stop button: quiet neutral control (no rose glow) ---- */
.btn-toolbar.btn-stop {
- background: rgba(255, 255, 255, 0.045);
- border: 1px solid rgba(255, 255, 255, 0.12);
+ background: var(--control-bg);
+ border: 1px solid var(--control-border);
color: var(--text-dim);
text-shadow: none;
}
@@ -11653,11 +11804,11 @@ html:not([data-skin="og"]) {
/* ---- Case selector + add ---- */
.toolbar-select {
- background: rgba(255, 255, 255, 0.045);
- border-color: rgba(255, 255, 255, 0.09);
+ background: var(--control-bg);
+ border-color: var(--control-border);
color: var(--text-dim);
}
-.toolbar-select:hover { background: rgba(255, 255, 255, 0.07); border-color: rgba(255, 255, 255, 0.14); }
+.toolbar-select:hover { background: var(--control-bg-hover); border-color: var(--control-border-hover); }
.toolbar-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.25);
@@ -11671,8 +11822,8 @@ html:not([data-skin="og"]) {
/* ---- Monitor + Subagents panels: lifted frosted slate ---- */
.monitor-panel,
.subagents-panel {
- background: rgba(31, 38, 48, 0.95);
- border: 1px solid rgba(255, 255, 255, 0.08);
+ background: var(--floating-bg);
+ border: 1px solid var(--control-border);
}
/* ---- Subagent progress ring: emerald, calm glow ---- */
@@ -11702,7 +11853,7 @@ html:not([data-skin="og"]) {
}
.welcome-btn-opencode:hover { box-shadow: 0 0 28px -4px rgba(var(--accent-rgb), 0.25); }
.welcome-btn-tunnel {
- background: linear-gradient(135deg, rgba(46, 55, 67, 0.9), rgba(56, 66, 79, 0.9));
+ background: linear-gradient(135deg, var(--bg-input), var(--bg-hover));
border-color: var(--border-light);
color: var(--text);
}
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index f309a31b..a2397e65 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -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,
@@ -2846,7 +2859,9 @@ 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;
try {
this.terminal.refresh(0, this.terminal.rows - 1);
@@ -2855,6 +2870,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);
diff --git a/test/skin-themes.test.ts b/test/skin-themes.test.ts
new file mode 100644
index 00000000..8ab62339
--- /dev/null
+++ b/test/skin-themes.test.ts
@@ -0,0 +1,101 @@
+/**
+ * @fileoverview Static and VM regressions for Codeman UI/xterm skin parity.
+ */
+
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { describe, expect, it, vi } from 'vitest';
+
+const indexSource = readFileSync(resolve('src/web/public/index.html'), 'utf8');
+const stylesSource = readFileSync(resolve('src/web/public/styles.css'), 'utf8');
+const mobileStylesSource = readFileSync(resolve('src/web/public/mobile.css'), 'utf8');
+const terminalSource = readFileSync(resolve('src/web/public/terminal-ui.js'), 'utf8');
+
+const LIGHT_SKINS = ['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn'] as const;
+
+function hexRgb(hex: string): [number, number, number] {
+ const normalized = hex.replace('#', '');
+ if (!/^[0-9a-f]{6}$/i.test(normalized)) throw new Error(`Expected six-digit hex color, got ${hex}`);
+ return [0, 2, 4].map((offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16)) as [
+ number,
+ number,
+ number,
+ ];
+}
+
+function luminance(hex: string): number {
+ const channels = hexRgb(hex).map((channel) => {
+ const value = channel / 255;
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
+}
+
+function contrastRatio(first: string, second: string): number {
+ const [lighter, darker] = [luminance(first), luminance(second)].sort((a, b) => b - a);
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
+function loadTerminalThemes() {
+ const FakeCodemanApp = function () {} as unknown as { prototype: Record
unknown> };
+ const document = { documentElement: { dataset: { skin: 'paper-gray' } } };
+ const window: Record = {};
+ vm.runInNewContext(terminalSource, { CodemanApp: FakeCodemanApp, document, window }, { filename: 'terminal-ui.js' });
+ return {
+ mixin: FakeCodemanApp.prototype,
+ themes: window.CODEMAN_XTERM_THEMES as Record>,
+ isLight: window.codemanCurrentSkinIsLight as (skin?: string) => boolean,
+ };
+}
+
+describe('Codeman light skins', () => {
+ const terminal = loadTerminalThemes();
+
+ it('keeps the picker, pre-paint allowlist, CSS, and xterm palette in sync', () => {
+ for (const skin of LIGHT_SKINS) {
+ expect(indexSource).toContain(`value="${skin}"`);
+ expect(indexSource).toContain(`'${skin}'`);
+ expect(stylesSource).toContain(`html[data-skin="${skin}"]`);
+ expect(stylesSource).toMatch(new RegExp(`html\\[data-skin="${skin}"\\] \\{[\\s\\S]*?color-scheme: light;`));
+ expect(terminal.themes[skin]).toBeDefined();
+ expect(terminal.isLight(skin)).toBe(true);
+ }
+ expect(terminal.isLight('daylight-blue')).toBe(false);
+ });
+
+ it('provides readable dark-on-light terminal foregrounds', () => {
+ for (const skin of LIGHT_SKINS) {
+ const theme = terminal.themes[skin];
+ expect(contrastRatio(theme.background, theme.foreground), skin).toBeGreaterThanOrEqual(4.5);
+ }
+ });
+
+ it('switches live terminals between light and dark contrast policies', () => {
+ const main = { options: {} as Record, rows: 24, refresh: vi.fn() };
+ const teammate = { options: {} as Record, rows: 12, refresh: vi.fn() };
+ const app = {
+ terminal: main,
+ teammateTerminals: new Map([['agent-1', { terminal: teammate }]]),
+ };
+
+ terminal.mixin.applyTerminalSkin.call(app, 'paper-gray');
+ expect(main.options.minimumContrastRatio).toBe(4.5);
+ expect(teammate.options.minimumContrastRatio).toBe(4.5);
+ expect((main.options.theme as Record).background).toBe('#f6f8fa');
+
+ terminal.mixin.applyTerminalSkin.call(app, 'daylight-blue');
+ expect(main.options.minimumContrastRatio).toBe(1);
+ expect(teammate.options.minimumContrastRatio).toBe(1);
+ expect((main.options.theme as Record).background).toBe('#161b23');
+ });
+
+ it('uses skin variables for the pre-paint skeleton and native controls', () => {
+ expect(indexSource).toContain('background:var(--term-bg,#161b23)');
+ expect(indexSource).toContain('background:var(--glass-bg,rgba(31,38,48,0.85))');
+ expect(stylesSource).toContain('color-scheme: light;');
+ expect(stylesSource).toContain('background: var(--floating-bg);');
+ expect(mobileStylesSource).toContain(':is(.header, .toolbar, .keyboard-accessory-bar)');
+ expect(mobileStylesSource).toContain(':is(.case-settings-popover-mobile, .mobile-case-picker-sheet)');
+ });
+});
From a842b091bf869e11f2df70c2492efac23a20df76 Mon Sep 17 00:00:00 2001
From: codeman-local
Date: Tue, 21 Jul 2026 01:04:34 +0800
Subject: [PATCH 02/25] fix(ui): theme stateful light surfaces
---
CLAUDE.md | 4 +-
src/web/public/styles.css | 247 ++++++++++++++++++----------------
src/web/public/terminal-ui.js | 4 +
test/skin-themes.test.ts | 13 ++
4 files changed, 149 insertions(+), 119 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 9001c804..d0c757c3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -212,7 +212,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
-**Skins**: App chrome tokens live at the top of `public/styles.css`; the matching xterm ANSI palettes and light-skin contrast policy live in `public/terminal-ui.js`. Every new skin must also be added to the pre-paint allowlist and Settings picker in `public/index.html`. `test/skin-themes.test.ts` guards that four-way parity so reloads do not flash/fall back and teammate terminals match the main terminal.
+**Skins**: App chrome tokens live at the top of `public/styles.css`; the matching xterm ANSI palettes and light-skin contrast policy live in `public/terminal-ui.js`. Every new skin must also be added to the pre-paint allowlist and Settings picker in `public/index.html`. `test/skin-themes.test.ts` guards that four-way parity so reloads do not flash/fall back and teammate terminals match the main terminal. Stateful surfaces must use the same tokens too: the response viewer/code blocks, file/subagent previews, CJK/paste inputs, and the zero-lag input overlay. `applyTerminalSkin()` must call the overlay's `refreshFont()` because it caches the terminal foreground/background.
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over.
@@ -230,7 +230,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Gesture-control source lives in-repo** at `packages/gesture-control/` (workspace package `codeman-gesture-control`, was the standalone `Ark0N/codeman-gesture-control` repo). The transport-agnostic core is `src/gesture/*` (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); `src/codeman/entry.ts` is the Codeman _consumer_ that maps grab/drag/drop onto real `.session-tab`/toolbar buttons and is the bundle entry. **Edit there, then run `npm run build:gesture`** (`scripts/build-gesture-bundle.mjs` → esbuild bundles `entry.ts`, MediaPipe JS included, into `src/web/public/gesture/gesture-codeman.js`) and **commit the regenerated bundle** — the committed bundle is what dev/`tsx` serves (no bundler at runtime), and `scripts/build.mjs` reruns the same step so prod always reflects current source. The MediaPipe **wasm + model** are NOT bundled — loaded at runtime from same-origin `/gesture/wasm` + `/gesture/gesture_recognizer.task`, fetched by `scripts/fetch-gesture-assets.mjs` (gitignored, see Gotchas). `entry.ts` mounts `window.__codemanGesture = new GestureBridge()` idempotently at module-eval. A standalone vite playground (`npm run dev` in the package — fake tabs, no Codeman) lets you iterate on gesture _feel_ in isolation. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision` in `packages/gesture-control/package.json`.
-**Theme skins** (App Settings → Display): the `skin` setting selects a palette via a `data-skin` attribute on ``. Values: `daylight-blue` (default), `daylight-green`, `og` (OG Codeman). CSS lives under `[data-skin="…"]` blocks in `styles.css`. To avoid a flash-of-wrong-theme, an **inline pre-paint script** in `index.html` (``) reads `localStorage['codeman:skin']` and sets `data-skin` before first paint; `settings-ui.js` `applySkin()` applies it live on save (sets `html[data-skin]` + `window.__codemanSkin`, syncs the standalone `codeman:skin` key with the settings blob, and calls terminal-ui.js `applyTerminalSkin()` to re-theme live terminals). `skin` is a **per-device/client-only** setting — it's destructured OUT of the server payload (settings-ui.js, alongside `localEchoEnabled`/`cjkInputEnabled`/`extendedKeyboardBar`), so it does NOT sync across devices.
+**Theme skins** (App Settings → Display): the `skin` setting selects a palette via a `data-skin` attribute on ``. Values: `daylight-blue` (default), `daylight-green`, `og` (OG Codeman), plus the light palettes `paper-gray`, `solarized-light`, `catppuccin-latte`, and `rose-pine-dawn`. CSS lives under `[data-skin="…"]` blocks in `styles.css`. To avoid a flash-of-wrong-theme, an **inline pre-paint script** in `index.html` (``) reads `localStorage['codeman:skin']` and sets `data-skin` before first paint; `settings-ui.js` `applySkin()` applies it live on save (sets `html[data-skin]` + `window.__codemanSkin`, syncs the standalone `codeman:skin` key with the settings blob, and calls terminal-ui.js `applyTerminalSkin()` to re-theme live terminals and refresh the cached local-echo colors). `skin` is a **per-device/client-only** setting — it's destructured OUT of the server payload (settings-ui.js, alongside `localEchoEnabled`/`cjkInputEnabled`/`extendedKeyboardBar`), so it does NOT sync across devices.
**Custom branding + UI language** (App Settings → Display → Branding & Language): `displayName` is schema-validated (trimmed, 1–40 chars), server-synced, and changes user-facing browser branding/window titles only — NEVER rename npm package/CLI/API/storage/CSS/protocol identifiers. `language` is a per-device `en`/`zh-CN` display key, stripped from the server payload. `i18n.js` keeps English as the canonical source/fallback, observes newly inserted application DOM for dynamic copy, preserves source strings so live EN↔ZH switching is reversible, and skips terminal/response/file/session-name/user-content surfaces. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`.
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 2d0b475b..d65064bd 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -92,6 +92,22 @@
--run-hover-a: #5cc4f5;
--run-hover-b: #3aa3e2;
--gear-hover: #2a93d0;
+
+ /* Compatibility aliases used by newer panels and overlays. Keep these
+ references dynamic so every skin, including light skins, inherits the
+ same semantic surface and text colors. */
+ --bg-primary: var(--bg-dark);
+ --bg-secondary: var(--bg-card);
+ --bg-tertiary: var(--bg-input);
+ --text-primary: var(--text);
+ --text-secondary: var(--text-dim);
+ --border-color: var(--border);
+ --accent-color: var(--accent);
+ --success: var(--green);
+ --error: var(--red);
+ --danger: var(--red);
+ --font-mono: 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, monospace;
+ --shadow-lg: var(--elevated-shadow);
}
/* ===== Skin: Daylight Blue (current default) ===== */
@@ -7650,7 +7666,7 @@ kbd {
.subagent-id {
font-family: var(--font-mono);
font-size: 0.75rem;
- color: white;
+ color: var(--text);
}
.subagent-status {
@@ -7953,7 +7969,7 @@ kbd {
.subagent-window-title .id {
font-family: var(--font-mono);
font-size: 0.8rem;
- color: white;
+ color: var(--text);
}
.subagent-window-title .status {
@@ -8002,8 +8018,8 @@ kbd {
padding: 0.5rem;
font-family: var(--font-mono);
font-size: 0.75rem;
- background: #111;
- color: #c8c8c8;
+ background: var(--bg-dark);
+ color: var(--text);
min-height: 0; /* Allow flex child to shrink below content size */
scroll-behavior: smooth;
}
@@ -8599,7 +8615,7 @@ kbd {
flex: 1;
overflow: auto;
padding: 0;
- background: #111;
+ background: var(--bg-dark);
}
.file-preview-body pre {
@@ -8608,7 +8624,7 @@ kbd {
font-family: var(--font-mono);
font-size: 0.8rem;
line-height: 1.5;
- color: #d4d4d4;
+ color: var(--text);
white-space: pre-wrap;
word-break: break-all;
}
@@ -8695,7 +8711,7 @@ kbd {
.log-viewer-window-title .filename {
font-family: var(--font-mono);
font-size: 0.8rem;
- color: white;
+ color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -9692,7 +9708,7 @@ kbd {
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.75rem;
- color: var(--green);
+ color: var(--text);
}
/* Advanced Options */
@@ -10223,10 +10239,10 @@ kbd {
left: 0;
right: 0;
max-height: 88vh;
- background: #14141f;
- border-top: 1px solid #2a2a3a;
+ background: var(--floating-bg);
+ border-top: 1px solid var(--border);
border-radius: 14px 14px 0 0;
- box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.45);
+ box-shadow: var(--elevated-shadow);
z-index: 5000;
flex-direction: column;
transform: translateY(100%);
@@ -10243,11 +10259,11 @@ kbd {
align-items: center;
justify-content: space-between;
padding: 14px 20px;
- border-bottom: 1px solid #2a2a3a;
+ border-bottom: 1px solid var(--border);
flex-shrink: 0;
font-size: 14px;
font-weight: 600;
- color: #e8e8ec;
+ color: var(--text);
letter-spacing: 0.2px;
}
@@ -10258,9 +10274,9 @@ kbd {
}
.response-viewer-more {
- background: #2a2a4a;
- border: 1px solid #444;
- color: #aaa;
+ background: var(--control-bg);
+ border: 1px solid var(--control-border);
+ color: var(--text-dim);
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
@@ -10268,13 +10284,13 @@ kbd {
}
.response-viewer-more:active {
- background: #3a3a5a;
+ background: var(--control-bg-hover);
}
.response-viewer-close {
background: none;
border: none;
- color: #888;
+ color: var(--text-muted);
font-size: 22px;
cursor: pointer;
padding: 0 4px;
@@ -10286,9 +10302,9 @@ kbd {
margin: 0 0 18px;
padding: 14px 16px 16px;
border-radius: 10px;
- border: 1px solid #252538;
+ border: 1px solid var(--border);
border-left-width: 3px;
- background: #181826;
+ background: var(--bg-card);
position: relative;
}
@@ -10299,14 +10315,14 @@ kbd {
/* Distinct accent per role so threads are scannable at a glance */
.rv-message:has(.rv-role-user),
.rv-message.rv-msg-user {
- border-left-color: #7aa2ff;
- background: #16182a;
+ border-left-color: var(--accent);
+ background: rgba(var(--accent-rgb), 0.055);
}
.rv-message:has(.rv-role-assistant),
.rv-message.rv-msg-assistant {
- border-left-color: #6ddb7f;
- background: #161f1a;
+ border-left-color: var(--green);
+ background: color-mix(in srgb, var(--green) 6%, var(--bg-card));
}
.rv-role {
@@ -10318,17 +10334,17 @@ kbd {
margin-bottom: 10px;
padding: 2px 8px;
border-radius: 10px;
- background: rgba(255, 255, 255, 0.04);
+ background: var(--control-bg);
}
.rv-role-user {
- color: #7aa2ff;
- background: rgba(122, 162, 255, 0.12);
+ color: var(--accent);
+ background: rgba(var(--accent-rgb), 0.12);
}
.rv-role-assistant {
- color: #6ddb7f;
- background: rgba(109, 219, 127, 0.12);
+ color: var(--green);
+ background: color-mix(in srgb, var(--green) 12%, transparent);
}
/* Markdown rendered content inside response viewer.
@@ -10353,7 +10369,7 @@ kbd {
.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4,
.response-viewer-body > h1, .response-viewer-body > h2,
.response-viewer-body > h3, .response-viewer-body > h4 {
- color: #f2f2f6;
+ color: var(--text);
margin: 1.4em 0 0.5em;
line-height: 1.3;
font-weight: 700;
@@ -10368,27 +10384,27 @@ kbd {
.rv-text h1, .response-viewer-body > h1 {
font-size: 1.55em;
padding-bottom: 0.3em;
- border-bottom: 1px solid #2d2d40;
+ border-bottom: 1px solid var(--border);
}
.rv-text h2, .response-viewer-body > h2 {
font-size: 1.3em;
- color: #ffd27a;
+ color: var(--yellow);
}
.rv-text h3, .response-viewer-body > h3 {
font-size: 1.13em;
- color: #bfc8ff;
+ color: var(--accent);
}
.rv-text h4, .response-viewer-body > h4 {
font-size: 1em;
- color: #c9c9d5;
+ color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rv-text code,
.response-viewer-body > :not(pre) code {
- background: #262638;
- color: #ffb4a2;
+ background: var(--bg-input);
+ color: var(--red);
padding: 1px 6px;
border-radius: 4px;
font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, monospace;
@@ -10401,14 +10417,14 @@ kbd {
already matched via descendant; keep both in lockstep. */
.rv-text pre,
.response-viewer-body pre {
- background: #0f0f1a;
- border: 1px solid #2a2a3d;
+ background: var(--bg-dark);
+ border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
overflow-x: auto;
margin: 1em 0;
-webkit-overflow-scrolling: touch;
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
+ box-shadow: inset 0 0 0 1px var(--control-border);
position: relative;
}
@@ -10418,7 +10434,7 @@ kbd {
.rv-text pre code,
.response-viewer-body pre code {
background: none;
- color: #e6e6f0;
+ color: var(--text);
padding: 0;
font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, monospace;
font-size: 12.5px;
@@ -10468,10 +10484,7 @@ kbd {
.rv-text pre.rv-diagram.rv-nowrap,
.response-viewer-body pre.rv-diagram.rv-nowrap {
- background:
- linear-gradient(to left, #0f0f1a 0, rgba(15, 15, 26, 0) 28px) right / 28px 100% no-repeat,
- linear-gradient(to left, rgba(122, 162, 255, 0.18) 0, rgba(15, 15, 26, 0) 28px) right / 28px 100% no-repeat,
- #0f0f1a;
+ background: var(--bg-dark);
}
/* Toggle button — pinned to the wrapper's top-right, NOT affected by 's
@@ -10483,10 +10496,10 @@ kbd {
width: 28px;
height: 24px;
padding: 0;
- border: 1px solid #2f2f45;
+ border: 1px solid var(--control-border);
border-radius: 5px;
- background: rgba(20, 20, 32, 0.92);
- color: #8b8b97;
+ background: var(--floating-bg);
+ color: var(--text-muted);
font-size: 11px;
line-height: 1;
cursor: pointer;
@@ -10499,8 +10512,8 @@ kbd {
.rv-wrap-toggle:hover,
.rv-wrap-toggle:active {
- color: #e0e0ec;
- border-color: #4a4a65;
+ color: var(--text);
+ border-color: var(--control-border-hover);
}
/* Default icon = "return" (wrap is active). Clicking switches to expand/scroll. */
@@ -10557,10 +10570,10 @@ kbd {
width: 28px;
height: 24px;
padding: 0;
- border: 1px solid #2f2f45;
+ border: 1px solid var(--control-border);
border-radius: 5px;
- background: rgba(20, 20, 32, 0.92);
- color: #8b8b97;
+ background: var(--floating-bg);
+ color: var(--text-muted);
font-size: 13px;
line-height: 1;
cursor: pointer;
@@ -10572,14 +10585,14 @@ kbd {
.rv-copy-btn:hover,
.rv-copy-btn:active {
- color: #e0e0ec;
- border-color: #4a4a65;
+ color: var(--text);
+ border-color: var(--control-border-hover);
}
.rv-copy-btn::before { content: '\2398'; } /* ⎘ — matches file-preview copy */
-.rv-copy-btn.rv-copied { color: #9ece6a; border-color: #3a5a3a; }
+.rv-copy-btn.rv-copied { color: var(--green); border-color: var(--green); }
.rv-copy-btn.rv-copied::before { content: '\2713'; } /* ✓ */
-.rv-copy-btn.rv-copy-failed { color: #f7768e; border-color: #5a3a3a; }
+.rv-copy-btn.rv-copy-failed { color: var(--red); border-color: var(--red); }
.rv-copy-btn.rv-copy-failed::before { content: '\2715'; } /* ✕ */
.rv-text ul, .rv-text ol,
@@ -10597,36 +10610,36 @@ kbd {
.rv-text blockquote,
.response-viewer-body > blockquote {
- border-left: 3px solid #5c7cfa;
- background: rgba(92, 124, 250, 0.06);
+ border-left: 3px solid var(--accent);
+ background: rgba(var(--accent-rgb), 0.06);
margin: 0.8em 0;
padding: 0.5em 14px;
- color: #b8b8c8;
+ color: var(--text-dim);
border-radius: 0 6px 6px 0;
}
.rv-text strong,
.response-viewer-body > p strong,
.response-viewer-body > li strong {
- color: #ffffff;
+ color: var(--text);
font-weight: 700;
}
.rv-text em,
.response-viewer-body em {
- color: #e0e0ec;
+ color: var(--text);
}
.rv-text a,
.response-viewer-body a {
- color: #7aa2ff;
+ color: var(--accent);
text-decoration: none;
- border-bottom: 1px solid rgba(122, 162, 255, 0.35);
+ border-bottom: 1px solid rgba(var(--accent-rgb), 0.35);
}
.rv-text a:hover,
.response-viewer-body a:hover {
- border-bottom-color: #7aa2ff;
+ border-bottom-color: var(--accent);
}
/* Tables — scroll wrapper keeps table proper while allowing horizontal overflow */
@@ -10634,9 +10647,9 @@ kbd {
margin: 1em 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
- border: 1px solid #2a2a3d;
+ border: 1px solid var(--border);
border-radius: 8px;
- background: #12121d;
+ background: var(--bg-card);
}
.rv-text table,
@@ -10653,8 +10666,8 @@ kbd {
.response-viewer-body > table th,
.response-viewer-body > table td,
.rv-table-wrap th, .rv-table-wrap td {
- border-bottom: 1px solid #252538;
- border-right: 1px solid #252538;
+ border-bottom: 1px solid var(--border);
+ border-right: 1px solid var(--border);
padding: 8px 12px;
text-align: left;
vertical-align: top;
@@ -10677,29 +10690,29 @@ kbd {
.rv-text th,
.response-viewer-body > table th,
.rv-table-wrap th {
- background: #20202e;
- color: #f0f0f5;
+ background: var(--bg-input);
+ color: var(--text);
font-weight: 600;
- border-bottom: 2px solid #2f2f45;
+ border-bottom: 2px solid var(--border-light);
white-space: nowrap;
}
.rv-text tbody tr:nth-child(even) td,
.response-viewer-body > table tbody tr:nth-child(even) td,
.rv-table-wrap tbody tr:nth-child(even) td {
- background: rgba(255, 255, 255, 0.022);
+ background: var(--control-bg);
}
.rv-text tbody tr:hover td,
.response-viewer-body > table tbody tr:hover td,
.rv-table-wrap tbody tr:hover td {
- background: rgba(122, 162, 255, 0.06);
+ background: rgba(var(--accent-rgb), 0.06);
}
.rv-text hr,
.response-viewer-body > hr {
border: none;
- border-top: 1px solid #2d2d40;
+ border-top: 1px solid var(--border);
margin: 1.5em 0;
}
@@ -10715,7 +10728,7 @@ kbd {
'Noto Sans CJK SC', sans-serif;
font-size: 15px;
line-height: 1.7;
- color: #d8d8e0;
+ color: var(--text);
/* Comfortable reading width on wider viewports */
--rv-content-max: 720px;
}
@@ -10734,7 +10747,7 @@ kbd {
.response-viewer-body:empty::after {
content: 'No response yet';
- color: #555;
+ color: var(--text-muted);
font-style: italic;
}
@@ -10742,7 +10755,7 @@ kbd {
display: none;
position: fixed;
inset: 0;
- background: rgba(0, 0, 0, 0.5);
+ background: var(--modal-backdrop);
z-index: 4999;
}
@@ -10760,9 +10773,9 @@ kbd {
width: 100%;
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, monospace;
font-size: 14px;
- background: #1a1a2e;
- color: #e0e0e0;
- border: 1px solid #333;
+ background: var(--bg-input);
+ color: var(--text);
+ border: 1px solid var(--border);
border-top: none;
padding: 6px 10px;
outline: none;
@@ -10772,12 +10785,12 @@ kbd {
}
#cjkInput:focus {
- border-color: #339af0;
- background: #111;
+ border-color: var(--accent);
+ background: var(--bg-card);
}
#cjkInput::placeholder {
- color: #495057;
+ color: var(--text-muted);
font-size: 12px;
}
@@ -10791,12 +10804,12 @@ kbd {
min-height: 34px;
max-height: 68px;
padding: 6px 10px;
- border: 1px solid rgba(80, 120, 190, 0.55);
+ border: 1px solid var(--control-border-hover);
border-left: none;
border-right: none;
- background: #101827;
- color: #f3f4f6;
- box-shadow: 0 -8px 20px rgba(0, 0, 0, 0.35);
+ background: var(--floating-bg);
+ color: var(--text);
+ box-shadow: var(--subtle-shadow);
transition: transform 0.15s ease-out;
will-change: transform;
}
@@ -10856,10 +10869,10 @@ body.touch-device.cjk-input-visible .main {
flex-shrink: 0;
gap: 4px;
padding: 6px 12px;
- background: #2a2a2a;
- border: 1px solid rgba(255, 255, 255, 0.15);
+ background: var(--control-bg);
+ border: 1px solid var(--control-border);
border-radius: 6px;
- color: #e5e5e5;
+ color: var(--text-dim);
font-size: 0.65rem;
font-weight: 500;
cursor: pointer;
@@ -10867,13 +10880,13 @@ body.touch-device.cjk-input-visible .main {
}
.accessory-btn.confirming {
- background: #6b4f00;
- border-color: #b8860b;
- color: #ffd54f;
+ background: color-mix(in srgb, var(--yellow) 18%, var(--bg-input));
+ border-color: var(--yellow);
+ color: var(--yellow);
}
.accessory-btn:active {
- background: #3a3a3a;
+ background: var(--control-bg-hover);
}
.accessory-btn svg {
@@ -10883,13 +10896,13 @@ body.touch-device.cjk-input-visible .main {
.accessory-btn-arrow {
padding: 6px 10px;
- background: #2563eb;
- border-color: rgba(59, 130, 246, 0.5);
- color: #fff;
+ background: var(--accent);
+ border-color: var(--accent);
+ color: var(--accent-ink);
}
.accessory-btn-arrow:active {
- background: #1d4ed8;
+ background: var(--accent-hover);
}
.accessory-btn-dismiss {
@@ -10897,9 +10910,9 @@ body.touch-device.cjk-input-visible .main {
flex: 1 1 0;
max-width: 100px;
padding: 10px 8px;
- background: #2563eb;
- border-color: rgba(59, 130, 246, 0.5);
- color: #fff;
+ background: var(--accent);
+ border-color: var(--accent);
+ color: var(--accent-ink);
font-weight: 600;
}
@@ -10909,7 +10922,7 @@ body.touch-device.cjk-input-visible .main {
}
.accessory-btn-dismiss:active {
- background: #1d4ed8;
+ background: var(--accent-hover);
}
/* ═══════════════════════════════════════════════════════════════
@@ -10929,8 +10942,8 @@ body.touch-device.cjk-input-visible .main {
}
.paste-dialog {
- background: var(--bg-secondary, #1e1e2e);
- border: 1px solid var(--border-color, #444);
+ background: var(--bg-card);
+ border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
width: calc(100% - 24px);
@@ -10941,9 +10954,9 @@ body.touch-device.cjk-input-visible .main {
width: 100%;
min-height: 80px;
max-height: 200px;
- background: var(--bg-primary, #0d0d14);
- color: var(--text-primary, #e0e0e0);
- border: 1px solid var(--border-color, #444);
+ background: var(--bg-input);
+ color: var(--text);
+ border: 1px solid var(--border);
border-radius: 8px;
padding: 8px;
font-family: inherit;
@@ -10954,7 +10967,7 @@ body.touch-device.cjk-input-visible .main {
.paste-textarea:focus {
outline: none;
- border-color: var(--accent-color, #7aa2f7);
+ border-color: var(--accent);
}
.paste-actions {
@@ -10974,24 +10987,24 @@ body.touch-device.cjk-input-visible .main {
.paste-image {
margin-right: auto;
- background: var(--bg-tertiary, #333);
- color: var(--accent-color, #7aa2f7);
- border: 1px solid var(--accent-color, #7aa2f7);
+ background: var(--bg-input);
+ color: var(--accent);
+ border: 1px solid var(--accent);
}
.paste-cancel {
- background: var(--bg-tertiary, #333);
- color: var(--text-secondary, #aaa);
+ background: var(--bg-input);
+ color: var(--text-dim);
}
.paste-new {
- background: var(--bg-tertiary, #333);
- color: var(--accent-color, #7aa2f7);
- border: 1px solid var(--accent-color, #7aa2f7);
+ background: var(--bg-input);
+ color: var(--accent);
+ border: 1px solid var(--accent);
}
.paste-send {
- background: var(--accent-color, #7aa2f7);
+ background: var(--accent);
color: #fff;
font-weight: 600;
}
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index a2397e65..c999e04c 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -2863,6 +2863,10 @@ Object.assign(CodemanApp.prototype, {
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 {}
diff --git a/test/skin-themes.test.ts b/test/skin-themes.test.ts
index 8ab62339..59e9d28f 100644
--- a/test/skin-themes.test.ts
+++ b/test/skin-themes.test.ts
@@ -74,20 +74,33 @@ describe('Codeman light skins', () => {
it('switches live terminals between light and dark contrast policies', () => {
const main = { options: {} as Record, rows: 24, refresh: vi.fn() };
const teammate = { options: {} as Record, rows: 12, refresh: vi.fn() };
+ const refreshFont = vi.fn();
const app = {
terminal: main,
teammateTerminals: new Map([['agent-1', { terminal: teammate }]]),
+ _localEchoOverlay: { refreshFont },
};
terminal.mixin.applyTerminalSkin.call(app, 'paper-gray');
expect(main.options.minimumContrastRatio).toBe(4.5);
expect(teammate.options.minimumContrastRatio).toBe(4.5);
expect((main.options.theme as Record).background).toBe('#f6f8fa');
+ expect(refreshFont).toHaveBeenCalledTimes(1);
terminal.mixin.applyTerminalSkin.call(app, 'daylight-blue');
expect(main.options.minimumContrastRatio).toBe(1);
expect(teammate.options.minimumContrastRatio).toBe(1);
expect((main.options.theme as Record).background).toBe('#161b23');
+ expect(refreshFont).toHaveBeenCalledTimes(2);
+ });
+
+ it('themes stateful input and response surfaces instead of pinning dark colors', () => {
+ expect(stylesSource).toContain('background: var(--bg-input);\n color: var(--text);');
+ expect(stylesSource).toMatch(/#cjkInput \{[\s\S]*?background: var\(--bg-input\);[\s\S]*?color: var\(--text\);/);
+ expect(stylesSource).toMatch(/\.response-viewer \{[\s\S]*?background: var\(--floating-bg\);/);
+ expect(stylesSource).toMatch(/\.response-viewer-body pre \{[\s\S]*?background: var\(--bg-dark\);/);
+ expect(stylesSource).toMatch(/\.response-viewer-body pre code \{[\s\S]*?color: var\(--text\);/);
+ expect(stylesSource).toMatch(/\.file-preview-body \{[\s\S]*?background: var\(--bg-dark\);/);
});
it('uses skin variables for the pre-paint skeleton and native controls', () => {
From 2667150f33daa2f87cece0dd0020729cbd4465cc Mon Sep 17 00:00:00 2001
From: codeman-local
Date: Mon, 20 Jul 2026 19:11:31 +0800
Subject: [PATCH 03/25] feat(mobile): add filesystem path picker
---
.changeset/mobile-filesystem-path-picker.md | 9 +
CLAUDE.md | 4 +-
src/types/common.ts | 26 ++
src/web/public/index.html | 7 +-
src/web/public/keyboard-accessory.js | 252 +++++++++++++++++-
src/web/public/session-ui.js | 19 ++
src/web/public/styles.css | 277 ++++++++++++++++++++
src/web/public/terminal-ui.js | 44 ++++
src/web/routes/file-routes.ts | 218 ++++++++++++++-
src/web/schemas.ts | 23 ++
test/path-picker-ui.test.ts | 180 +++++++++++++
test/routes/file-routes.test.ts | 91 ++++++-
12 files changed, 1141 insertions(+), 9 deletions(-)
create mode 100644 .changeset/mobile-filesystem-path-picker.md
create mode 100644 test/path-picker-ui.test.ts
diff --git a/.changeset/mobile-filesystem-path-picker.md b/.changeset/mobile-filesystem-path-picker.md
new file mode 100644
index 00000000..80378df6
--- /dev/null
+++ b/.changeset/mobile-filesystem-path-picker.md
@@ -0,0 +1,9 @@
+---
+"aicodeman": minor
+---
+
+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, and a new
+one-tap action clears only the current unsent input without invoking `/clear`.
diff --git a/CLAUDE.md b/CLAUDE.md
index 56ad327c..8c0e50e7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -261,7 +261,9 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### API Routes
-~190 handlers across 20 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (32, incl. `GET /api/sessions/unified`, `POST /api/sessions/:id/pin`, `PUT /api/session-order`), orchestrator (10), cases (27, incl. remote hosts CRUD + remote case-link, docker hosts CRUD + `docker-link` + `docker-quickcreate` + export/import + `docker-exports`), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), admin (8, multi-user `/api/admin/users*` incl. per-user case folders), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), me (2, `/api/me` + password), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
+~191 handlers across 20 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (32, incl. `GET /api/sessions/unified`, `POST /api/sessions/:id/pin`, `PUT /api/session-order`), orchestrator (10), cases (27, incl. remote hosts CRUD + remote case-link, docker hosts CRUD + `docker-link` + `docker-quickcreate` + export/import + `docker-exports`), ralph (9), plan (8), files (15, incl. root-confined `GET /api/filesystem/browse`, attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), admin (8, multi-user `/api/admin/users*` incl. per-user case folders), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), me (2, `/api/me` + password), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
+
+**Filesystem path picker**: Link Existing exposes a Browse button, and the extended mobile keyboard exposes `📁 Path` (insert the chosen file/folder path without Enter) plus `⌫ All` (clear only the current unsent prompt, never the agent's `/clear` command). The picker lazily lists one directory through `GET /api/filesystem/browse`, starts at the active session working directory or `/mnt/d`, hides dot entries, blocks sensitive trees and symlink escapes, and only traverses Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured with `CODEMAN_FILE_PICKER_ROOTS`.
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
diff --git a/src/types/common.ts b/src/types/common.ts
index 67f9081f..3aadb251 100644
--- a/src/types/common.ts
+++ b/src/types/common.ts
@@ -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,31 @@ export interface ProcessStats {
updatedAt: number;
}
+/** A selectable entry returned by the filesystem path-picker API. */
+export interface FilesystemBrowseEntry {
+ name: string;
+ path: string;
+ type: 'file' | 'directory';
+ size?: number;
+ symlink?: boolean;
+}
+
+/** 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';
/**
diff --git a/src/web/public/index.html b/src/web/public/index.html
index b51c96d1..ffd1a846 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -1962,8 +1962,11 @@
diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js
index 467a92ae..4cde64d8 100644
--- a/src/web/public/keyboard-accessory.js
+++ b/src/web/public/keyboard-accessory.js
@@ -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,227 @@
// 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,
+
+ /**
+ * 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 = `
+
+
+
+ Location
+
+
+
+
Loading...
+
+
+ Selected
+ None
+
+
+ Select Current Folder
+
+ Cancel
+ Select
+
+
`;
+
+ 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();
+ 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) 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 (!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');
+ });
+ },
+
+ 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.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 +316,8 @@ const KeyboardAccessoryBar = {
+ 📁 Path
+ ⌫ All
Tab
⇧Tab
Max
@@ -128,7 +354,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 +433,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 +497,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).
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js
index a3e01b71..44d57bee 100644
--- a/src/web/public/session-ui.js
+++ b/src/web/public/session-ui.js
@@ -1863,6 +1863,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();
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index a29a8606..e29149b8 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -10845,6 +10845,283 @@ body.touch-device.cjk-input-visible .main {
font-weight: 600;
}
+/* Shared lazy filesystem path picker (case linking + mobile input). */
+.path-input-group {
+ display: flex;
+ gap: 8px;
+ width: 100%;
+}
+
+.path-input-group input {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.path-input-browse {
+ flex: 0 0 auto;
+ min-height: 38px;
+}
+
+.path-picker-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 10020;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ background: rgba(0, 0, 0, 0.72);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+}
+
+.path-picker-dialog {
+ display: flex;
+ flex-direction: column;
+ width: min(680px, 100%);
+ height: min(720px, calc(100dvh - 32px));
+ overflow: hidden;
+ color: var(--text);
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
+}
+
+.path-picker-header,
+.path-picker-nav,
+.path-picker-roots-row,
+.path-picker-selection,
+.path-picker-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.path-picker-header {
+ justify-content: space-between;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border);
+}
+
+.path-picker-title {
+ font-size: 0.95rem;
+}
+
+.path-picker-close {
+ width: 36px;
+ height: 36px;
+ color: var(--text-muted);
+ font-size: 1.5rem;
+ background: transparent;
+ border: 0;
+ border-radius: 8px;
+ cursor: pointer;
+}
+
+.path-picker-roots-row {
+ padding: 10px 12px 0;
+ color: var(--text-muted);
+ font-size: 0.75rem;
+}
+
+.path-picker-roots {
+ flex: 1;
+ min-width: 0;
+ padding: 8px 10px;
+ color: var(--text);
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
+.path-picker-nav {
+ padding: 10px 12px;
+}
+
+.path-picker-up,
+.path-picker-refresh {
+ flex: 0 0 38px;
+ height: 38px;
+ color: var(--text);
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ cursor: pointer;
+}
+
+.path-picker-up:disabled {
+ opacity: 0.35;
+ cursor: default;
+}
+
+.path-picker-current {
+ flex: 1;
+ min-width: 0;
+ padding: 9px 11px;
+ overflow-x: auto;
+ color: var(--accent);
+ font-family: var(--font-mono, monospace);
+ font-size: 0.75rem;
+ white-space: nowrap;
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
+.path-picker-status {
+ padding: 0 14px 8px;
+ color: var(--text-dim);
+ font-size: 0.7rem;
+}
+
+.path-picker-status.error {
+ color: var(--danger, #ef4444);
+}
+
+.path-picker-list {
+ flex: 1;
+ min-height: 140px;
+ overflow-y: auto;
+ padding: 0 10px 10px;
+}
+
+.path-picker-item {
+ display: flex;
+ align-items: stretch;
+ margin: 2px 0;
+ border: 1px solid transparent;
+ border-radius: 9px;
+}
+
+.path-picker-item:hover,
+.path-picker-item.selected {
+ background: var(--bg-hover);
+ border-color: var(--border);
+}
+
+.path-picker-item.selected {
+ border-color: var(--accent);
+}
+
+.path-picker-item.not-selectable {
+ opacity: 0.62;
+}
+
+.path-picker-item-main {
+ display: flex;
+ flex: 1;
+ align-items: center;
+ min-width: 0;
+ padding: 10px 8px;
+ color: var(--text);
+ text-align: left;
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+}
+
+.path-picker-item-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ font-size: 0.8rem;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.path-picker-item-icon {
+ margin-right: 8px;
+}
+
+.path-picker-item-link,
+.path-picker-item-chevron {
+ margin-left: 8px;
+ color: var(--text-dim);
+}
+
+.path-picker-item-select {
+ align-self: center;
+ margin-right: 6px;
+ padding: 6px 9px;
+ color: var(--accent);
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ cursor: pointer;
+}
+
+.path-picker-selection {
+ padding: 9px 14px;
+ color: var(--text-muted);
+ font-size: 0.72rem;
+ border-top: 1px solid var(--border);
+}
+
+.path-picker-selection-value {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--text);
+ font-family: var(--font-mono, monospace);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.path-picker-actions {
+ padding: 10px 12px 12px;
+}
+
+.path-picker-actions button {
+ min-height: 40px;
+ padding: 8px 13px;
+ color: var(--text);
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ cursor: pointer;
+}
+
+.path-picker-action-spacer {
+ flex: 1;
+}
+
+.path-picker-actions .path-picker-confirm {
+ color: #fff;
+ background: var(--accent);
+ border-color: var(--accent);
+ font-weight: 600;
+}
+
+.path-picker-actions .path-picker-confirm:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+
+@media (max-width: 600px) {
+ .path-picker-overlay {
+ align-items: flex-end;
+ padding: 0;
+ }
+
+ .path-picker-dialog {
+ width: 100%;
+ height: min(82dvh, 720px);
+ border-right: 0;
+ border-bottom: 0;
+ border-left: 0;
+ border-radius: 16px 16px 0 0;
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+ }
+
+ .path-picker-actions {
+ flex-wrap: wrap;
+ }
+
+ .path-picker-current-select {
+ flex: 1 1 100%;
+ }
+}
+
/* ═══════════════════════════════════════════════════════════════
Orchestrator Panel
═══════════════════════════════════════════════════════════════ */
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index f309a31b..b5faac7f 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -2418,6 +2418,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.
diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts
index 8d34e096..07b88665 100644
--- a/src/web/routes/file-routes.ts
+++ b/src/web/routes/file-routes.ts
@@ -4,9 +4,11 @@
*/
import { FastifyInstance, type FastifyReply } from 'fastify';
-import { basename as pathBasename, join } from 'node:path';
+import { basename as pathBasename, 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 } from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { fileStreamManager } from '../../file-stream-manager.js';
import {
@@ -22,12 +24,20 @@ 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 {
+ 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 } from '../schemas.js';
const MIME_TYPES: Record = {
png: 'image/png',
@@ -260,6 +270,75 @@ type AttachmentHistoryRouteItem = Omit. 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 configuredFilesystemPickerRoots(): Array<{ label: string; path: string }> {
+ const candidates: Array<{ label: string; path: string }> = [
+ { label: 'Home', path: homedir() },
+ { label: 'Codeman Cases', path: CASES_DIR },
+ { label: 'WSL D:', path: '/mnt/d' },
+ ];
+ const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS;
+ if (extraRoots) {
+ for (const [index, path] of extraRoots
+ .split(',')
+ .map((value) => value.trim())
+ .filter(Boolean)
+ .entries()) {
+ candidates.push({ label: `Configured ${index + 1}`, path });
+ }
+ }
+ return candidates;
+}
+
+async function resolveFilesystemPickerRoots(
+ ctx: SessionPort & ConfigPort,
+ sessionId?: string
+): Promise {
+ const candidates = configuredFilesystemPickerRoots();
+ if (sessionId) {
+ const session = ctx.sessions.get(sessionId) ?? ctx.store.getSession(sessionId);
+ if (!session) {
+ 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();
+ 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;
+}
+
function appendDownloadFlag(url: string): string {
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
}
@@ -375,6 +454,141 @@ 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> => {
+ const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
+ const roots = await resolveFilesystemPickerRoots(ctx, sessionId);
+ if (roots.length === 0) {
+ reply.code(403);
+ return createErrorResponse(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 {
+ reply.code(404);
+ return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
+ }
+
+ const matchingRoots = roots
+ .filter((root) => isPathWithinRoot(root.path, resolvedPath))
+ .sort((a, b) => b.path.length - a.path.length);
+ const matchingRoot = matchingRoots[0];
+ if (!matchingRoot) {
+ reply.code(403);
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
+ }
+
+ const guard = await loadAttachmentGuardConfig();
+ if (isBlockedPickerPath(resolvedPath, guard.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 = roots.some((root) => isPathWithinRoot(root.path, targetPath));
+ if (!targetRoot) 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, guard.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 });
+ }
+
+ 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,
+ },
+ };
+ });
+
// File tree listing
app.get('/api/sessions/:id/files', async (req) => {
const { id } = req.params as { id: string };
diff --git a/src/web/schemas.ts b/src/web/schemas.ts
index cbbcf6d2..af704547 100644
--- a/src/web/schemas.ts
+++ b/src/web/schemas.ts
@@ -49,6 +49,29 @@ 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(),
+});
+
// ========== Env Var Allowlist ==========
/** Allowlisted env var key prefixes */
diff --git a/test/path-picker-ui.test.ts b/test/path-picker-ui.test.ts
new file mode 100644
index 00000000..93e3fee7
--- /dev/null
+++ b/test/path-picker-ui.test.ts
@@ -0,0 +1,180 @@
+/**
+ * @fileoverview Fast VM/static regressions for the shared filesystem picker and
+ * extended mobile keyboard actions. No browser or real server required.
+ */
+
+import { readFileSync } from 'node:fs';
+import { performance } from 'node:perf_hooks';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { describe, expect, it, vi } from 'vitest';
+
+const keyboardSource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
+const terminalSource = readFileSync(resolve('src/web/public/terminal-ui.js'), 'utf8');
+const sessionSource = readFileSync(resolve('src/web/public/session-ui.js'), 'utf8');
+const indexSource = readFileSync(resolve('src/web/public/index.html'), 'utf8');
+
+function loadTerminalMixin() {
+ const FakeCodemanApp = function () {} as unknown as { prototype: Record unknown> };
+ const cjkClear = vi.fn();
+ const context = vm.createContext({
+ console,
+ performance,
+ setTimeout,
+ clearTimeout,
+ setInterval: vi.fn(),
+ clearInterval: vi.fn(),
+ requestAnimationFrame: vi.fn(),
+ CodemanApp: FakeCodemanApp,
+ CjkInput: { clear: cjkClear },
+ window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
+ document: { addEventListener: vi.fn() },
+ });
+ vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' });
+ return { mixin: FakeCodemanApp.prototype, cjkClear };
+}
+
+const terminalHarness = loadTerminalMixin();
+
+function loadKeyboardModule() {
+ const app = {
+ activeSessionId: 'session-1',
+ sessions: new Map([['session-1', { workingDir: '/mnt/d/AI' }]]),
+ terminal: { focus: vi.fn() },
+ clearTerminalInput: vi.fn(),
+ insertTerminalText: vi.fn(),
+ sendInput: vi.fn(),
+ };
+ const context = vm.createContext({
+ app,
+ MobileDetection: { isTouchDevice: () => false },
+ URLSearchParams,
+ fetch: vi.fn(),
+ document: {},
+ setTimeout: (fn: () => void) => {
+ fn();
+ return 1;
+ },
+ clearTimeout: vi.fn(),
+ });
+ vm.runInContext(
+ `${keyboardSource}\nglobalThis.__bar = KeyboardAccessoryBar; globalThis.__picker = PathPicker;`,
+ context
+ );
+ return {
+ app,
+ bar: (context as unknown as { __bar: { handleAction(action: string): void } }).__bar,
+ picker: (context as unknown as { __picker: { open: ReturnType } }).__picker,
+ };
+}
+
+describe('mobile filesystem picker actions', () => {
+ it('keeps clear-input separate from the destructive /clear command', () => {
+ const { app, bar } = loadKeyboardModule();
+ bar.handleAction('clear-input');
+
+ expect(app.clearTerminalInput).toHaveBeenCalledOnce();
+ expect(app.sendInput).not.toHaveBeenCalled();
+ expect(keyboardSource).toContain('data-action="clear-input"');
+ expect(keyboardSource).toContain('data-action="clear" title="/clear"');
+ });
+
+ it('opens at the active working directory and inserts the selected path without Enter', () => {
+ const { app, bar, picker } = loadKeyboardModule();
+ picker.open = vi.fn();
+
+ bar.handleAction('pick-path');
+
+ expect(picker.open).toHaveBeenCalledOnce();
+ const options = picker.open.mock.calls[0][0];
+ expect(options).toMatchObject({
+ sessionId: 'session-1',
+ initialPath: '/mnt/d/AI',
+ directoriesOnly: false,
+ });
+ options.onSelect('/mnt/d/AI/project/file.ts');
+ expect(app.insertTerminalText).toHaveBeenCalledWith('/mnt/d/AI/project/file.ts');
+ expect(app.sendInput).not.toHaveBeenCalled();
+ });
+
+ it('wires Link Existing to the shared folder-only picker', () => {
+ expect(indexSource).toContain('onclick="app.openLinkCasePathPicker()"');
+ expect(indexSource).toContain('id="linkCasePath"');
+ expect(sessionSource).toContain('openLinkCasePathPicker()');
+ expect(sessionSource).toContain('directoriesOnly: true');
+ });
+
+ it('inserts a selected path into the editable local-echo prompt without sending it', () => {
+ const appendText = vi.fn();
+ const sendInput = vi.fn();
+ const focus = vi.fn();
+ const app = {
+ activeSessionId: 'session-1',
+ _localEchoEnabled: true,
+ _localEchoOverlay: { appendText },
+ terminal: { focus },
+ sendInput,
+ };
+
+ terminalHarness.mixin.insertTerminalText.call(app, '/mnt/d/AI/project');
+
+ expect(appendText).toHaveBeenCalledWith('/mnt/d/AI/project');
+ expect(sendInput).not.toHaveBeenCalled();
+ expect(focus).toHaveBeenCalledOnce();
+ });
+
+ it('clears pending and already-flushed prompt text without invoking /clear', () => {
+ const clear = vi.fn();
+ const suppressBufferDetection = vi.fn();
+ const sendInput = vi.fn(() => Promise.resolve());
+ const showToast = vi.fn();
+ const focus = vi.fn();
+ const app = {
+ activeSessionId: 'session-1',
+ _inputFlushTimeout: null,
+ _pendingInput: 'pending text',
+ _localEchoEnabled: true,
+ _localEchoOverlay: {
+ getFlushed: () => ({ count: 4, text: 'sent' }),
+ clear,
+ suppressBufferDetection,
+ },
+ _flushedOffsets: new Map([['session-1', 4]]),
+ _flushedTexts: new Map([['session-1', 'sent']]),
+ sendInput,
+ showToast,
+ terminal: { focus },
+ };
+
+ terminalHarness.mixin.clearTerminalInput.call(app);
+
+ expect(app._pendingInput).toBe('');
+ expect(clear).toHaveBeenCalledOnce();
+ expect(suppressBufferDetection).toHaveBeenCalledOnce();
+ expect(sendInput).toHaveBeenCalledWith('\x7f'.repeat(4));
+ expect(sendInput).not.toHaveBeenCalledWith('/clear');
+ expect(app._flushedOffsets.size).toBe(0);
+ expect(app._flushedTexts.size).toBe(0);
+ expect(showToast).toHaveBeenCalledWith('Input cleared', 'success');
+ expect(focus).toHaveBeenCalledOnce();
+ expect(terminalHarness.cjkClear).toHaveBeenCalled();
+ });
+
+ it('uses Ctrl+U to clear the TUI-owned prompt when local echo is disabled', () => {
+ const sendInput = vi.fn(() => Promise.resolve());
+ const app = {
+ activeSessionId: 'session-1',
+ _inputFlushTimeout: null,
+ _pendingInput: '',
+ _localEchoEnabled: false,
+ _localEchoOverlay: null,
+ sendInput,
+ showToast: vi.fn(),
+ terminal: { focus: vi.fn() },
+ };
+
+ terminalHarness.mixin.clearTerminalInput.call(app);
+
+ expect(sendInput).toHaveBeenCalledWith('\x15');
+ });
+});
diff --git a/test/routes/file-routes.test.ts b/test/routes/file-routes.test.ts
index bec8ab04..fb725145 100644
--- a/test/routes/file-routes.test.ts
+++ b/test/routes/file-routes.test.ts
@@ -8,13 +8,14 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
+import { ApiErrorCode } from '../../src/types.js';
// Mock fs/promises for file operations
vi.mock('node:fs/promises', () => ({
default: {
readdir: vi.fn(async () => []),
readFile: vi.fn(async () => 'file content'),
- stat: vi.fn(async () => ({ size: 100, isFile: () => true })),
+ stat: vi.fn(async () => ({ size: 100, isFile: () => true, isDirectory: () => true })),
},
}));
@@ -55,13 +56,99 @@ describe('file-routes', () => {
// Default: realpathSync returns the path unchanged
mockedRealpathSync.mockImplementation((p: string) => p as never);
// Default stat
- mockedStat.mockResolvedValue({ size: 100, isFile: () => true } as never);
+ mockedStat.mockResolvedValue({ size: 100, isFile: () => true, isDirectory: () => true } as never);
});
afterEach(async () => {
await harness.app.close();
});
+ // ========== GET /api/filesystem/browse ==========
+
+ describe('GET /api/filesystem/browse', () => {
+ it('lists the active session folder lazily with directories first', async () => {
+ mockedReaddir.mockResolvedValueOnce([
+ {
+ name: 'notes.txt',
+ isDirectory: () => false,
+ isFile: () => true,
+ isSymbolicLink: () => false,
+ },
+ {
+ name: 'src',
+ isDirectory: () => true,
+ isFile: () => false,
+ isSymbolicLink: () => false,
+ },
+ ] as never);
+
+ const path = harness.ctx._session.workingDir;
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.success).toBe(true);
+ expect(body.data.path).toBe(path);
+ expect(body.data.roots[0]).toEqual({ label: 'Current Folder', path });
+ expect(body.data.entries.map((entry: { name: string; type: string }) => [entry.name, entry.type])).toEqual([
+ ['src', 'directory'],
+ ['notes.txt', 'file'],
+ ]);
+ });
+
+ it('rejects paths outside the configured roots', async () => {
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: `/api/filesystem/browse?path=${encodeURIComponent('/tmp/not-an-allowed-root')}`,
+ });
+
+ expect(res.statusCode).toBe(403);
+ expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
+ });
+
+ it('does not expose hidden entries or symlinks that escape the allowed roots', async () => {
+ const root = harness.ctx._session.workingDir;
+ mockedReaddir.mockResolvedValueOnce([
+ {
+ name: '.secret',
+ isDirectory: () => false,
+ isFile: () => true,
+ isSymbolicLink: () => false,
+ },
+ {
+ name: 'outside-link',
+ isDirectory: () => false,
+ isFile: () => false,
+ isSymbolicLink: () => true,
+ },
+ ] as never);
+ mockedRealpathSync.mockImplementation((path: string) =>
+ path === `${root}/outside-link` ? ('/etc/shadow' as never) : (path as never)
+ );
+
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(JSON.parse(res.body).data.entries).toEqual([]);
+ });
+
+ it('returns 404 for an unknown session scope', async () => {
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: '/api/filesystem/browse?sessionId=missing-session',
+ });
+
+ expect(res.statusCode).toBe(404);
+ expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.NOT_FOUND });
+ });
+ });
+
// ========== GET /api/sessions/:id/files ==========
describe('GET /api/sessions/:id/files', () => {
From f812f65a33300647feb59f8efb33d66dda25bc11 Mon Sep 17 00:00:00 2001
From: codeman-local
Date: Mon, 20 Jul 2026 19:50:20 +0800
Subject: [PATCH 04/25] feat(files): preview picker documents and images
---
.changeset/mobile-filesystem-path-picker.md | 5 +-
CLAUDE.md | 2 +
src/types/common.ts | 3 +
src/web/public/keyboard-accessory.js | 115 ++++++++++-
src/web/public/styles.css | 157 ++++++++++++++-
src/web/routes/file-routes.ts | 204 ++++++++++++++++----
src/web/schemas.ts | 10 +
test/path-picker-ui.test.ts | 9 +
test/routes/file-routes.test.ts | 96 ++++++++-
9 files changed, 552 insertions(+), 49 deletions(-)
diff --git a/.changeset/mobile-filesystem-path-picker.md b/.changeset/mobile-filesystem-path-picker.md
index 80378df6..9427c9ad 100644
--- a/.changeset/mobile-filesystem-path-picker.md
+++ b/.changeset/mobile-filesystem-path-picker.md
@@ -5,5 +5,6 @@
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, and a new
-one-tap action clears only the current unsent input without invoking `/clear`.
+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`.
diff --git a/CLAUDE.md b/CLAUDE.md
index 8c0e50e7..71d68557 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -262,8 +262,10 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### API Routes
~191 handlers across 20 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (32, incl. `GET /api/sessions/unified`, `POST /api/sessions/:id/pin`, `PUT /api/session-order`), orchestrator (10), cases (27, incl. remote hosts CRUD + remote case-link, docker hosts CRUD + `docker-link` + `docker-quickcreate` + export/import + `docker-exports`), ralph (9), plan (8), files (15, incl. root-confined `GET /api/filesystem/browse`, attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), admin (8, multi-user `/api/admin/users*` incl. per-user case folders), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), me (2, `/api/me` + password), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
+~192 handlers across 20 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (32, incl. `GET /api/sessions/unified`, `POST /api/sessions/:id/pin`, `PUT /api/session-order`), orchestrator (10), cases (27, incl. remote hosts CRUD + remote case-link, docker hosts CRUD + `docker-link` + `docker-quickcreate` + export/import + `docker-exports`), ralph (9), plan (8), files (16, incl. root-confined `GET /api/filesystem/browse` and `GET /api/filesystem/preview`, attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), admin (8, multi-user `/api/admin/users*` incl. per-user case folders), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), me (2, `/api/me` + password), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
**Filesystem path picker**: Link Existing exposes a Browse button, and the extended mobile keyboard exposes `📁 Path` (insert the chosen file/folder path without Enter) plus `⌫ All` (clear only the current unsent prompt, never the agent's `/clear` command). The picker lazily lists one directory through `GET /api/filesystem/browse`, starts at the active session working directory or `/mnt/d`, hides dot entries, blocks sensitive trees and symlink escapes, and only traverses Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured with `CODEMAN_FILE_PICKER_ROOTS`.
+For supported files, tapping the file opens the responsive preview layer while `Choose` remains the separate path-selection action: `GET /api/filesystem/preview` serves images/PDF inline, converts DOCX/PPTX through the shared conversion cache/limiter, and returns Markdown/TXT/JSON as inert `text/plain`; text is capped at 2MB and binary/document previews at 50MB.
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
diff --git a/src/types/common.ts b/src/types/common.ts
index 3aadb251..5f55f074 100644
--- a/src/types/common.ts
+++ b/src/types/common.ts
@@ -70,12 +70,15 @@ export interface ProcessStats {
}
/** 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. */
diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js
index 4cde64d8..68e08fd2 100644
--- a/src/web/public/keyboard-accessory.js
+++ b/src/web/public/keyboard-accessory.js
@@ -40,6 +40,9 @@ const PathPicker = {
_previousFocus: null,
_keydownHandler: null,
_loadSequence: 0,
+ _previewOverlay: null,
+ _previewRequestSequence: 0,
+ _previewPreviousFocus: null,
/**
* Open the lazy filesystem browser.
@@ -108,7 +111,8 @@ const PathPicker = {
this._keydownHandler = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
- this.close(true);
+ if (this._previewOverlay) this.closePreview(true);
+ else this.close(true);
}
};
document.addEventListener('keydown', this._keydownHandler);
@@ -170,7 +174,9 @@ const PathPicker = {
for (const entry of data.entries) {
const row = document.createElement('div');
row.className = 'path-picker-item';
- if (entry.type === 'file' && this._options.directoriesOnly) row.classList.add('not-selectable');
+ 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');
@@ -197,6 +203,14 @@ const PathPicker = {
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 {
@@ -228,6 +242,102 @@ const PathPicker = {
});
},
+ 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 = `
+ `;
+ 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;
@@ -240,6 +350,7 @@ const PathPicker = {
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;
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index e29149b8..843b4c79 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -11035,11 +11035,16 @@ body.touch-device.cjk-input-visible .main {
}
.path-picker-item-link,
-.path-picker-item-chevron {
+.path-picker-item-chevron,
+.path-picker-item-preview {
margin-left: 8px;
color: var(--text-dim);
}
+.path-picker-item-preview {
+ font-size: 0.9rem;
+}
+
.path-picker-item-select {
align-self: center;
margin-right: 6px;
@@ -11097,6 +11102,139 @@ body.touch-device.cjk-input-visible .main {
cursor: default;
}
+.path-preview-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 10030;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 18px;
+ background: rgba(0, 0, 0, 0.82);
+ backdrop-filter: blur(5px);
+ -webkit-backdrop-filter: blur(5px);
+}
+
+.path-preview-dialog {
+ display: flex;
+ flex-direction: column;
+ width: min(1000px, 100%);
+ height: min(860px, calc(100dvh - 36px));
+ overflow: hidden;
+ color: var(--text);
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ box-shadow: 0 24px 72px rgba(0, 0, 0, 0.62);
+}
+
+.path-preview-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 11px 13px;
+ border-bottom: 1px solid var(--border);
+}
+
+.path-preview-heading {
+ flex: 1;
+ min-width: 0;
+}
+
+.path-preview-title,
+.path-preview-path {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.path-preview-title {
+ font-size: 0.9rem;
+}
+
+.path-preview-path {
+ margin-top: 2px;
+ color: var(--text-dim);
+ font-family: var(--font-mono, monospace);
+ font-size: 0.65rem;
+}
+
+.path-preview-open,
+.path-preview-close {
+ flex: 0 0 auto;
+ color: var(--text);
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
+.path-preview-open {
+ padding: 8px 11px;
+ color: var(--accent);
+ font-size: 0.75rem;
+ text-decoration: none;
+}
+
+.path-preview-close {
+ width: 38px;
+ height: 38px;
+ font-size: 1.5rem;
+ cursor: pointer;
+}
+
+.path-preview-body {
+ position: relative;
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ align-items: center;
+ justify-content: center;
+ overflow: auto;
+ background: #111;
+}
+
+.path-preview-loading,
+.path-preview-error {
+ padding: 24px;
+ color: var(--text-muted);
+ font-size: 0.8rem;
+ text-align: center;
+}
+
+.path-preview-error {
+ color: var(--danger, #ef4444);
+}
+
+.path-preview-image {
+ display: block;
+ max-width: 100%;
+ max-height: 100%;
+ margin: auto;
+ object-fit: contain;
+}
+
+.path-preview-frame {
+ width: 100%;
+ height: 100%;
+ background: #fff;
+ border: 0;
+}
+
+.path-preview-text {
+ width: 100%;
+ min-height: 100%;
+ margin: 0;
+ padding: 18px;
+ overflow: visible;
+ color: #e5e7eb;
+ font-family: var(--font-mono, monospace);
+ font-size: 0.78rem;
+ line-height: 1.55;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
@media (max-width: 600px) {
.path-picker-overlay {
align-items: flex-end;
@@ -11120,6 +11258,23 @@ body.touch-device.cjk-input-visible .main {
.path-picker-current-select {
flex: 1 1 100%;
}
+
+ .path-preview-overlay {
+ align-items: stretch;
+ padding: 0;
+ }
+
+ .path-preview-dialog {
+ width: 100%;
+ height: 100dvh;
+ border: 0;
+ border-radius: 0;
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+ }
+
+ .path-preview-open {
+ padding: 8px;
+ }
}
/* ═══════════════════════════════════════════════════════════════
diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts
index 07b88665..58a8fc9e 100644
--- a/src/web/routes/file-routes.ts
+++ b/src/web/routes/file-routes.ts
@@ -4,11 +4,17 @@
*/
import { FastifyInstance, type FastifyReply } from 'fastify';
-import { basename as pathBasename, isAbsolute, join, relative, resolve, sep } 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 } from '../../types.js';
+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 {
@@ -37,7 +43,7 @@ import type { SessionAttachmentHistoryItem, SessionState } from '../../types/ses
import { isSensitivePath } from '../sensitive-path.js';
import { SseEvent } from '../sse-events.js';
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
-import { FilesystemBrowseQuerySchema } from '../schemas.js';
+import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema } from '../schemas.js';
const MIME_TYPES: Record = {
png: 'image/png',
@@ -55,8 +61,14 @@ const MIME_TYPES: Record = {
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 {
@@ -102,13 +114,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);
@@ -116,7 +127,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);
@@ -204,7 +215,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');
@@ -271,12 +285,36 @@ type AttachmentHistoryRouteItem = Omit 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
@@ -339,6 +377,54 @@ async function resolveFilesystemPickerRoots(
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,
+ requestedPath: string | undefined,
+ sessionId?: string
+): Promise {
+ const roots = await resolveFilesystemPickerRoots(ctx, 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`;
}
@@ -457,35 +543,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
app.get('/api/filesystem/browse', async (req, reply): Promise> => {
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
- const roots = await resolveFilesystemPickerRoots(ctx, sessionId);
- if (roots.length === 0) {
- reply.code(403);
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
- }
+ const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
+ ctx,
+ requestedPath,
+ sessionId
+ );
- 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 {
- reply.code(404);
- return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
- }
-
- const matchingRoots = roots
- .filter((root) => isPathWithinRoot(root.path, resolvedPath))
- .sort((a, b) => b.path.length - a.path.length);
- const matchingRoot = matchingRoots[0];
- if (!matchingRoot) {
- reply.code(403);
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
- }
-
- const guard = await loadAttachmentGuardConfig();
- if (isBlockedPickerPath(resolvedPath, guard.blockedTrees, true)) {
+ if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked');
}
@@ -532,8 +596,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
continue;
}
- const targetRoot = roots.some((root) => isPathWithinRoot(root.path, targetPath));
- if (!targetRoot) continue;
+ const targetRoot = findMatchingPickerRoot(roots, targetPath);
+ if (!targetRoot || containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
let type: FilesystemBrowseEntry['type'];
let size: number | undefined;
@@ -554,7 +618,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
continue;
}
- if (isBlockedPickerPath(targetPath, guard.blockedTrees, type === 'directory')) continue;
+ if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue;
if (type === 'file' && size === undefined) {
try {
size = (await fs.stat(targetPath)).size;
@@ -562,7 +626,14 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// 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 });
+ entries.push({
+ name: entry.name,
+ path: visiblePath,
+ type,
+ size,
+ symlink: symlink || undefined,
+ previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined,
+ });
}
const parentCandidate = resolve(candidatePath, '..');
@@ -589,6 +660,57 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
};
});
+ // Inline preview for files selected through the root-confined filesystem picker.
+ app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise => {
+ const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query);
+ const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
+ ctx,
+ 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 };
diff --git a/src/web/schemas.ts b/src/web/schemas.ts
index af704547..1eef7321 100644
--- a/src/web/schemas.ts
+++ b/src/web/schemas.ts
@@ -72,6 +72,16 @@ export const FilesystemBrowseQuerySchema = z.object({
.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 */
diff --git a/test/path-picker-ui.test.ts b/test/path-picker-ui.test.ts
index 93e3fee7..b64633e6 100644
--- a/test/path-picker-ui.test.ts
+++ b/test/path-picker-ui.test.ts
@@ -104,6 +104,15 @@ describe('mobile filesystem picker actions', () => {
expect(sessionSource).toContain('directoriesOnly: true');
});
+ it('keeps Choose separate from safe inline file preview', () => {
+ expect(keyboardSource).toContain('openPreview(entry)');
+ expect(keyboardSource).toContain('/api/filesystem/preview?');
+ expect(keyboardSource).toContain("entry.previewKind === 'image'");
+ expect(keyboardSource).toContain("entry.previewKind === 'text'");
+ expect(keyboardSource).toContain("choose.textContent = 'Choose'");
+ expect(keyboardSource).toContain('pre.textContent = content');
+ });
+
it('inserts a selected path into the editable local-echo prompt without sending it', () => {
const appendText = vi.fn();
const sendInput = vi.fn();
diff --git a/test/routes/file-routes.test.ts b/test/routes/file-routes.test.ts
index fb725145..c1e1cde8 100644
--- a/test/routes/file-routes.test.ts
+++ b/test/routes/file-routes.test.ts
@@ -57,6 +57,9 @@ describe('file-routes', () => {
mockedRealpathSync.mockImplementation((p: string) => p as never);
// Default stat
mockedStat.mockResolvedValue({ size: 100, isFile: () => true, isDirectory: () => true } as never);
+ mockedReadFile.mockImplementation(async (path) =>
+ String(path).endsWith('settings.json') ? ('{}' as never) : ('file content' as never)
+ );
});
afterEach(async () => {
@@ -93,9 +96,15 @@ describe('file-routes', () => {
expect(body.success).toBe(true);
expect(body.data.path).toBe(path);
expect(body.data.roots[0]).toEqual({ label: 'Current Folder', path });
- expect(body.data.entries.map((entry: { name: string; type: string }) => [entry.name, entry.type])).toEqual([
- ['src', 'directory'],
- ['notes.txt', 'file'],
+ expect(
+ body.data.entries.map((entry: { name: string; type: string; previewKind?: string }) => [
+ entry.name,
+ entry.type,
+ entry.previewKind,
+ ])
+ ).toEqual([
+ ['src', 'directory', undefined],
+ ['notes.txt', 'file', 'text'],
]);
});
@@ -147,6 +156,87 @@ describe('file-routes', () => {
expect(res.statusCode).toBe(404);
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.NOT_FOUND });
});
+
+ it('rejects direct navigation into a hidden descendant', async () => {
+ const hidden = `${harness.ctx._session.workingDir}/.git`;
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(hidden)}`,
+ });
+
+ expect(res.statusCode).toBe(403);
+ expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
+ });
+ });
+
+ // ========== GET /api/filesystem/preview ==========
+
+ describe('GET /api/filesystem/preview', () => {
+ it('serves Markdown as inert plain text inside the active session root', async () => {
+ const path = `${harness.ctx._session.workingDir}/notes.md`;
+ mockedReadFile.mockImplementation(async (candidate) =>
+ candidate === path ? ('# Safe heading\n' as never) : ('{}' as never)
+ );
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toContain('text/plain');
+ expect(res.headers['x-content-type-options']).toBe('nosniff');
+ expect(res.body).toContain('');
+ });
+
+ it('rejects unsupported file types', async () => {
+ const path = `${harness.ctx._session.workingDir}/archive.exe`;
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
+ });
+
+ expect(res.statusCode).toBe(400);
+ expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
+ });
+
+ it('rejects hidden files even when requested directly', async () => {
+ const path = `${harness.ctx._session.workingDir}/.env`;
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
+ });
+
+ expect(res.statusCode).toBe(403);
+ });
+
+ it('rejects a preview symlink whose real path escapes every allowed root', async () => {
+ const path = `${harness.ctx._session.workingDir}/outside.png`;
+ mockedRealpathSync.mockImplementation((candidate: string) =>
+ candidate === path ? ('/etc/shadow' as never) : (candidate as never)
+ );
+
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
+ });
+
+ expect(res.statusCode).toBe(403);
+ });
+
+ it('caps text previews at 2MB', async () => {
+ const path = `${harness.ctx._session.workingDir}/large.txt`;
+ mockedStat.mockImplementation(async (candidate) =>
+ candidate === path
+ ? ({ size: 2 * 1024 * 1024 + 1, isFile: () => true, isDirectory: () => false } as never)
+ : ({ size: 100, isFile: () => true, isDirectory: () => true } as never)
+ );
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
+ });
+
+ expect(res.statusCode).toBe(413);
+ });
});
// ========== GET /api/sessions/:id/files ==========
From 8c089a4819e12de4df42aab3fd8d610a8305a3b9 Mon Sep 17 00:00:00 2001
From: codeman-local
Date: Sat, 25 Jul 2026 15:31:23 +0800
Subject: [PATCH 05/25] chore: add light skins changeset
---
.changeset/light-skins.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/light-skins.md
diff --git a/.changeset/light-skins.md b/.changeset/light-skins.md
new file mode 100644
index 00000000..6e1f7a33
--- /dev/null
+++ b/.changeset/light-skins.md
@@ -0,0 +1,5 @@
+---
+"aicodeman": patch
+---
+
+Add four light UI and terminal skins—Paper Gray, Solarized Light, Catppuccin Latte, and Rosé Pine Dawn—with readable native controls, xterm palettes, and stateful mobile/response-viewer surfaces.
From 63710cf2c1d4d7ac92b9f24e0bd47e97f7a79c3d Mon Sep 17 00:00:00 2001
From: Codeman maintainer
Date: Mon, 27 Jul 2026 02:00:13 +0200
Subject: [PATCH 06/25] docs: split CLAUDE.md deep detail into
architecture-invariants, ignore it in prettier
CLAUDE.md was 110KB (~27.5k tokens) loaded into every session, with 30 lines
carrying 49% of the bytes as single-paragraph walls (the Docker cases entry
alone was 9,388 chars). Extract the implementation detail verbatim into
docs/architecture-invariants.md (41 sections) and leave the rule plus a
pointer inline. Result: 59.5KB, ~14.9k tokens, 46% smaller.
Also:
- Add CLAUDE.md to .prettierignore. Prettier's markdown printer escapes
underscores in the glob-heavy paths used throughout, which had already
corrupted the Ultracode paragraph (agent-*.jsonl became agent-\_.jsonl,
collapsing backtick spans). npm run format:check is unaffected; its globs
are src/** only.
- Move version archaeology (PR numbers, ticket ids, commit shas, "was X now
Y" lineage) into the invariants doc, keeping the rules and their reasoning
inline.
- De-duplicate the Core Files table against Key Patterns.
- Document install.sh in Scripts, and why Prettier's scope is deliberately
narrow (14 hand-formatted public JS modules are guarded by
check:public-assets and check:frontend-syntax instead).
Two factual fixes found while verifying: displayKeys is a client-side merge
policy, not a wire filter, and showResponseViewer / showPlanUsageLimits /
language are declared in SettingsUpdateSchema and do persist server-side; and
the respawn route count is 7, not 18.
Verified: 30/30 cross-doc pointers resolve, 1,184 of 1,190 backticked
identifiers from the original survive (the 6 others are dropped archaeology
or the prettier-corrupted spellings), 59 table rows well-formed,
format:check and check:frontend-syntax clean.
Co-Authored-By: Claude Opus 5 (1M context)
---
.prettierignore | 3 +
CLAUDE.md | 174 +++++++++++++++++---------------
docs/architecture-invariants.md | 167 ++++++++++++++++++++++++++++++
3 files changed, 260 insertions(+), 84 deletions(-)
create mode 100644 docs/architecture-invariants.md
diff --git a/.prettierignore b/.prettierignore
index 5dc68cd3..cdfaf71e 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
index 56ad327c..55784247 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2,6 +2,10 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+> Deep implementation detail lives in [`docs/architecture-invariants.md`](docs/architecture-invariants.md). This file holds the rules that prevent mistakes; that file holds the mechanisms, file inventories, and the history behind each rule. Pointers below are written as `→ architecture-invariants#anchor`.
+>
+> **This file is in `.prettierignore` on purpose.** Prettier's markdown printer escapes underscores inside the glob-heavy paths used throughout (`agent-*.jsonl` became `agent-\_.jsonl`, collapsing backtick spans and corrupting a whole paragraph). Do not remove the ignore entry, and do not run `prettier --write` on it.
+
## Quick Reference
| Task | Command |
@@ -100,6 +104,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
+**Prettier scope is deliberately narrow.** `npm run format` globs only `src/**/*.ts` and `src/web/public/**`, and `.prettierignore` then exempts most of `src/web/public/*.js` (app.js, styles.css, index.html, and 14 hand-formatted modules) plus `CLAUDE.md`. Those files are hand-formatted by design; `npm run check:public-assets` and `check:frontend-syntax` are what guard them (NUL bytes + JS syntax), not Prettier. Do not "fix" a file by adding it back to Prettier's scope.
+
## Common Gotchas
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink
@@ -109,12 +115,12 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.)
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort ` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
-- **Multi-CLI prefix discipline** — Codeman supports Claude Code, OpenCode, Codex, and Gemini (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts` / `codex-cli-resolver.ts` / `gemini-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional — Vertex AI auth uses `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc.; it's the loosest allowlist entry, affecting only the user's own spawned CLI). When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward all prefixes. See `docs/opencode-integration.md` for the resolver design pattern
-- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
-- **`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs` (the `xterm-zerolag-input` esbuild step, for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
-- **Default bind is loopback-only; non-loopback without a password starts but warns** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). As of **0.9.0** binding a non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **no longer refuses to start — it starts and prints a loud warning** listing the fixes (set `CODEMAN_PASSWORD`, bind loopback + tunnel/`tailscale serve`, or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` to acknowledge → terser note). Host classification is `isLoopbackBindHost()` in `network-auth-policy.ts`; the warn-vs-start logic is in `server.ts` `start()`; flags wired in `cli.ts`. ⚠️ Operational note: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so it binds **localhost only** — reach it remotely via `tailscale serve`/tunnel to `127.0.0.1`, or add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → `127.0.0.1`) but NOT by a browser hitting the box's LAN IP. Auth user defaults to `admin`. **Full model: `docs/security-architecture.md`.**
-- **Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-` + `-L codeman-`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`.
-- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — `scripts/capture-real-overview.mjs` (drives a live session in headless Chromium → overview PNG). Two traps, both observed 2026-06-14: **(1) DSF=2 doubles the console font.** xterm's WebGL renderer draws terminal glyphs at ~2× their nominal size under `deviceScaleFactor: 2`, while STILL reporting nominal cell dims (`terminal.cols`/`_renderService.dimensions.css.cell` say 8px/187cols — they lie), so it's invisible to any internal measurement and only the pixels reveal it. The HTML chrome (header/toolbar) is unaffected → ONLY the console font looks comically large. Default to **DSF=1** (script does); the image is 1× res but the font is true-to-browser. **(2) Stable filenames → stale renders.** Overwriting a fixed path (`claude-overview.png`) in place leaves OS image viewers (eog/feh) — and any HTTP client behind a long/`immutable` cache — showing the OLD render; the user reads it as "the fix didn't work". The script now mints a timestamped `claude-overview-.png` per run. ⚠️ This was a LOCAL image-viewer cache, NOT a Codeman serving bug: `file-routes` previews send `Cache-Control: no-cache` and `/api/screenshots/:name` sends none. The one real Codeman-side footgun: `server.ts` serves non-content-hashed static assets `public, max-age=31536000, immutable`, and `cacheBustAssets()` only rewrites `.js`/`.css` refs — a stable-named **image** referenced from public/ would go stale on overwrite. Reflect the per-device UI to match a real device when capturing: seed `localStorage` `codeman:skin`, `codeman-font-size`, and the desktop `codeman-app-settings` blob (the plan-usage chip is a per-device display key deleted from the server payload — a fresh browser hides it unless seeded; close side panels for a full-width terminal).
+- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. Resolver design pattern: `docs/opencode-integration.md`
+- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice
+- **`xterm-zerolag-input` is single-source** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/`, and is bundled into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` (dev, by `scripts/postinstall.js`) and `dist/.../vendor/` (prod, by `scripts/build.mjs`). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundle.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md`
+- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
+- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
+- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
@@ -122,30 +128,30 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
### Core Files (by domain)
-| Domain | Key files | Notes |
-| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
-| **Entry** | `src/index.ts`, `src/cli.ts` | |
-| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts`, `src/session-pty-exit-breaker.ts`, `src/session-order.ts` (pure tab-order normalize/merge helpers, COD-131), `src/usage-limit-patterns.ts`, `src/usage-telemetry.ts`; `src/services/unified-session-service.ts` (merges live/persisted/lifecycle/transcript rows for `GET /api/sessions/unified`) | |
-| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | |
-| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
-| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
-| **Orchestrator** | `src/orchestrator-loop.ts`, `src/orchestrator-planner.ts`, `src/orchestrator-verifier.ts` | Read `docs/orchestrator-loop-architecture.md` first |
-| **Cron** | `src/cron/cron-service.ts`, `src/cron/cron-time.ts` (pure next-run math), `src/cron/cron-input.ts` | Cron-style `CronJob`s. Read `docs/cron-discovery.md` first; distinct from legacy `ScheduledRun` (`/api/scheduled`) — see Key Patterns |
-| **Agents** | `src/subagent-watcher.ts` ★, `src/team-watcher.ts`, `src/bash-tool-parser.ts`, `src/transcript-watcher.ts`, `src/workflow-run-watcher.ts` | `workflow-run-watcher` is STANDALONE (never touches `subagent-watcher`) — see Key Patterns |
-| **AI** | `src/ai-checker-base.ts`, `src/ai-idle-checker.ts`, `src/ai-plan-checker.ts` | |
-| **Tasks** | `src/task.ts`, `src/task-queue.ts`, `src/task-tracker.ts` | |
-| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | |
-| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts`, `src/remote-hosts.ts` (remote SSH hosts/cases — see Key Patterns), `src/remote-reconnect.ts` (pure COD-108 auto-reconnect backoff/eligibility; watcher lives in `tmux-manager.ts`), `src/docker-hosts.ts` + `src/docker-export.ts` (docker hosts/cases + export/import — see Key Patterns) | |
-| **Search** | `src/search-service.ts` | Pure in-memory core for `GET /api/search` — see Key Patterns |
-| **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/generated-artifact-attachments.ts` (Codex `Saved to:` artifacts), `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns |
-| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | |
-| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (20 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts`, `src/web/ws-connection-registry.ts` (per-tab WS supersede), `src/web/heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` (HEIC→JPEG off-thread) | |
-| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 7 infra modules (`constants.js`, `i18n.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 10 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `cron-ui.js`, `settings-ui.js`, `panels-ui.js`, `admin-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `i18n.js` owns English/Simplified Chinese UI localization + user-facing branding; `ultracode-windows.js` = floating run windows w/ tab connector lines |
-| **Types** | `src/types/index.ts` (barrel) → 19 domain files (incl. `workflow-run.ts`, `search.ts`, `cron.ts`, `user.ts`); also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
+| Domain | Key files | Notes |
+| ---------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
+| **Entry** | `src/index.ts`, `src/cli.ts` | |
+| **Session** | `src/session.ts` ★, `session-manager`, `session-auto-ops`, `session-cli-builder`, `session-task-cache`, `session-order` (pure), `session-pty-exit-breaker`, `usage-limit-patterns`, `usage-telemetry`; `src/services/unified-session-service.ts` | Pure/unit-tested helpers are split out of `session.ts` on purpose |
+| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | |
+| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
+| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
+| **Orchestrator** | `src/orchestrator-loop.ts`, `-planner`, `-verifier` | Read `docs/orchestrator-loop-architecture.md` first |
+| **Cron** | `src/cron/cron-service.ts`, `cron-time.ts` (pure next-run math), `cron-input.ts` | Read `docs/cron-discovery.md` first. Distinct from legacy `ScheduledRun` (`/api/scheduled`) |
+| **Agents** | `src/subagent-watcher.ts` ★, `team-watcher`, `bash-tool-parser`, `transcript-watcher`, `workflow-run-watcher` | `workflow-run-watcher` is STANDALONE and never touches `subagent-watcher` |
+| **AI** | `src/ai-checker-base.ts`, `ai-idle-checker.ts`, `ai-plan-checker.ts` | |
+| **Tasks** | `src/task.ts`, `task-queue.ts`, `task-tracker.ts` | |
+| **State** | `src/state-store.ts`, `run-summary.ts`, `session-lifecycle-log.ts` | |
+| **Infra** | `src/hooks-config.ts`, `push-store`, `tunnel-manager`, `image-watcher`, `file-stream-manager`, `remote-hosts` + `remote-reconnect` (pure), `docker-hosts` + `docker-export` | Remote/docker case overlays; see Key Patterns |
+| **Search** | `src/search-service.ts` | Pure in-memory core for `GET /api/search` |
+| **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns |
+| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases |
+| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (20 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | |
+| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 23 modules + `sw.js` | See Frontend section for the load order, which is authoritative |
+| **Types** | `src/types/index.ts` (barrel) → 19 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
-**Local packages**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; single-source, bundled to the gitignored `vendor/xterm-zerolag-input.js` and consumed by `app.js` (see Gotchas). `packages/gesture-control/` (`codeman-gesture-control`) — hand-tracking overlay source; built to `src/web/public/gesture/gesture-codeman.js` via `npm run build:gesture` (see Frontend → Gesture control).
+**Local packages**: `packages/xterm-zerolag-input/` (local echo overlay, single-source, see Gotchas). `packages/gesture-control/` (`codeman-gesture-control`, hand-tracking overlay source, built via `npm run build:gesture`).
**Config**: `src/config/` — 16 files, no barrel (`index.ts`) exists; import from the specific file.
@@ -160,51 +166,49 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
### Key Patterns
-**Input**: `session.writeViaMux()` for programmatic/curl input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`).
+**Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
-**Auto-resume on usage limit** ("token pause" control, opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit ("5-hour limit reached ∙ resets 8pm" and all 1.0.x–2.1.x variants), `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time from cleaned output; `SessionAutoOps` arms a timer for reset+2min, then sends Esc (dismisses the rate-limit dialog) + `continue`. Still-limited responses re-arm the loop (5-min retry on stale times); a `working` transition cancels it. Claude-mode only (detection rides `_processExpensiveParsers`). Persists/recovers via `SessionState.autoResumeEnabled`/`autoResumeAt`; respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected` — prevents `/clear` from wiping the paused conversation). Endpoint: `POST /api/sessions/:id/auto-resume`; SSE: `session:limitPauseScheduled`/`limitResume`/`limitResumeCancelled`. Tests: `test/usage-limit-patterns.test.ts`, `test/session-auto-resume.test.ts`.
+**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
-**Plan-usage chip** (statusLine telemetry, opt-in `showPlanUsageLimits`, default OFF): Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is _ours_, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (so injecting our statusLine doesn't blank the in-terminal footer). `plan-usage-latest.ts` holds the process-wide last value, replayed in the SSE init snapshot (`getLightState`) so the header chip (`#planUsageChip`, toggled by `showPlanUsageLimits` in settings-ui.js) renders immediately on page load / reconnect without per-browser localStorage. Claude-mode only. **Distinct from auto-resume** (which reacts to the limit _message_; this proactively shows the live %). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`.
+**Plan-usage chip** (statusLine telemetry, opt-in `showPlanUsageLimits`, default OFF): Codeman injects its own `statusLine.command` exporter which POSTs Claude's `rate_limits` blob to `POST /api/status-telemetry`. The exporter is identified by a marker, so it only ever adds/updates/removes a statusLine that is **ours**, never a user's hand-authored one, and it prints the footer through so the in-terminal statusline is not blanked. Claude-mode only; distinct from auto-resume, which reacts to the limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
-**Cron (cron-style `CronJob`s)**: saved, named jobs with a recurring schedule (`once`/`interval`/`daily`/`weekly`), enable/disable, Run Now, next-run calc, and per-job run history (`CronJobRun`). ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded autonomous loop) — the two never interact; the legacy concept keeps the `Scheduled*` names, the recurring-job feature is `Cron*`. `CronService` (`src/cron/cron-service.ts`) owns CRUD + the 30s background due-tick (`tickDueJobs`, registered via `cleanup.setInterval` in `server.ts`; `init()` recomputes nextRunAt on boot) and **reuses the existing session layer** (create → `addSession` → `setupSessionListeners` → `startInteractive`/`startShell` → prompt via `writeViaMux`/`write`) rather than rebuilding tmux logic. Next-run math is pure/unit-tested in `cron-time.ts` (SERVER-LOCAL timezone for daily/weekly). Dup-launch guard = `lastDueKey` (jobId:fireTime); schedule is advanced BEFORE launch so a slow launch can't re-trigger. `once` jobs self-disable after firing (`completedOnce`). Persisted via `AppState.cronJobs`/`cronJobRuns` (StateStore accessors). Routes `/api/cron/jobs*` + `/api/cron/runs` (`cron-routes.ts`, `CronPort`); schema `CronJobSchema` (cross-field `superRefine`; the `.partial()` update schema does NOT re-run it); SSE `cron:*`. Frontend `cron-ui.js` (#cronModal). Claude/shell/opencode/codex/gemini agent types. Tests: `test/cron-time.test.ts`, `test/cron-service.test.ts`. Design: `docs/cron-discovery.md`.
+**Cron (`CronJob`s)**: saved, named jobs on a recurring schedule (`once`/`interval`/`daily`/`weekly`) with per-job run history. ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded loop); the two never interact and keep separate `Scheduled*` / `Cron*` names. `CronService` **reuses the existing session layer** rather than rebuilding tmux logic. Next-run math is pure and unit-tested in `cron-time.ts` (server-local timezone). The schedule is advanced BEFORE launch so a slow launch cannot re-trigger. → [architecture-invariants#cron-jobs](docs/architecture-invariants.md#cron-jobs), `docs/cron-discovery.md`
-**Remote sessions (SSH)**: Sessions can run the agent inside a durable `tmux -L codeman-remote new-session -A` **on a remote host** so it survives the SSH drop (COD-104), and can also **discover + attach** to `codeman-*` sessions another Codeman launched there — attached (`owned:false`) sessions **detach, never kill** on tab close (COD-105). **Shared/collaborative** (COD-106): remote set-options are scoped per-session (never `-g`) and `window-size latest` lets multiple clients attach the same session at different viewports without clamping to the smallest; a client count surfaces a "shared · N" badge. **Auto-reconnect** (COD-108): a bounded-backoff watcher re-establishes a dropped remote session's local ssh pane and reattaches the still-running durable remote tmux (kill-switch `remoteAutoReconnect`, default ON); the pure pieces (backoff schedule, per-session reconnect state, `decideReconnect` eligibility) live in `src/remote-reconnect.ts` (tests: `test/remote-auto-reconnect.test.ts`), while `tmux-manager.ts` owns the live pane probe + timers. Owned sessions propagate `kill-session` to the remote on close; non-owned never do. ⚠️ Command-injection surface (COD-107): all ssh command lines flow through the single shell-safe `buildSshConnectionArgs()` — every user field (`-J jumpHost`, `-i identity`, `-o`) is `shellescape`d; never hand-build an ssh line elsewhere. Full design: `docs/remote-sessions.md`.
+**Remote sessions + remote SSH cases**: a case can point at a remote host. The agent runs inside a durable remote `tmux -L codeman-remote` (session name `codeman-ssh-`, deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so an instance on the target host never adopts it), fronted by a LOCAL tmux pane running `ssh`. Attached (`owned:false`) sessions **detach, never kill** on tab close; owned ones propagate `kill-session`. A bounded-backoff watcher auto-reconnects dropped sessions (`remoteAutoReconnect`, default ON). ⚠️ **Command-injection surface: every ssh command line must flow through `buildSshConnectionArgs()`**, which `shellescape`s every user field. Never hand-build an ssh line elsewhere. ⚠️ Run flows must route remote cases through `POST /api/quick-start`, not `POST /api/sessions` (which stat-validates `workingDir` locally and has no `caseName`). → [architecture-invariants#remote-sessions-over-ssh](docs/architecture-invariants.md#remote-sessions-over-ssh), [#remote-ssh-cases](docs/architecture-invariants.md#remote-ssh-cases), `docs/remote-sessions.md`
-**External CLI modes (OpenCode, Codex, Gemini)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All three modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM).
+**Docker cases**: a case can point at a **container**, with any of the five CLI backends running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design)
-**Run launch synchronization**: the main Run entrypoint in `session-ui.js` holds an in-flight lock and disables `#runBtn` for the whole launch (at least 500ms), so a double click cannot create duplicate sessions with the same `w-` name. A successful create/quick-start also calls `_ensureCreatedSessionVisible()` before `selectSession()`: local creates use the response's full session snapshot; quick-start modes fetch `GET /api/sessions/:id` only when `session:created` SSE has not already populated the map. The normal `_onSessionCreated()` handler remains the idempotent upsert, so POST-first and SSE-first ordering both produce one immediately-rendered tab. Tests: `test/run-mode-ui.test.ts`.
+**External CLI modes (OpenCode, Codex, Gemini)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All three **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini)
-**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh ` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to `exec claude --dangerously-skip-permissions`; per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`.
+**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w-` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
-**Docker cases** (shipped 1.4.0; user guide `docs/docker-cases.md`, design `docs/docker-cases-plan.md`): a case can point at a **container** instead of a local/remote path, and any of the five CLI backends runs INSIDE it. Like remote-SSH, it is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`** (`SessionMode` is unchanged). Storage `~/.codeman/docker-hosts.json` + `docker-cases.json` via `src/docker-hosts.ts` (direct mirror of `remote-hosts.ts`: `readDockerHosts`/`readDockerCases`, `toSessionDocker`, `dockerDisplayPath`, and the PURE builders `buildDockerBaseArgs`/`buildDockerCreateArgs`/`containerApiUrl`/`hostGatewayAlias`/`dockerConfigHash`). CRUD `/api/docker-hosts` + `/api/cases/docker-link`, plus **one-click** `/api/cases/docker-quickcreate` (Create New "Run in Docker" checkbox → case folder in `CASES_DIR` + auto-provisioned shared `default` host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case `q-` host), and export/import (`/api/docker-cases/:name/export`, `/api/docker-cases/import`, `GET/DELETE /api/docker-exports`) — all in `case-routes.ts`. Run flows route through `POST /api/quick-start` like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). **Launch model**: exactly one long-lived container **per case** (`codeman-case-`, PID1 `sleep infinity` under `--init`); a LOCAL tmux pane runs `docker exec -it` into a **durable in-container tmux** on dedicated socket `-L codeman-docker`, session `codeman-dkr-` (deliberately fails `SAFE_MUX_NAME_PATTERN` so a Codeman running INSIDE the container never adopts it, exactly like remote's `codeman-ssh-`). Builders `buildDockerLaunchCommand`/`buildDockerKillCommand` in `tmux-manager.ts` (image-check → `docker inspect||create` → start → exec, all idempotent). The container is **shared by all sessions of the case**: `buildDockerKillCommand` kills ONLY that session's in-container tmux session, NEVER `docker stop` while siblings remain; `docker rm -f` happens only on case-delete (plus an instance-scoped boot reaper keyed on the `codeman.instance` label). **Two-layer durability/resume** (the central design point): (1) Codeman-PROCESS restart with the container still up → `tmux new-session -A` reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so the re-run pane command resumes the conversation from the bind-mounted transcript: claude mode pins a DETERMINISTIC conversation id via `claudeDockerPaneCommand()` (`tmux-manager.ts`) — fresh launch `claude --session-id || claude --resume ` (a duplicate `--session-id` exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume `--resume || --session-id ` so a stale id never dead-panes (leading `exec ` is stripped — an exec'd first branch could never fall back); codex `resume ` / gemini `--resume` keep `appendResumeFlag`. The resume id rides `resumeSessionId` through create/respawn options and persists on `DockerCase.lastClaudeSessionId` via `persistDockerCaseClaudeSessionId()` (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-`/clear` switches track; seeded back when `resumeOnStart`, default true); `-A` makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). **Config drift** (`dockerConfigHash` → `codeman.confighash` label): quick-start compares via `checkDockerConfigDrift()` and REFUSES a drifted launch with `CONFLICT`; the UI confirm calls `POST /api/docker-cases/:name/recreate` (refused while case sessions are live) which `docker rm -f`s so the next launch recreates with the new config — host config edits actually take effect. **Workspace** is a REAL host dir bind-mounted at the SAME absolute path (mirror, `dst==src`), so `Session.workingDir = hostWorkspacePath` keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; `resolveMuxAttachCwd` returns `/tmp` for docker (the local pane only runs `docker exec`). **Creds** arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (`~/.claude/projects` transcripts; codex `sessions/` + `history.jsonl` for response-viewer/`codex resume`); everything else is SEEDED (RO mount, copied into container HOME once at launch via `[ -e ] || cp`; the container refreshes its own copy and never writes back): `~/.claude.json` is merged through `buildSeamlessClaudeConfig()` (forces `hasCompletedOnboarding` + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus `.claude/{.credentials.json,settings.json,stats-cache.json}`, plus whole-dir seeds for `~/.gemini`/`~/.config/{gcloud,opencode}` (`resolveDockerClaudeArtifacts`/`resolveDockerCredentialArtifacts` in `docker-hosts.ts`). Bind mounts are physically excluded from `docker commit`, so exports stay secret-free; API-key CLIs get exec-time NAME-ONLY `--env OPENAI_API_KEY` (no `=value`); the SEALED profile is `mountCredentials:false` + `network:none`. NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket. **Hardening** on every create: `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit`, `--memory`==`--memory-swap`, non-root via `--user :0` (Linux, GID 0 for writable HOME) / `--userns=keep-id` (podman rootless) / baked uid (Docker Desktop), `--pull=never`, `--init`. Base image `codeman/agent:base` is BUILT LOCALLY from `docker/agent.Dockerfile` (node22 + tmux + claude/codex/gemini/opencode, OpenShift arbitrary-uid HOME, `C.UTF-8` locale so tmux/Ink render real box-drawing glyphs; Codeman also sets `LANG`/`LC_ALL` at run time for containers built before that line) via `scripts/build-agent-image.mjs` OR **auto-built on first use** (1.4.1: `ensureAgentBaseImage()` in `docker-hosts.ts`; idempotent + concurrency-safe, only the DEFAULT image ref is ever auto-built, `--pull=never` stays absolute; build output streams over SSE `docker:imageBuildStarted`/`imageBuildProgress`/`imageBuildComplete`/`imageBuildFailed`, and quick-create returns `imageBuilding:true` while the first launch awaits the gate); tmux-in-image is a HARD gated prerequisite (`checkDockerTmuxAvailable`), never a silent bare-exec fallback. **Hooks + model**: the workspace-scaffolding block DOES run for docker (writes `.claude/settings.local.json` + the CLAUDE.md scaffold into the real host dir), so `modelOverride` works via `settings.local.json` — it is a `QuickStartSchema` field applied for local AND docker quick-starts (`updateCaseModel`), sent by the frontend docker run path (the one deliberate difference from remote, which rejects it); `effort`/`envOverrides`/`codexConfig`/`geminiConfig`/`openCodeConfig` stay rejected. In-container hook curls hit `containerApiUrl(process.env.CODEMAN_API_URL, engine)` (swaps ONLY the hostname to the gateway alias, preserving scheme+port so prod HTTPS still works); the host guard allowlists both `host.docker.internal`/`host.containers.internal` (`DOCKER_HOST_GATEWAY_ALIASES` in `network-auth-policy.ts`). ⚠️ On a **loopback-only** bind (the prod default) a container cannot reach 127.0.0.1, so in-container hooks fire ONLY when `CODEMAN_DOCKER_BRIDGE_HOOKS=1` — an opt-in SECOND listener on the docker bridge gateway (`_startDockerBridgeHooksListener` in `server.ts`; gateway auto-detected via `detectDockerBridgeGateway`, or set `CODEMAN_DOCKER_BRIDGE_HOST`) that serves ONLY the hook endpoints (403 for any other path) into the same secret-gated pipeline; otherwise idle detection falls back to output-based through the docker-exec PTY. Container-set `CLAUDE_CODE_TMPDIR` keeps claude launching regardless of workspace path. `SessionState.docker`/`MuxSession.docker` round-trip through recovery. Every docker IO path is `IS_TEST_MODE` (VITEST) no-op'd; the pure builders are unit-tested. **Export/import** (`src/docker-export.ts`): full-image (`docker commit` + `save | gzip` + workspace tar + manifest) or workspace-only → one portable `~/.codeman/docker-exports/-.codeman-container.tgz`; import validates per-member sha256, traversal-guards the workspace tar, `docker load`s + quarantine-retags the image (`codeman/imported-:`, never overwriting a local tag); a `saveImageToTar` stream `pipeline` avoids truncation. **GPU** passthrough (`gpus` → `--gpus`, needs the NVIDIA container toolkit) and **elastic disk** (no `--storage-opt` cap, so container storage grows with data). SSE `docker:exportComplete`/`exportFailed`/`importComplete` (both registries). **UI** in `session-ui.js`: Create Case **Docker** tab (collapsed/compact form since 1.4.1), the one-click checkbox + Template picker, short `(docker)` case-menu tags, and a Manage-tab Export button; docker AND remote sessions name their tabs `w-` via the shared `_nextCaseSessionStartNumber()` so all tabs follow one naming convention. Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/docker-export.test.ts`, `test/network-host-guard.test.ts`.
-
-**Unified session list** (COD-160/#139): `GET /api/sessions/unified?limit=&q=` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows are keyed by conversation UUID and folded into their owning session via a `claudeSessionId → Codeman id` alias map (resumed//clear-respawned sessions must not appear twice); lifecycle name/mode resolution is first-seen-wins (the log returns entries NEWEST-first). No terminal buffers in the response (unlike `/api/sessions`). Consumed by the Cmd+K Session Manager (#146). Session Manager polish (COD-162/#157, 1.6.0): **pinning** via `POST /api/sessions/:id/pin` (`session:pinned` SSE; killing a pinned session demotes it to a lightweight stopped record that stays visible/resumable, and cleanup skips pinned records); **cross-device tab order** via `PUT /api/session-order` (`session:orderChanged` SSE, persisted in `state.json`; pure `normalizeSessionOrder`/`mergeSessionOrder` in `src/session-order.ts`: pushing device wins, server-only ids fall to the end, never dropped); resume from the manager keeps the original session name (COD-143); `firstPrompt` is backfilled for sessions whose id != transcript UUID and the most recent prompt (`lastPrompt`) is shown + searched (COD-140/145).
+**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`.
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
-**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`. **Distinct: PTY-exit breaker** (COD-115/118/#147, `session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits (crash loops on attach), blocks further auto-restarts, broadcasts SSE `session:respawnBreakerTripped` + push (in `PUSH_EVENT_MAP`). Reset ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive` (sent by the user-facing restart control) — the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. Sessions also scrub inherited `TMUX`/`TMUX_PANE` env so Codeman-in-tmux doesn't nest. Tests: `test/respawn-pty-breaker.test.ts`.
+**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
-**Full-scrollback replay** (COD-164/#148): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). Only the FIRST buffer load after a page load requests `full=1` (one-shot `_initialFullBufferLoad` flag in app.js); tab switches keep the cheap `?tail=` visible-frame path. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`.
+**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. Only the FIRST buffer load after a page load requests `full=1`; tab switches keep the cheap `?tail=` path. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
-**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. Supervisors: `systemd` (user unit), `launchd` (GUI LaunchAgent, gui-domain kickstart), `launchd-daemon` (KeepAlive system LaunchDaemon on headless Macs — restarts rootlessly by killing the server PID and letting launchd respawn it; detected only when the daemon is bootstrapped AND KeepAlive), else `none` → "restart manually" message; on next boot a manual-restart status auto-completes when the running version matches the target. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
+**Self-update** (App Settings → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. → [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
-**Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups _identical_ in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals. **Codex generated artifacts** (COD-166/#150, `generated-artifact-attachments.ts`): codex-mode sessions ALSO scan (ANSI-stripped) output for `Saved to: file:///…` lines and surface those files as attachment cards with a relaxed trust policy — the allow decision runs on the **realpath-resolved** path against `os.homedir()`-anchored `~/.codex` marker dirs (symlink escapes fall back to force-confinement); gated to `mode === 'codex'` only (`source` is a REQUIRED param through the listener-deps chain — a dropped arg here silently kills the feature). Image thumbnails pass through jpg/jpeg/gif/webp.
+**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
-**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects///workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_/` (journal.jsonl + agent-\_.jsonl). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf\__.json`appears and supersedes, and broadcasts SSE`workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents`**or**`ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()`returns`(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows`(optional`?minutes=`filter) and`GET /api/workflows/:runId`. Frontend `ultracode-panel.js`renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side`agentId`join). **Additionally**,`ultracode-windows.js`auto-pops a draggable **floating window per active run** (gated on a **DEDICATED**`ultracodeFloatingWindows`toggle, default OFF — independent of the dock panel's`showUltracodeAgents`; see `\_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines`SVG from the tail of`\_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA`badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a`window`grab kind in`entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
+**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
-**Cross-session search** (COD-113/#133): `GET /api/search?q=&types=&limit=` federates an **in-memory** search across all live sessions — session metadata (name/workingDir/id), run-summary events, and per-session attachment-history file entries (workspace-relative path only; the server-private `externalPath` is never read). Pure core `searchSources()` in `search-service.ts` (substring-matches with hard per-type caps — no regex, so no ReDoS; no filesystem reads, so no traversal); `harvestSources()` in `search-routes.ts` gathers the in-memory sources. `SearchQuerySchema` bounds `q` (1–200), allowlists `types` (`session,event,file`), clamps `limit` (1–60). Returns the `{success,data}` envelope. Frontend: history-panel search box in `terminal-ui.js`. Types: `src/types/search.ts`.
+**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
-**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default; shipped 1.5.0 via PR #161, design `docs/multi-user-plan.md`): named users with individually scrypt-hashed passwords in `~/.codeman/users.json` (via `src/user-store.ts`: atomic 0600 write, short-TTL cache, SERIALIZED read-modify-write so a fire-and-forget `touchLastLogin` can't clobber a concurrent route write, last-admin invariants). Gated everywhere by `isMultiUserMode()` (`src/config/multiuser.ts`); when OFF, behavior is byte-identical to single-user (all scoping helpers short-circuit). ⚠️ **Not a security boundary at the agent layer** — every session still runs as the SAME OS account; this separates WORKSPACES, it does not sandbox users (Docker cases are the isolation story). Auth: a PARALLEL async branch in `middleware/auth.ts` (single-user branch untouched) verifies `username:password` against the store, mints identity-carrying cookies (`AuthSessionRecord` gains `username`/`role`/`mustChangePassword`), decorates `req.authUser` (Fastify augmentation; single-user leaves it undefined and the ownership helpers default to a synthetic admin), enforces a per-username failure bucket + the `mustChangePassword` lockbox. Ownership threads through `Session.owner` (stamped from `req.authUser`/`job.owner` at every `new Session()`, round-tripped via `MuxSession.owner` on recovery); `findSessionOrFail(ctx,id,req)` does a NOT_FOUND owner check; list endpoints + `getLightState` + SSE (`deriveSseHint` routes session-scoped events by owner, fail-closed; machine-level + host-plan telemetry admin-only) + WS + search + file-preview all filter by owner. §6.3 permission policy: non-granted users are forced to `--permission-mode auto` (via `resolveClaudeModeForUser` at all spawn sites, incl. one-shots because `buildPromptArgs` now respects the session mode), and shell mode / cron `launchCommand` require the `canBypassPermissions` grant. Cases live in per-user `~/codeman-users//cases` (`resolveCasesDir`); a non-admin's `workingDir` is realpath-confined there; host CRUD is admin-only. Admin API `src/web/routes/admin-routes.ts` (`/api/admin/users*`, one-time passwords, audit log `admin-audit.jsonl`) + self-service `/api/me` + `/api/me/password` (`me-routes.ts`); frontend `public/admin-ui.js` (identity boot, change-password modal + interceptor, admin Users tab, and the header **Admin Panel** button `#adminPanelBtn`: ships `btn-admin-panel--hidden`, revealed for admins in multi-user mode, phone-hidden via mobile.css; opens the full Admin Panel modal with user CRUD, per-user permission toggles, and case-folder list/delete via `GET/DELETE /api/admin/users/:username/cases[/:caseName]`; live-refreshes on SSE `admin:usersChanged`, wired in app.js). CLI `codeman users add|passwd|list|rm`. Per-user session cap via `sessionCapacityState`/`sessionCapacityMessage`. Tests: `test/user-store.test.ts`, `test/multiuser-auth.test.ts`, `test/ownership-scoping.test.ts`, `test/admin-routes.test.ts`, `test/admin-ui.test.ts`.
+**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
-**Away digest** (COD-41/#136): `GET /api/away-digest?range=&since=&until=&lastViewed=` aggregates "what happened while you were away" from the lifecycle log + run-summary events + live sessions + daily token stats + recently-completed subagents into needs-attention/completed/still-running/idle/informational sections. Pure aggregator in `web/away-digest.ts` (`resolveAwayDigestRange()` validates the window — `since-last-visit`/`1h`/`today`/`24h`/`custom`, server-local TZ; `buildAwayDigest()` classifies). Header-button modal in `panels-ui.js` (button hidden on phones — regression-guarded). ⚠️ Returns `{success:true,digest}` (a legacy raw-ish shape, consistent with the other raw GET handlers in `system-routes.ts` — `{entries}`/`{config}`/`{files}`/`getSystemStats()`); frontend + tests read `.digest`. Subagent lookback is a fixed 60-min window regardless of range.
+**Away digest**: `GET /api/away-digest` aggregates what happened while you were away from the lifecycle log, run-summary events, live sessions, token stats, and recent subagents. Pure aggregator in `web/away-digest.ts`. ⚠️ Returns `{success:true,digest}`, a legacy raw-ish shape consistent with the other raw GET handlers in `system-routes.ts`; frontend and tests read `.digest`. → [architecture-invariants#away-digest](docs/architecture-invariants.md#away-digest)
-**Ralph todo-config** (COD-79/#135): per-session `maxTodos` (FIFO-eviction cap, default 500 = `MAX_TODOS_PER_SESSION`) + `todoExpirationMinutes` (auto-expiry, default 60) set via `POST /api/sessions/:id/ralph-config` (`RalphConfigSchema`, both `.int().positive()`). Stored on the tracker (`setMaxTodos`/`setTodoExpirationMinutes`) and **persisted/read-back via `RalphTrackerState`** (surfaced in the `loopState` getter → `toState()` + SSE broadcast → modal `populateRalphForm`), mirroring how `maxIterations` round-trips. Claude-only (skipped by `isExternalCliMode`).
+**Ralph todo-config**: per-session `maxTodos` (FIFO-eviction cap, default 500 = `MAX_TODOS_PER_SESSION`) + `todoExpirationMinutes` (auto-expiry, default 60) set via `POST /api/sessions/:id/ralph-config` (`RalphConfigSchema`, both `.int().positive()`). Stored on the tracker (`setMaxTodos`/`setTodoExpirationMinutes`) and **persisted/read-back via `RalphTrackerState`** (surfaced in the `loopState` getter → `toState()` + SSE broadcast → modal `populateRalphForm`), mirroring how `maxIterations` round-trips. Claude-only (skipped by `isExternalCliMode`).
**Port interfaces**: Routes declare dependencies via port interfaces (`src/web/ports/`). Routes use intersection types (e.g., `SessionPort & EventPort`).
@@ -212,56 +216,53 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
-**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over.
+**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
-**WebGL renderer toggle** (#140, `webglRendererEnabled`): per-device (`displayKeys` set, stripped from the server payload — NOT in `SettingsUpdateSchema`, which is `.strict()`). The GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads; it's cleared only by an explicit OFF→ON save transition or `?webgl=force` (`shouldSkipWebGL` in constants.js). `?nowebgl` still forces the DOM renderer per-load.
+**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
-**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried; bug fixed in `b8cb467`), log viewers (2000), image popups (3000), local echo overlay (7).
+**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
-**Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`.
+**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
-**Response-viewer (eye) button** (header) is likewise **hidden by default** — enable under App Settings → Display → **Response Viewer** (`showResponseViewer`). Works for Claude AND Codex sessions (#152): Codex last-responses are located via a 4-layer rollout resolution under `CODEX_HOME` (history pin → originator match → resume-UUID → cwd fallback with other-pane exclusion), with injected-context filtering and event/legacy dedup — tests in `test/routes/session-routes-codex-last-response.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
+**Theme skins / branding / i18n**: `skin` selects a palette via `data-skin` on ``, applied by an **inline pre-paint script** in `index.html` reading `localStorage['codeman:skin']` to avoid a flash of wrong theme. `displayName` changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. `language` (`en`/`zh-CN`) keeps English as the canonical source so live switching stays reversible. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`. → [architecture-invariants#theme-skins](docs/architecture-invariants.md#theme-skins)
-**File Viewer button** (header, 1.4.1) is likewise **hidden by default**: enable under App Settings → Display → **Header Displays** → File Viewer (`showFileViewerButton`, also in the per-device `displayKeys` set). Purely client-side like the response viewer: the template ships `btn-file-viewer--hidden` and `applyHeaderVisibilitySettings()` toggles the marker class after settings load. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Display → Header Displays); cron jobs themselves are unaffected.
+**Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](docs/architecture-invariants.md#foldable-settings-identity)
-**Gesture control** (the camera hand-tracking overlay) is **opt-in, default OFF**, under App Settings → Display → **Input** (`gestureControlEnabled`). `CODEMAN_GESTURE=1` makes the feature _available_ on the instance (CSP widening + `/gesture/` assets) and sets `window.__codemanGestureAvailable` (the Input section only shows when set); the overlay bundle is injected by `renderIndexHtml` **only when the setting is enabled**, so that method is `async` and reads `settings.json` via `readSettings(true)` — the `true` forces a **fresh** read (bypassing the 2s `_settingsCache`), because a post-save reload happens within that TTL and the cached value would otherwise render the pre-toggle state. Toggling the setting reloads the page (the bundle is render-injected).
+**WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](docs/architecture-invariants.md#webgl-renderer-toggle)
-**Gesture-control source lives in-repo** at `packages/gesture-control/` (workspace package `codeman-gesture-control`, was the standalone `Ark0N/codeman-gesture-control` repo). The transport-agnostic core is `src/gesture/*` (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); `src/codeman/entry.ts` is the Codeman _consumer_ that maps grab/drag/drop onto real `.session-tab`/toolbar buttons and is the bundle entry. **Edit there, then run `npm run build:gesture`** (`scripts/build-gesture-bundle.mjs` → esbuild bundles `entry.ts`, MediaPipe JS included, into `src/web/public/gesture/gesture-codeman.js`) and **commit the regenerated bundle** — the committed bundle is what dev/`tsx` serves (no bundler at runtime), and `scripts/build.mjs` reruns the same step so prod always reflects current source. The MediaPipe **wasm + model** are NOT bundled — loaded at runtime from same-origin `/gesture/wasm` + `/gesture/gesture_recognizer.task`, fetched by `scripts/fetch-gesture-assets.mjs` (gitignored, see Gotchas). `entry.ts` mounts `window.__codemanGesture = new GestureBridge()` idempotently at module-eval. A standalone vite playground (`npm run dev` in the package — fake tabs, no Codeman) lets you iterate on gesture _feel_ in isolation. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision` in `packages/gesture-control/package.json`.
+**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), image popups (3000), local echo overlay (7).
-**Theme skins** (App Settings → Display): the `skin` setting selects a palette via a `data-skin` attribute on ``. Values: `daylight-blue` (default), `daylight-green`, `og` (OG Codeman). CSS lives under `[data-skin="…"]` blocks in `styles.css`. To avoid a flash-of-wrong-theme, an **inline pre-paint script** in `index.html` (``) reads `localStorage['codeman:skin']` and sets `data-skin` before first paint; `settings-ui.js` `applySkin()` applies it live on save (sets `html[data-skin]` + `window.__codemanSkin`, syncs the standalone `codeman:skin` key with the settings blob, and calls terminal-ui.js `applyTerminalSkin()` to re-theme live terminals). `skin` is a **per-device/client-only** setting — it's destructured OUT of the server payload (settings-ui.js, alongside `localEchoEnabled`/`cjkInputEnabled`/`extendedKeyboardBar`), so it does NOT sync across devices.
-
-**Custom branding + UI language** (App Settings → Display → Branding & Language): `displayName` is schema-validated (trimmed, 1–40 chars), server-synced, and changes user-facing browser branding/window titles only — NEVER rename npm package/CLI/API/storage/CSS/protocol identifiers. `language` is a per-device `en`/`zh-CN` display key, stripped from the server payload. `i18n.js` keeps English as the canonical source/fallback, observes newly inserted application DOM for dynamic copy, preserves source strings so live EN↔ZH switching is reversible, and skips terminal/response/file/session-name/user-content surfaces. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`.
-
-**Foldable settings identity**: responsive layout remains width-driven through `MobileDetection.getDeviceType()`, but the localStorage namespace/defaults use `MobileDetection.isHandheldDevice()` so an Android foldable keeps `codeman-app-settings-mobile` after unfolding past the desktop breakpoint. The stable handheld check prefers explicit phone/tablet/desktop UA tokens, then `navigator.userAgentData.mobile`; Android WebView is covered by the `Mobile` UA fallback. Do not switch per-device settings namespaces from instantaneous viewport width — a posture-triggered WebView reload would lose opt-in UI such as `showResponseViewer` and `extendedKeyboardBar`. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`.
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
-**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active). Rebindable via the registry (see Command palette above).
+**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active). Rebindable via the registry.
### Security
-**Full model: [`docs/security-architecture.md`](docs/security-architecture.md)** — network binding, auth pipeline, the tunnel caveat, file-serving hardening, supply-chain, instance isolation, and recommended secure setups.
+**Full model: [`docs/security-architecture.md`](docs/security-architecture.md)** (network binding, auth pipeline, the tunnel caveat, file-serving hardening, supply-chain, instance isolation, recommended setups). **Layer-by-layer detail with the history behind each: [architecture-invariants#security-layers](docs/architecture-invariants.md#security-layers).**
-| Layer | Details |
-| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME` (defaults to `admin`) / `CODEMAN_PASSWORD` env vars. Active only when `CODEMAN_PASSWORD` is set (`middleware/auth.ts`) |
-| **Network bind** | Defaults to `127.0.0.1` (loopback). A non-loopback bind (`--host`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **starts but warns loudly** (0.9.0; was fail-closed in COD-29/#107). `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges the warning. Classifier: `network-auth-policy.ts` |
-| **Host guard** | Always-on Host-header allowlist blocks DNS rebinding (RCE on the default no-auth loopback install). Allows loopback, any IP literal, the bind host, `*.ts.net`/`*.trycloudflare.com`/`*.cfargotunnel.com`, the active managed tunnel, and `CODEMAN_ALLOWED_HOSTS`. ⚠️ **Custom reverse-proxy domains are rejected** unless added via `CODEMAN_ALLOWED_HOSTS=host,.suffix`. `registerHostGuard` in `server.ts`; policy in `network-auth-policy.ts` (`buildHostPolicy`/`isAllowedRequestHost`/`isAllowedRequestOrigin`) |
-| **CSRF / Origin** | Always-on cross-site Origin guard rejects state-changing requests from foreign origins (covers self-update, session create/input, settings/tunnel toggles). **A missing Origin is allowed** so curl/CLI and Claude Code hooks keep working. The global body parser keeps `text/plain` RAW (no auto-JSON-parse, which had enabled simple-request CSRF); `/api/crash-diag` self-parses. WebSocket upgrade validates Origin+Host (anti-CSWSH) in `ws-routes.ts`. Added in `c669518` (closes 2026-06-09 review CRITICALs) |
-| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
-| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
-| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
-| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted |
-| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) |
-| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`) |
-| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
+| Layer | The rule |
+| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME` (default `admin`) / `CODEMAN_PASSWORD`. Active only when `CODEMAN_PASSWORD` is set (`middleware/auth.ts`) |
+| **Network bind** | Defaults to loopback. Non-loopback without a password starts but warns loudly. Classifier: `network-auth-policy.ts` |
+| **Host guard** | Always-on Host-header allowlist blocking DNS rebinding. ⚠️ **Custom reverse-proxy domains are rejected** unless added via `CODEMAN_ALLOWED_HOSTS=host,.suffix` |
+| **CSRF / Origin** | Always-on cross-site Origin guard on state-changing requests. **A missing Origin is allowed** so curl/CLI and hooks keep working. ⚠️ The body parser keeps `text/plain` RAW; auto-JSON-parsing it enabled simple-request CSRF |
+| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
+| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
+| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR and hook-secret have separate buckets, so neither can lock out login |
+| **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) |
+| **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) |
+| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`) |
+| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
+
+**Security-relevant env vars**: `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS=1` (opt-in hooks-only listener on the docker bridge gateway).
### SSE Event Registry
-148 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend), incl. `docker:exportComplete`/`exportFailed`/`importComplete` and `docker:imageBuildStarted`/`imageBuildProgress`/`imageBuildComplete`/`imageBuildFailed`. Both must be kept in sync.
+148 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync** — they are currently exactly in sync, and the backend file's `@fileoverview` carries the per-category breakdown.
### API Routes
-~190 handlers across 20 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (32, incl. `GET /api/sessions/unified`, `POST /api/sessions/:id/pin`, `PUT /api/session-order`), orchestrator (10), cases (27, incl. remote hosts CRUD + remote case-link, docker hosts CRUD + `docker-link` + `docker-quickcreate` + export/import + `docker-exports`), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), admin (8, multi-user `/api/admin/users*` incl. per-user case folders), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), me (2, `/api/me` + password), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
+~190 handlers across 20 route files in `src/web/routes/`: system (45), sessions (32), cases (27), files (14), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
@@ -270,15 +271,16 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
- **API endpoint**: Types in `src/types/` domain file, route in `src/web/routes/*-routes.ts`. Return the `ApiResponse` envelope (`{ success: true, data }`; errors via `createErrorResponse()` with proper status code). Validate with Zod schemas in `schemas.ts`.
- **SSE event**: Add to `src/web/sse-events.ts` + `SSE_EVENTS` in `constants.js`, emit via `broadcast()`, handle in `app.js` (`addListener(`)
- **Session setting**: Add to `SessionState`, include in `session.toState()`, call `persistSessionState()`
+- **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`).
- **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema`
-- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`
+- **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`).
- **New test**: Pick unique port (search `const PORT =`). Route tests use `app.inject()` (no port needed) — see `test/routes/_route-test-utils.ts`.
**Validation**: Zod v4 (different API from v3). Define schemas in `schemas.ts`, use `.parse()`/`.safeParse()`.
## State Files
-All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, `cronJobs`/`cronJobRuns`), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` (VAPID), `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `linked-cases.json` (linked-case registry used for case-path resolution), `remote-hosts.json` + `remote-cases.json` (remote SSH hosts/cases, COD-94), `docker-hosts.json` + `docker-cases.json` (docker hosts/cases, 1.4.0) + `docker-exports/` (portable container bundles), `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance hook secret, COD-54), `users.json` (multi-user accounts, scrypt hashes, mode 0600) + `admin-audit.jsonl` (multi-user admin action log), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
+All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `linked-cases.json`, `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
@@ -297,9 +299,9 @@ Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pa
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s. `config/vitest.ci.config.ts` = same minus the browser/perf excludes — keep the two configs in sync when changing shared options.
-**Tmux safety**: under vitest (`VITEST` env var, set automatically), `TmuxManager` no-ops ALL shell commands and becomes a pure in-memory mock — tests physically cannot create/kill/attach real tmux sessions (`IS_TEST_MODE` in `src/tmux-manager.ts`). `test/setup.ts` additionally strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME` (so auth state from the running instance can't leak into tests) and `CODEMAN_GESTURE` (a shell-exported gesture flag would flip render-injection assertions).
+**Tmux safety**: under vitest (`VITEST` env var, set automatically), `TmuxManager` no-ops ALL shell commands and becomes a pure in-memory mock — tests physically cannot create/kill/attach real tmux sessions (`IS_TEST_MODE` in `src/tmux-manager.ts`). Every docker IO path is no-op'd the same way. `test/setup.ts` additionally strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME` (so auth state from the running instance can't leak into tests) and `CODEMAN_GESTURE` (a shell-exported gesture flag would flip render-injection assertions).
-**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
+**Ports**: Pick unique ports manually, 3150+. Search `const PORT =` before adding new tests. Never 3000 (the live instance).
**Respawn tests**: Use `MockSession` from `test/mocks/index.ts` (defined in `test/mocks/mock-session.ts`). **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (136 device profiles). Browser-testing infra and practices: `docs/browser-testing-guide.md`.
@@ -317,10 +319,14 @@ Mobile screenshots: `~/.codeman/screenshots/`, accessed via `GET/POST /api/scree
## Performance & Limits
-Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired live); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
+Target: 20 sessions, 50 agent windows at 60fps. Limits live in `src/config/` (terminal 32MB, text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100), most env-overridable.
-**Memory leaks (24+ hour sessions)**: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`. Verify: `npm test -- test/memory-leak-prevention.test.ts`.
+Two constraints worth knowing before you touch them: the env-derived PTY buffer trim is **clamped to ≤75% of max**, because a trim ≥ max would disable `BufferAccumulator` trimming entirely and make memory unbounded; and browser xterm scrollback is a **separate hardcoded 50k** (`DEFAULT_SCROLLBACK` in constants.js), deliberately lower than tmux's 100k history because 100k per tab is a mobile-memory hazard. The settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but **inert**; only `tmuxHistoryLimit` is wired live. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history), `docs/terminal-anti-flicker.md`
+
+**Memory leaks (24+ hour sessions)**: use `CleanupManager`, clear Maps in `stop()`, guard async with `if (this.cleanup.isStopped) return`. Frontend: store handler refs, clean in `close*()`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Verify: `npm test -- test/memory-leak-prevention.test.ts`.
## Scripts & Tunnel
-Key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh [quick|named] start|stop|status|url` (quick = random trycloudflare URL, default; `named setup|enable` = fixed-hostname tunnel via `scripts/codeman-tunnel-named.service`; bare `start|stop|url` still means quick). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
+**`install.sh`** (repo root, 69KB) is the public entry point: `curl -fsSL | bash` installs Node/tmux if missing, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service. It prompts for the network binding (LAN default + password prompt) and preserves the existing binding on re-runs via `read_existing_binding()`. `install.sh update` and `install.sh uninstall` also exist; `CODEMAN_NONINTERACTIVE=1` approves system changes for automation.
+
+Other key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh [quick|named] start|stop|status|url` (quick = random trycloudflare URL, default; `named setup|enable` = fixed-hostname tunnel via `scripts/codeman-tunnel-named.service`; bare `start|stop|url` still means quick), `scripts/run-beta.sh` (isolated beta instance), `scripts/build-agent-image.mjs` (docker base image), `scripts/self-update.sh` (detached updater). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md
new file mode 100644
index 00000000..68bf377e
--- /dev/null
+++ b/docs/architecture-invariants.md
@@ -0,0 +1,167 @@
+# Architecture invariants
+
+Implementation detail extracted from `CLAUDE.md` so that file stays small enough to load into every session cheaply. Nothing here was rewritten: these are the original paragraphs, verbatim, including the version history and PR references that explain *why* each rule exists.
+
+`CLAUDE.md` keeps the short form of each rule plus a pointer to the section here. Read the pointer first; come here when you need the mechanism, the file names, or the history behind a constraint.
+
+---
+
+## Network binding and instance isolation
+
+### Default bind, and the non-loopback warning path
+
+**Default bind is loopback-only; non-loopback without a password starts but warns** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). As of **0.9.0** binding a non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **no longer refuses to start — it starts and prints a loud warning** listing the fixes (set `CODEMAN_PASSWORD`, bind loopback + tunnel/`tailscale serve`, or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` to acknowledge → terser note). Host classification is `isLoopbackBindHost()` in `network-auth-policy.ts`; the warn-vs-start logic is in `server.ts` `start()`; flags wired in `cli.ts`. ⚠️ Operational note: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so it binds **localhost only** — reach it remotely via `tailscale serve`/tunnel to `127.0.0.1`, or add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → `127.0.0.1`) but NOT by a browser hitting the box's LAN IP. Auth user defaults to `admin`. **Installer note** (1.8.x, `install.sh`): interactive installs now PROMPT for the binding, defaulting to LAN (`0.0.0.0`) with a required password prompt (skipping the password needs an explicit confirm and prints a loud warning); non-interactive installs keep loopback unless `CODEMAN_HOST` is preset, and re-runs/updates preserve the EXISTING binding (`read_existing_binding()` parses the current systemd unit / launchd plist). The server binary's own default is unchanged. **Full model: `docs/security-architecture.md`.**
+
+### Instance isolation and the multi-instance attach danger
+
+**Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-` + `-L codeman-`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`.
+
+## Session launch modes
+
+### External CLI modes (OpenCode, Codex, Gemini)
+
+**External CLI modes (OpenCode, Codex, Gemini)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All three modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM).
+
+### Remote sessions over SSH
+
+**Remote sessions (SSH)**: Sessions can run the agent inside a durable `tmux -L codeman-remote new-session -A` **on a remote host** so it survives the SSH drop (COD-104), and can also **discover + attach** to `codeman-*` sessions another Codeman launched there — attached (`owned:false`) sessions **detach, never kill** on tab close (COD-105). **Shared/collaborative** (COD-106): remote set-options are scoped per-session (never `-g`) and `window-size latest` lets multiple clients attach the same session at different viewports without clamping to the smallest; a client count surfaces a "shared · N" badge. **Auto-reconnect** (COD-108): a bounded-backoff watcher re-establishes a dropped remote session's local ssh pane and reattaches the still-running durable remote tmux (kill-switch `remoteAutoReconnect`, default ON); the pure pieces (backoff schedule, per-session reconnect state, `decideReconnect` eligibility) live in `src/remote-reconnect.ts` (tests: `test/remote-auto-reconnect.test.ts`), while `tmux-manager.ts` owns the live pane probe + timers. Owned sessions propagate `kill-session` to the remote on close; non-owned never do. ⚠️ Command-injection surface (COD-107): all ssh command lines flow through the single shell-safe `buildSshConnectionArgs()` — every user field (`-J jumpHost`, `-i identity`, `-o`) is `shellescape`d; never hand-build an ssh line elsewhere. Full design: `docs/remote-sessions.md`.
+
+### Remote SSH cases
+
+**Remote SSH cases** (COD-94/#145): cases can point at a **remote host** (`~/.codeman/remote-hosts.json` + `remote-cases.json` via `src/remote-hosts.ts`; CRUD under `/api/cases` — cases route file). A remote session launches a LOCAL tmux pane running `ssh ` that creates a durable REMOTE tmux session on a **dedicated socket** `-L codeman-remote` with name `codeman-ssh-` — deliberately failing the remote Codeman's `SAFE_MUX_NAME_PATTERN` so a Codeman instance on the target host never adopts it; no `-g` global tmux options are set remotely. `remotePath`/`identityFile` are schema-guarded against shell injection (backticks/`$` rejected — same approach as `extraSshOptions`); remote tmux availability is probed via `checkRemoteTmuxAvailable()` in quick-start (ssh args carry `-o ConnectTimeout=10`). Remote claude defaults to `exec claude --dangerously-skip-permissions`; per-host `commands.*` override. Session kill best-effort kills the remote tmux too. `SessionState.remote`/`MuxSession.remote` round-trip through recovery (`restoreMuxSessions` passes `remote` back into the Session constructor). ⚠️ Run flows must route remote cases through `POST /api/quick-start` (which resolves the remote case and skips LOCAL CLI availability gates) — `POST /api/sessions` stat-validates `workingDir` locally and has no `caseName`. `envOverrides`/`effort`/`modelOverride`/`codexConfig`/`geminiConfig` are rejected for remote quick-starts (not silently dropped). UI: Create Case modal → Remote tab. Tests: `test/remote-hosts.test.ts`, `test/remote-ssh-options.test.ts`.
+
+### Docker cases
+
+**Docker cases** (shipped 1.4.0; user guide `docs/docker-cases.md`, design `docs/docker-cases-plan.md`): a case can point at a **container** instead of a local/remote path, and any of the five CLI backends runs INSIDE it. Like remote-SSH, it is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`** (`SessionMode` is unchanged). Storage `~/.codeman/docker-hosts.json` + `docker-cases.json` via `src/docker-hosts.ts` (direct mirror of `remote-hosts.ts`: `readDockerHosts`/`readDockerCases`, `toSessionDocker`, `dockerDisplayPath`, and the PURE builders `buildDockerBaseArgs`/`buildDockerCreateArgs`/`containerApiUrl`/`hostGatewayAlias`/`dockerConfigHash`). CRUD `/api/docker-hosts` + `/api/cases/docker-link`, plus **one-click** `/api/cases/docker-quickcreate` (Create New "Run in Docker" checkbox → case folder in `CASES_DIR` + auto-provisioned shared `default` host + auto-start a session inside; an expandable Template picker Small/Medium/Large/GPU or any override creates a per-case `q-` host), and export/import (`/api/docker-cases/:name/export`, `/api/docker-cases/import`, `GET/DELETE /api/docker-exports`) — all in `case-routes.ts`. Run flows route through `POST /api/quick-start` like remote (session-routes.ts docker branch, skips LOCAL CLI-availability gates). **Launch model**: exactly one long-lived container **per case** (`codeman-case-`, PID1 `sleep infinity` under `--init`); a LOCAL tmux pane runs `docker exec -it` into a **durable in-container tmux** on dedicated socket `-L codeman-docker`, session `codeman-dkr-` (deliberately fails `SAFE_MUX_NAME_PATTERN` so a Codeman running INSIDE the container never adopts it, exactly like remote's `codeman-ssh-`). Builders `buildDockerLaunchCommand`/`buildDockerKillCommand` in `tmux-manager.ts` (image-check → `docker inspect||create` → start → exec, all idempotent). The container is **shared by all sessions of the case**: `buildDockerKillCommand` kills ONLY that session's in-container tmux session, NEVER `docker stop` while siblings remain; `docker rm -f` happens only on case-delete (plus an instance-scoped boot reaper keyed on the `codeman.instance` label). **Two-layer durability/resume** (the central design point): (1) Codeman-PROCESS restart with the container still up → `tmux new-session -A` reattaches the SAME live agent (paneCommand ignored); (2) container stop/reboot/OOM → inner tmux is gone, so the re-run pane command resumes the conversation from the bind-mounted transcript: claude mode pins a DETERMINISTIC conversation id via `claudeDockerPaneCommand()` (`tmux-manager.ts`) — fresh launch `claude --session-id || claude --resume ` (a duplicate `--session-id` exits 1 "already in use", so the fallback RESUMES after a container stop; verified CLI behavior), explicit resume `--resume || --session-id ` so a stale id never dead-panes (leading `exec ` is stripped — an exec'd first branch could never fall back); codex `resume ` / gemini `--resume` keep `appendResumeFlag`. The resume id rides `resumeSessionId` through create/respawn options and persists on `DockerCase.lastClaudeSessionId` via `persistDockerCaseClaudeSessionId()` (written at quick-start launch, and again on hook/last-response conversation-id adoption so post-`/clear` switches track; seeded back when `resumeOnStart`, default true); `-A` makes the pane command self-selecting (inert on reattach, active only when tmux was re-created). **Config drift** (`dockerConfigHash` → `codeman.confighash` label): quick-start compares via `checkDockerConfigDrift()` and REFUSES a drifted launch with `CONFLICT`; the UI confirm calls `POST /api/docker-cases/:name/recreate` (refused while case sessions are live) which `docker rm -f`s so the next launch recreates with the new config — host config edits actually take effect. **Workspace** is a REAL host dir bind-mounted at the SAME absolute path (mirror, `dst==src`), so `Session.workingDir = hostWorkspacePath` keeps file-routes/attachments/watchers on real host bytes AND the in-container transcript projHash matches the host so subagent/workflow correlation (and thus resume-id capture) works; `resolveMuxAttachCwd` returns `/tmp` for docker (the local pane only runs `docker exec`). **Creds** arrive commit-safe and ISOLATED (1.4.1; replaced the whole-dir RW mounts that let in-container CLIs write refreshed tokens/state back to the host): shared RW across the boundary is ONLY what host-side reads/resume need (`~/.claude/projects` transcripts; codex `sessions/` + `history.jsonl` for response-viewer/`codex resume`); everything else is SEEDED (RO mount, copied into container HOME once at launch via `[ -e ] || cp`; the container refreshes its own copy and never writes back): `~/.claude.json` is merged through `buildSeamlessClaudeConfig()` (forces `hasCompletedOnboarding` + theme + workspace trust, so no login wizard/theme picker/trust prompt inside the container), plus `.claude/{.credentials.json,settings.json,stats-cache.json}`, plus whole-dir seeds for `~/.gemini`/`~/.config/{gcloud,opencode}` (`resolveDockerClaudeArtifacts`/`resolveDockerCredentialArtifacts` in `docker-hosts.ts`). Bind mounts are physically excluded from `docker commit`, so exports stay secret-free; API-key CLIs get exec-time NAME-ONLY `--env OPENAI_API_KEY` (no `=value`); the SEALED profile is `mountCredentials:false` + `network:none`. NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket. **Hardening** on every create: `--cap-drop ALL`, `--security-opt no-new-privileges`, `--pids-limit`, `--memory`==`--memory-swap`, non-root via `--user :0` (Linux, GID 0 for writable HOME) / `--userns=keep-id` (podman rootless) / baked uid (Docker Desktop), `--pull=never`, `--init`. Base image `codeman/agent:base` is BUILT LOCALLY from `docker/agent.Dockerfile` (node22 + tmux + claude/codex/gemini/opencode, OpenShift arbitrary-uid HOME, `C.UTF-8` locale so tmux/Ink render real box-drawing glyphs; Codeman also sets `LANG`/`LC_ALL` at run time for containers built before that line) via `scripts/build-agent-image.mjs` OR **auto-built on first use** (1.4.1: `ensureAgentBaseImage()` in `docker-hosts.ts`; idempotent + concurrency-safe, only the DEFAULT image ref is ever auto-built, `--pull=never` stays absolute; build output streams over SSE `docker:imageBuildStarted`/`imageBuildProgress`/`imageBuildComplete`/`imageBuildFailed`, and quick-create returns `imageBuilding:true` while the first launch awaits the gate); tmux-in-image is a HARD gated prerequisite (`checkDockerTmuxAvailable`), never a silent bare-exec fallback. **Hooks + model**: the workspace-scaffolding block DOES run for docker (writes `.claude/settings.local.json` + the CLAUDE.md scaffold into the real host dir), so `modelOverride` works via `settings.local.json` — it is a `QuickStartSchema` field applied for local AND docker quick-starts (`updateCaseModel`), sent by the frontend docker run path (the one deliberate difference from remote, which rejects it); `effort`/`envOverrides`/`codexConfig`/`geminiConfig`/`openCodeConfig` stay rejected. In-container hook curls hit `containerApiUrl(process.env.CODEMAN_API_URL, engine)` (swaps ONLY the hostname to the gateway alias, preserving scheme+port so prod HTTPS still works); the host guard allowlists both `host.docker.internal`/`host.containers.internal` (`DOCKER_HOST_GATEWAY_ALIASES` in `network-auth-policy.ts`). ⚠️ On a **loopback-only** bind (the prod default) a container cannot reach 127.0.0.1, so in-container hooks fire ONLY when `CODEMAN_DOCKER_BRIDGE_HOOKS=1` — an opt-in SECOND listener on the docker bridge gateway (`_startDockerBridgeHooksListener` in `server.ts`; gateway auto-detected via `detectDockerBridgeGateway`, or set `CODEMAN_DOCKER_BRIDGE_HOST`) that serves ONLY the hook endpoints (403 for any other path) into the same secret-gated pipeline; otherwise idle detection falls back to output-based through the docker-exec PTY. Container-set `CLAUDE_CODE_TMPDIR` keeps claude launching regardless of workspace path. `SessionState.docker`/`MuxSession.docker` round-trip through recovery. Every docker IO path is `IS_TEST_MODE` (VITEST) no-op'd; the pure builders are unit-tested. **Export/import** (`src/docker-export.ts`): full-image (`docker commit` + `save | gzip` + workspace tar + manifest) or workspace-only → one portable `~/.codeman/docker-exports/-.codeman-container.tgz`; import validates per-member sha256, traversal-guards the workspace tar, `docker load`s + quarantine-retags the image (`codeman/imported-:`, never overwriting a local tag); a `saveImageToTar` stream `pipeline` avoids truncation. **GPU** passthrough (`gpus` → `--gpus`, needs the NVIDIA container toolkit) and **elastic disk** (no `--storage-opt` cap, so container storage grows with data). SSE `docker:exportComplete`/`exportFailed`/`importComplete` (both registries). **UI** in `session-ui.js`: Create Case **Docker** tab (collapsed/compact form since 1.4.1), the one-click checkbox + Template picker, short `(docker)` case-menu tags, and a Manage-tab Export button; docker AND remote sessions name their tabs `w-` via the shared `_nextCaseSessionStartNumber()` so all tabs follow one naming convention. Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/docker-export.test.ts`, `test/network-host-guard.test.ts`.
+
+## Session data and lifecycle
+
+### Input delivery and WS resilience
+
+**Input**: `session.writeViaMux()` for programmatic/curl input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`).
+
+### Auto-resume on usage limit
+
+**Auto-resume on usage limit** ("token pause" control, opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit ("5-hour limit reached ∙ resets 8pm" and all 1.0.x–2.1.x variants), `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time from cleaned output; `SessionAutoOps` arms a timer for reset+2min, then sends Esc (dismisses the rate-limit dialog) + `continue`. Still-limited responses re-arm the loop (5-min retry on stale times); a `working` transition cancels it. Claude-mode only (detection rides `_processExpensiveParsers`). Persists/recovers via `SessionState.autoResumeEnabled`/`autoResumeAt`; respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected` — prevents `/clear` from wiping the paused conversation). Endpoint: `POST /api/sessions/:id/auto-resume`; SSE: `session:limitPauseScheduled`/`limitResume`/`limitResumeCancelled`. Tests: `test/usage-limit-patterns.test.ts`, `test/session-auto-resume.test.ts`.
+
+### Plan-usage chip (statusLine telemetry)
+
+**Plan-usage chip** (statusLine telemetry, opt-in `showPlanUsageLimits`, default OFF): Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is _ours_, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (so injecting our statusLine doesn't blank the in-terminal footer). `plan-usage-latest.ts` holds the process-wide last value, replayed in the SSE init snapshot (`getLightState`) so the header chip (`#planUsageChip`, toggled by `showPlanUsageLimits` in settings-ui.js) renders immediately on page load / reconnect without per-browser localStorage. Claude-mode only. **Distinct from auto-resume** (which reacts to the limit _message_; this proactively shows the live %). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`.
+
+### Cron jobs
+
+**Cron (cron-style `CronJob`s)**: saved, named jobs with a recurring schedule (`once`/`interval`/`daily`/`weekly`), enable/disable, Run Now, next-run calc, and per-job run history (`CronJobRun`). ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded autonomous loop) — the two never interact; the legacy concept keeps the `Scheduled*` names, the recurring-job feature is `Cron*`. `CronService` (`src/cron/cron-service.ts`) owns CRUD + the 30s background due-tick (`tickDueJobs`, registered via `cleanup.setInterval` in `server.ts`; `init()` recomputes nextRunAt on boot) and **reuses the existing session layer** (create → `addSession` → `setupSessionListeners` → `startInteractive`/`startShell` → prompt via `writeViaMux`/`write`) rather than rebuilding tmux logic. Next-run math is pure/unit-tested in `cron-time.ts` (SERVER-LOCAL timezone for daily/weekly). Dup-launch guard = `lastDueKey` (jobId:fireTime); schedule is advanced BEFORE launch so a slow launch can't re-trigger. `once` jobs self-disable after firing (`completedOnce`). Persisted via `AppState.cronJobs`/`cronJobRuns` (StateStore accessors). Routes `/api/cron/jobs*` + `/api/cron/runs` (`cron-routes.ts`, `CronPort`); schema `CronJobSchema` (cross-field `superRefine`; the `.partial()` update schema does NOT re-run it); SSE `cron:*`. Frontend `cron-ui.js` (#cronModal). Claude/shell/opencode/codex/gemini agent types. Tests: `test/cron-time.test.ts`, `test/cron-service.test.ts`. Design: `docs/cron-discovery.md`.
+
+### Unified session list and Session Manager
+
+**Unified session list** (COD-160/#139): `GET /api/sessions/unified?limit=&q=` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows are keyed by conversation UUID and folded into their owning session via a `claudeSessionId → Codeman id` alias map (resumed//clear-respawned sessions must not appear twice); lifecycle name/mode resolution is first-seen-wins (the log returns entries NEWEST-first). No terminal buffers in the response (unlike `/api/sessions`). Consumed by the Cmd+K Session Manager (#146). Session Manager polish (COD-162/#157, 1.6.0): **pinning** via `POST /api/sessions/:id/pin` (`session:pinned` SSE; killing a pinned session demotes it to a lightweight stopped record that stays visible/resumable, and cleanup skips pinned records); **cross-device tab order** via `PUT /api/session-order` (`session:orderChanged` SSE, persisted in `state.json`; pure `normalizeSessionOrder`/`mergeSessionOrder` in `src/session-order.ts`: pushing device wins, server-only ids fall to the end, never dropped); resume from the manager keeps the original session name (COD-143); `firstPrompt` is backfilled for sessions whose id != transcript UUID and the most recent prompt (`lastPrompt`) is shown + searched (COD-140/145).
+
+### Full-scrollback replay
+
+**Full-scrollback replay** (COD-164/#148): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). Only the FIRST buffer load after a page load requests `full=1` (one-shot `_initialFullBufferLoad` flag in app.js); tab switches keep the cheap `?tail=` visible-frame path. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`.
+
+### Run launch synchronization
+
+**Run launch synchronization**: the main Run entrypoint in `session-ui.js` holds an in-flight lock and disables `#runBtn` for the whole launch (at least 500ms), so a double click cannot create duplicate sessions with the same `w-` name. A successful create/quick-start also calls `_ensureCreatedSessionVisible()` before `selectSession()`: local creates use the response's full session snapshot; quick-start modes fetch `GET /api/sessions/:id` only when `session:created` SSE has not already populated the map. The normal `_onSessionCreated()` handler remains the idempotent upsert, so POST-first and SSE-first ordering both produce one immediately-rendered tab. Tests: `test/run-mode-ui.test.ts`.
+
+### Circuit breakers: Ralph and PTY-exit
+
+**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`. **Distinct: PTY-exit breaker** (COD-115/118/#147, `session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits (crash loops on attach), blocks further auto-restarts, broadcasts SSE `session:respawnBreakerTripped` + push (in `PUSH_EVENT_MAP`). Reset ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive` (sent by the user-facing restart control) — the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. Sessions also scrub inherited `TMUX`/`TMUX_PANE` env so Codeman-in-tmux doesn't nest. Tests: `test/respawn-pty-breaker.test.ts`.
+
+## Features
+
+### Attachments
+
+**Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups _identical_ in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals. **Codex generated artifacts** (COD-166/#150, `generated-artifact-attachments.ts`): codex-mode sessions ALSO scan (ANSI-stripped) output for `Saved to: file:///…` lines and surface those files as attachment cards with a relaxed trust policy — the allow decision runs on the **realpath-resolved** path against `os.homedir()`-anchored `~/.codex` marker dirs (symlink escapes fall back to force-confinement); gated to `mode === 'codex'` only (`source` is a REQUIRED param through the listener-deps chain — a dropped arg here silently kills the feature). Image thumbnails pass through jpg/jpeg/gif/webp.
+
+### Ultracode and workflow-run visualization
+
+**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects///workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
+
+### Cross-session search
+
+**Cross-session search** (COD-113/#133): `GET /api/search?q=&types=&limit=` federates an **in-memory** search across all live sessions — session metadata (name/workingDir/id), run-summary events, and per-session attachment-history file entries (workspace-relative path only; the server-private `externalPath` is never read). Pure core `searchSources()` in `search-service.ts` (substring-matches with hard per-type caps — no regex, so no ReDoS; no filesystem reads, so no traversal); `harvestSources()` in `search-routes.ts` gathers the in-memory sources. `SearchQuerySchema` bounds `q` (1–200), allowlists `types` (`session,event,file`), clamps `limit` (1–60). Returns the `{success,data}` envelope. Frontend: history-panel search box in `terminal-ui.js`. Types: `src/types/search.ts`.
+
+### Away digest
+
+**Away digest** (COD-41/#136): `GET /api/away-digest?range=&since=&until=&lastViewed=` aggregates "what happened while you were away" from the lifecycle log + run-summary events + live sessions + daily token stats + recently-completed subagents into needs-attention/completed/still-running/idle/informational sections. Pure aggregator in `web/away-digest.ts` (`resolveAwayDigestRange()` validates the window — `since-last-visit`/`1h`/`today`/`24h`/`custom`, server-local TZ; `buildAwayDigest()` classifies). Header-button modal in `panels-ui.js` (button hidden on phones — regression-guarded). ⚠️ Returns `{success:true,digest}` (a legacy raw-ish shape, consistent with the other raw GET handlers in `system-routes.ts` — `{entries}`/`{config}`/`{files}`/`getSystemStats()`); frontend + tests read `.digest`. Subagent lookback is a fixed 60-min window regardless of range.
+
+### Self-update
+
+**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. Supervisors: `systemd` (user unit), `launchd` (GUI LaunchAgent, gui-domain kickstart), `launchd-daemon` (KeepAlive system LaunchDaemon on headless Macs — restarts rootlessly by killing the server PID and letting launchd respawn it; detected only when the daemon is bootstrapped AND KeepAlive), else `none` → "restart manually" message; on next boot a manual-restart status auto-completes when the running version matches the target. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
+
+### Multi-user mode
+
+**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default; shipped 1.5.0 via PR #161, design `docs/multi-user-plan.md`): named users with individually scrypt-hashed passwords in `~/.codeman/users.json` (via `src/user-store.ts`: atomic 0600 write, short-TTL cache, SERIALIZED read-modify-write so a fire-and-forget `touchLastLogin` can't clobber a concurrent route write, last-admin invariants). Gated everywhere by `isMultiUserMode()` (`src/config/multiuser.ts`); when OFF, behavior is byte-identical to single-user (all scoping helpers short-circuit). ⚠️ **Not a security boundary at the agent layer** — every session still runs as the SAME OS account; this separates WORKSPACES, it does not sandbox users (Docker cases are the isolation story). Auth: a PARALLEL async branch in `middleware/auth.ts` (single-user branch untouched) verifies `username:password` against the store, mints identity-carrying cookies (`AuthSessionRecord` gains `username`/`role`/`mustChangePassword`), decorates `req.authUser` (Fastify augmentation; single-user leaves it undefined and the ownership helpers default to a synthetic admin), enforces a per-username failure bucket + the `mustChangePassword` lockbox. Ownership threads through `Session.owner` (stamped from `req.authUser`/`job.owner` at every `new Session()`, round-tripped via `MuxSession.owner` on recovery); `findSessionOrFail(ctx,id,req)` does a NOT_FOUND owner check; list endpoints + `getLightState` + SSE (`deriveSseHint` routes session-scoped events by owner, fail-closed; machine-level + host-plan telemetry admin-only) + WS + search + file-preview all filter by owner. §6.3 permission policy: non-granted users are forced to `--permission-mode auto` (via `resolveClaudeModeForUser` at all spawn sites, incl. one-shots because `buildPromptArgs` now respects the session mode), and shell mode / cron `launchCommand` require the `canBypassPermissions` grant. Cases live in per-user `~/codeman-users//cases` (`resolveCasesDir`); a non-admin's `workingDir` is realpath-confined there; host CRUD is admin-only. Admin API `src/web/routes/admin-routes.ts` (`/api/admin/users*`, one-time passwords, audit log `admin-audit.jsonl`) + self-service `/api/me` + `/api/me/password` (`me-routes.ts`); frontend `public/admin-ui.js` (identity boot, change-password modal + interceptor, admin Users tab, and the header **Admin Panel** button `#adminPanelBtn`: ships `btn-admin-panel--hidden`, revealed for admins in multi-user mode, phone-hidden via mobile.css; opens the full Admin Panel modal with user CRUD, per-user permission toggles, and case-folder list/delete via `GET/DELETE /api/admin/users/:username/cases[/:caseName]`; live-refreshes on SSE `admin:usersChanged`, wired in app.js). CLI `codeman users add|passwd|list|rm`. Per-user session cap via `sessionCapacityState`/`sessionCapacityMessage`. Tests: `test/user-store.test.ts`, `test/multiuser-auth.test.ts`, `test/ownership-scoping.test.ts`, `test/admin-routes.test.ts`, `test/admin-ui.test.ts`.
+
+## Frontend
+
+### Command palette and shortcut registry
+
+**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over.
+
+### WebGL renderer toggle
+
+**WebGL renderer toggle** (#140, `webglRendererEnabled`): per-device (`displayKeys` set, stripped from the server payload — NOT in `SettingsUpdateSchema`, which is `.strict()`). The GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads; it's cleared only by an explicit OFF→ON save transition or `?webgl=force` (`shouldSkipWebGL` in constants.js). `?nowebgl` still forces the DOM renderer per-load.
+
+### Header button visibility (multi-monitor, response viewer, file viewer, cron)
+
+**Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`.
+**Response-viewer (eye) button** (header) is likewise **hidden by default** — enable under App Settings → Display → **Response Viewer** (`showResponseViewer`). Works for Claude AND Codex sessions (#152): Codex last-responses are located via a 4-layer rollout resolution under `CODEX_HOME` (history pin → originator match → resume-UUID → cwd fallback with other-pane exclusion), with injected-context filtering and event/legacy dedup — tests in `test/routes/session-routes-codex-last-response.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices.
+**File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Display → **Header Displays** → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Display → Header Displays); cron jobs themselves are unaffected.
+
+### Gesture control: the setting
+
+**Gesture control** (the camera hand-tracking overlay) is **opt-in, default OFF**, under App Settings → Display → **Input** (`gestureControlEnabled`). `CODEMAN_GESTURE=1` makes the feature _available_ on the instance (CSP widening + `/gesture/` assets) and sets `window.__codemanGestureAvailable` (the Input section only shows when set); the overlay bundle is injected by `renderIndexHtml` **only when the setting is enabled**, so that method is `async` and reads `settings.json` via `readSettings(true)` — the `true` forces a **fresh** read (bypassing the 2s `_settingsCache`), because a post-save reload happens within that TTL and the cached value would otherwise render the pre-toggle state. Toggling the setting reloads the page (the bundle is render-injected).
+
+### Gesture control: the source package
+
+**Gesture-control source lives in-repo** at `packages/gesture-control/` (workspace package `codeman-gesture-control`, was the standalone `Ark0N/codeman-gesture-control` repo). The transport-agnostic core is `src/gesture/*` (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); `src/codeman/entry.ts` is the Codeman _consumer_ that maps grab/drag/drop onto real `.session-tab`/toolbar buttons and is the bundle entry. **Edit there, then run `npm run build:gesture`** (`scripts/build-gesture-bundle.mjs` → esbuild bundles `entry.ts`, MediaPipe JS included, into `src/web/public/gesture/gesture-codeman.js`) and **commit the regenerated bundle** — the committed bundle is what dev/`tsx` serves (no bundler at runtime), and `scripts/build.mjs` reruns the same step so prod always reflects current source. The MediaPipe **wasm + model** are NOT bundled — loaded at runtime from same-origin `/gesture/wasm` + `/gesture/gesture_recognizer.task`, fetched by `scripts/fetch-gesture-assets.mjs` (gitignored, see Gotchas). `entry.ts` mounts `window.__codemanGesture = new GestureBridge()` idempotently at module-eval. A standalone vite playground (`npm run dev` in the package — fake tabs, no Codeman) lets you iterate on gesture _feel_ in isolation. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision` in `packages/gesture-control/package.json`.
+
+### Theme skins
+
+**Theme skins** (App Settings → Display): the `skin` setting selects a palette via a `data-skin` attribute on ``. Values: `daylight-blue` (default), `daylight-green`, `og` (OG Codeman). CSS lives under `[data-skin="…"]` blocks in `styles.css`. To avoid a flash-of-wrong-theme, an **inline pre-paint script** in `index.html` (``) reads `localStorage['codeman:skin']` and sets `data-skin` before first paint; `settings-ui.js` `applySkin()` applies it live on save (sets `html[data-skin]` + `window.__codemanSkin`, syncs the standalone `codeman:skin` key with the settings blob, and calls terminal-ui.js `applyTerminalSkin()` to re-theme live terminals). `skin` is a **per-device/client-only** setting — it's destructured OUT of the server payload (settings-ui.js, alongside `localEchoEnabled`/`cjkInputEnabled`/`extendedKeyboardBar`), so it does NOT sync across devices.
+
+### Custom branding and UI language
+
+**Custom branding + UI language** (App Settings → Display → Branding & Language): `displayName` is schema-validated (trimmed, 1–40 chars), server-synced, and changes user-facing browser branding/window titles only — NEVER rename npm package/CLI/API/storage/CSS/protocol identifiers. `language` is a per-device `en`/`zh-CN` display key, stripped from the server payload. `i18n.js` keeps English as the canonical source/fallback, observes newly inserted application DOM for dynamic copy, preserves source strings so live EN↔ZH switching is reversible, and skips terminal/response/file/session-name/user-content surfaces. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`.
+
+### Foldable settings identity
+
+**Foldable settings identity**: responsive layout remains width-driven through `MobileDetection.getDeviceType()`, but the localStorage namespace/defaults use `MobileDetection.isHandheldDevice()` so an Android foldable keeps `codeman-app-settings-mobile` after unfolding past the desktop breakpoint. The stable handheld check prefers explicit phone/tablet/desktop UA tokens, then `navigator.userAgentData.mobile`; Android WebView is covered by the `Mobile` UA fallback. Do not switch per-device settings namespaces from instantaneous viewport width — a posture-triggered WebView reload would lose opt-in UI such as `showResponseViewer` and `extendedKeyboardBar`. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`.
+
+## Security layers
+
+### Layer-by-layer detail
+
+| Layer | Details |
+| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME` (defaults to `admin`) / `CODEMAN_PASSWORD` env vars. Active only when `CODEMAN_PASSWORD` is set (`middleware/auth.ts`) |
+| **Network bind** | Defaults to `127.0.0.1` (loopback). A non-loopback bind (`--host`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **starts but warns loudly** (0.9.0; was fail-closed in COD-29/#107). `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges the warning. Classifier: `network-auth-policy.ts` |
+| **Host guard** | Always-on Host-header allowlist blocks DNS rebinding (RCE on the default no-auth loopback install). Allows loopback, any IP literal, the bind host, `*.ts.net`/`*.trycloudflare.com`/`*.cfargotunnel.com`, the active managed tunnel, and `CODEMAN_ALLOWED_HOSTS`. ⚠️ **Custom reverse-proxy domains are rejected** unless added via `CODEMAN_ALLOWED_HOSTS=host,.suffix`. `registerHostGuard` in `server.ts`; policy in `network-auth-policy.ts` (`buildHostPolicy`/`isAllowedRequestHost`/`isAllowedRequestOrigin`) |
+| **CSRF / Origin** | Always-on cross-site Origin guard rejects state-changing requests from foreign origins (covers self-update, session create/input, settings/tunnel toggles). **A missing Origin is allowed** so curl/CLI and Claude Code hooks keep working. The global body parser keeps `text/plain` RAW (no auto-JSON-parse, which had enabled simple-request CSRF); `/api/crash-diag` self-parses. WebSocket upgrade validates Origin+Host (anti-CSWSH) in `ws-routes.ts`. Added in `c669518` (closes 2026-06-09 review CRITICALs) |
+| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
+| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
+| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
+| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted |
+| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) |
+| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`) |
+| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
+
+## Performance and limits
+
+### Buffers, uploads, and terminal history
+
+Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired live); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
+
+## Local packages and build artifacts
+
+### xterm-zerolag-input is single-source
+
+**`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs` (the `xterm-zerolag-input` esbuild step, for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
+
+## Tooling traps
+
+### Headless screenshot capture
+
+**Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — `scripts/capture-real-overview.mjs` (drives a live session in headless Chromium → overview PNG). Two traps, both observed 2026-06-14: **(1) DSF=2 doubles the console font.** xterm's WebGL renderer draws terminal glyphs at ~2× their nominal size under `deviceScaleFactor: 2`, while STILL reporting nominal cell dims (`terminal.cols`/`_renderService.dimensions.css.cell` say 8px/187cols — they lie), so it's invisible to any internal measurement and only the pixels reveal it. The HTML chrome (header/toolbar) is unaffected → ONLY the console font looks comically large. Default to **DSF=1** (script does); the image is 1× res but the font is true-to-browser. **(2) Stable filenames → stale renders.** Overwriting a fixed path (`claude-overview.png`) in place leaves OS image viewers (eog/feh) — and any HTTP client behind a long/`immutable` cache — showing the OLD render; the user reads it as "the fix didn't work". The script now mints a timestamped `claude-overview-.png` per run. ⚠️ This was a LOCAL image-viewer cache, NOT a Codeman serving bug: `file-routes` previews send `Cache-Control: no-cache` and `/api/screenshots/:name` sends none. The one real Codeman-side footgun: `server.ts` serves non-content-hashed static assets `public, max-age=31536000, immutable`, and `cacheBustAssets()` only rewrites `.js`/`.css` refs — a stable-named **image** referenced from public/ would go stale on overwrite. Reflect the per-device UI to match a real device when capturing: seed `localStorage` `codeman:skin`, `codeman-font-size`, and the desktop `codeman-app-settings` blob (the plan-usage chip is a per-device display key deleted from the server payload — a fresh browser hides it unless seeded; close side panels for a full-width terminal).
From de87c4e315f04c208c940fb88ff69ce89a5cfba2 Mon Sep 17 00:00:00 2001
From: Codeman maintainer
Date: Mon, 27 Jul 2026 02:07:28 +0200
Subject: [PATCH 07/25] docs(readme): add contributors and total-commits badges
Two live shields.io badges in the header block of both READMEs, linking to
the contributors graph and the commit history. Colors reuse the existing
palette (3b82f6, 1e3a5f) and keep the flat-square style.
Verified both endpoints render real data matching the GitHub API
(contributors: 13, commits: 1.5k against 1,460 on master) and that master
is the default branch, so the /commits/master link target is correct.
Co-Authored-By: Claude Opus 5 (1M context)
---
README.md | 2 ++
README.zh-CN.md | 2 ++
2 files changed, 4 insertions(+)
diff --git a/README.md b/README.md
index 10691cbc..febd167a 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,8 @@
+
+
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 2e749419..6ff03598 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -18,6 +18,8 @@
+
+
From cb6c25220f87720f44d251d792b0262319156c99 Mon Sep 17 00:00:00 2001
From: Codeman maintainer
Date: Mon, 27 Jul 2026 02:30:44 +0200
Subject: [PATCH 08/25] docs(readme): swap the mobile idle screenshot for an
interactive prompt
Replaces the middle cell of the Mobile-Optimized Web UI table in both
READMEs. The new shot shows an agent's multiple-choice prompt being answered
on a phone, with the touch accessory bar and bottom toolbar visible, which
demonstrates more of the mobile UI than the old idle-session capture.
Uses a dated filename per the convention the other 2026-07 images follow.
That also avoids GitHub's image cache serving the old picture, which an
in-place overwrite of mobile-session-idle.png would have risked. The old
file is left on disk so any existing external link to it keeps working.
Co-Authored-By: Claude Opus 5 (1M context)
---
README.md | 4 ++--
README.zh-CN.md | 4 ++--
.../mobile-session-question-20260727.png | Bin 0 -> 468282 bytes
3 files changed, 4 insertions(+), 4 deletions(-)
create mode 100644 docs/screenshots/mobile-session-question-20260727.png
diff --git a/README.md b/README.md
index febd167a..1c9c47ee 100644
--- a/README.md
+++ b/README.md
@@ -219,12 +219,12 @@ The most responsive AI coding agent experience on any phone. Full xterm.js termi
-
+
Landing page with QR auth
-Keyboard accessory bar
+Answering prompts by touch
Agent working in real-time
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 6ff03598..cb2d6090 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -209,12 +209,12 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
-
+
带二维码认证的登录页
-键盘配件栏
+触控回答提示
智能体实时工作中
diff --git a/docs/screenshots/mobile-session-question-20260727.png b/docs/screenshots/mobile-session-question-20260727.png
new file mode 100644
index 0000000000000000000000000000000000000000..0c2d0c8fcafd350d8ee650c14c0cd8256a0cb3bb
GIT binary patch
literal 468282
zcmY&%tr8^iRH;t(T70F^#t0t$l0<1|D$a&O_w70op^ryi_m%bP>!~DrZ2@n&;sAf>zUp
z)}}Wu&(E4aY!;`QOOB`0o4nm_mlqdTJ=|!1rlwlDQJI>UXvmX5vq0;?wIRFXpR?6?
zn;J+~y(~47o}KWnHK3h+IeoMGdVrtGNjLJl<{*m??(a%=K76Ui<80xVBf3we9KW}d
zk$v#{eE3bdMjBH!HRp2t^(mk*5C*B)i>f$qDpQMZ=_$Sw*x84)*Sn;xtURtT#YNZ2?}V1g1mfjDpg
z^da9+e@@H&68QIFH%z-mg+zb)hqOOB$)3%>RW`kqLM9PGMi`8tsm~!ktUHM9zMNp3
z1KeQ_si2a|xbX8X3lZh(ewYByO$j+)6On(>qe3lSgfL;$)j}h@UcN8ngCc>9?Q#|A
z;`&7biP8C5EtyaL=uTT!&$IJ2Q|OyvTaXYY6`&cVibpka-sJIMw8ywT*r{hUA~}&M
z0g}1%946L(Mh5Sy~z{Bfe*EB1%UX)xbwH9sH9haewG1q7M?Bs)o^v2(n9k2!~V1R~K~
zsmVuV0LwHxu$nu_N};}|A;#e}7zr!P{y81u8ibu&?4$Ihwg0tMue>~`rU^09V!bj~
zUHT4|Dn#ZdM#STF?T!jh@mx-ysxGm-3m)V|vt$?L#=o}eeFyxPf`z)GD_Nmf;qnM|
zvGUhN#6VZ-dIx#CDpx=3D+f|*N=g|j`hWiG8jCo9LTqHsUKSaJj-*8eYtk;kV=qJ!
z4De%okd6Ip6@4JcWrWq#Mk?8TzXeUWWVC{!m){@E;tV||BKXPCH)ok!@1h&`B!_khP2)E%DWcqB(w$pWHz$}dFP65kMR~w4P
z>JYX|{2oMY$TLRx=T6uQ066mPALI?v?pkkJV&;CEOGOfdU@K#48Rf70Y=Bw`{!Sxy
zGly{a1=DlWBn%-?*C5bP30f+`-;jvO2he<+1mm}Bj%&gGqfG_3J57Uw;M&xZ<5~>q
z*C3=GY8%nt`UA(AVQ}OeIRw#*LLVVt78qn9;?t8Aj9D
zzosr2=`*Mst;UNYDKm{*GoIHgC&}Ldohwa;Oztl{YD+wM1$wDnZ!q8Nqp|Il_HF&?
z4DWNZfC6-Vv73BbfCd+ttCtu?7Up;cRl-KkH8mWT{k#bSi1CHHBa89D{kK00UX2xK
z@J=d+>ZPIw7S8a?x=_57WmQ(w7vri;DVdgyo373VCw(7apLFh!AG7Xx>sLJlO?DeT
z#0Wc@Xb9j#jnat2LH!3u4zrL;6v^?S<{{++*pLBcv=~)3Hj}nPulr8Vg$qY5<_;^_
zo!!hj(9@2~-BJ)8r@|I6^{(;zpMQNtb~#as3C5+nn_?^o^-p?fF^WZs?R{;^h|c`V
z4P)bW5_z2@M}`mi#f+X!doPc88!p=s5Tfsdd&_s`&VDkDAClRWJ0~SIBVTq}DMfFo
zJIi!*<@rxV8GHiVo5cD_=8%(ba3(wsBoXS2j(s2W_6hDi^CZng<%aygfs$+cEb89?
z3H3sSch|#-kGLqq);F+Pw9-n1Wpv%ftUT$lz{>sH?FKfhrZfP(;#UQ45Fk_~+9x|qv|NXkRDlJ+Qs%=xvAwt5
zr|c>&tG-+*7056HLaoEpSs@JmDu+5?cC1XaH5AWGPonR2jA7Yx`EgQTWL4>Ff`#kq
zf_c0--@`CiCfl&S`KMuoh$V&eK?6Z{y3{zUv275Zy0<`z==A3HR&ZQ{)e=j}<>{~c
zgK&n6(=++L<>cc`?3c5t_ITv>vCFL6kijnFcU;*V?$S<15of!;Efg)~|zrLpU}c6$x)-}Ws!0gG5H39BV(W3`fcZ~_se09`*UE)^qDyAT{x^C!7$v|AEJ7>q+
z^x64KcJ)9(Q+98UgJ6z*zme^MjZy+5)QUPK55-$W*LR8-5+oLQJ-EXk$iTx&6DeOR
zZ%@Tci_r4a+{q&M8zV;iJL-YXC+rT-V@$8Bg&mDncT}j?{yw8(x!1N%dmeL>C0n>(Advuis|v7gSEj4Wfh-UJ}HzgI4L~kpnCVOb?Wh$&rz{Zy}ud
zzp(_tvBxADiQ2i?G3l+c33UE1&i;)s2N}{EIz1^pl%J&q-cSY?3W+iG5=)@<%!@JO
znbL@w8sH2+%GLI$rWSXcZHmG{V}F%Ngpl)c@yDTrm~CUQJGM7z6yisF$=e1(A#gvf
zNP>Xb4!vP?Umex*UX$Yh&&%NqUxU*ISswzTHbLds=K@;FatSU;f=o0JsA8*lSGDr0
zJ9!7rl^XuyzhBPi%4g!cH9)OhC5pf!EvC5(AI|*K0y9)PGG~JPiL6>4&m1U>8;59+ryc?uTY!|}jHwl$
z&*!ZbYQB*~>*1zqHUsUSanKobpe#-KEjnxD{UVn4OV&M7UZ@ONT5e@lA&4PUB18x^~$nQQ81oL
zQ^i1bD=e$wFMbr03Pc@Ki^{jJ&djcY2!#m0;{<_N4;wEBF
zko%2M0-E@#iSBfiCh^XWs&j7E^v>U6vVB`ZsflsqnN&doBC?&Y;Ob^gQTYn%Hw6AgC#`|nIzX*oNnh}lTQs?je^JkNXb&kV_4TOe&lsNnsr@TD
z4H$;HZl|fw=WoTkaPz4U8M8SNAG0I{_m}E*)f2!&g`I-U-l@_Z4Hnj{gTeeFa4E4ePC%OI&c4P<9WWrrdO>|+EnqP&G!zGwjyQkHU
zY{@0F{93~N_ZDN*ll}jgK)fz>05qziSraPsqQSuZ;-AiA1ulR`HjAgrA70j)m1)Ax
z;pVD}O#@Dq#hpb>QqSdK8v!#V9?VwX6YEr_w>mQZe;5+75i{_-q*|%y9U!a4+(+AM
zIU@m~oZbHEY1;W8QRoLoH2LPI0TeNiwhBOq!z^-KjGAROYw{!PB?$}D-i4M;-ejU}
z9AN2X!!dsf%xtRlY7$^=c$|`448P_nC4c3xWvW~>2GoAv
z#JrgcYcnjDsCf_}@ehgcJ8SxHfSdskZbfP+T04y`gu|-Vc?s(B|Du*0atZ}&YzI*@
zu`S$6rQ)yTwPpg*@{h|}$0E)Q8b2#AXv0}G+WlPS(W5K7WKguL&icLH{y%lf&JF{b
ztVGhq#Rzl9ya0y%TUDXJ2e#zoA5q258ffY0bzq3B>;<^|skJy{ydyxG6Y=E}Ad!mk
z5QmyXmmQF=b`W2yQ8nK868;~u`>Vh&0W92(Ezn~_jiB4R}F1d6_(Ejyqo_d
z@qM=86!J?~yemtxT^vXvd1=o=tYqbnul`*bXIKu;IcM|$s$AP>ECtUW{&S7LR&dwU
z@UM?oqwibNzrm;--j8Dt=Lw;5RtGS&2`gQX>8g{yDpv>T*xD`7!%K)>M*ze)6zwyo>!Q5Y3UCwvrSWLb@M_-xgfp*RD-kk;BNa^XQP546O_(2I_zzBUibJ7Z
z8Ri;dk|i}@NY$#CQ#eR$q4m8yg&iT5ffs(lZugfz|yKHBO9$R2%Gy@Zt_4iIcg)YZ1$_{=YC}9fg!5EKZ7s|R0kyr
z5Yl)ihzelTwXZJFgXZgvQf|)T2*8ug;g|vfApsi62%E(*+jprWN}+o@K%MTc3tCe
zh0RO1KNQFC=Nx?DQtuC>27SBDnsa$EdY%4bAjK)?O1vl09e&b*_~
zvOI8<1s)(_t&yC&orvc+6i!rHP#y7u&RP8Yb(MokN#Fdcv%O)bU+<_zDC=2*!0l~G
z4#C#vA312ZBaek(#Aq?8aN>e;WnY#fFm1xZjv5;CNnAMOUb6mnWJsXjxHmP115%un
z4p+`KCPvsdUwWH0-oHuE6(`f|U}DJ0MOa~nhBR^VH6oKnr|NJkK8YZ7Y#bZouT*z1
z#tY2DPbfiK#N6iSWE&Rq{qJOwBS6;0fjVF?2|BnxWvSly<(btV_SP2JEEpyFx`h*l
zi%Y46E#BR;lXBIo@%+5idcVOhQ&Q+D==6G@XVCeYx
z7%u;(RV1Uk03TEXf`7t*$I!_X8nLS&Hb0sGO1rPOL*mR{N@P6p4gW6YHUI4t+o2QR
z5$~;+GygSv{lXQ1{opf1X>?jwSU6T6VUyL*E_c2EV64FrS&p4MOpXf#63v&vAa%ik
zy*t6sd1est8_%{xoBgAU`_2#E^MjWOqZI~H1B|Oakx?kPq+1wo!|}Lnaj^{TpP{AF
zDU>YD{oxzd&Dxb>wgBO^>vOeO%yu2d>s0%iwO)(e
zO*#3K78h9T&BuvoVFM_Q$L@yl6u_5+i(ErNC`=U1Yxn`O4cTlz4+`gK%$JY?{Z&a6
z7Uf>Yjs;E;AvBX{0uGUCy>@V;jrM*q<+WeQm}OcEMP3YLba{kM(U?HlI690ltUyBX
z?a6Yhy!}BjRlRcn{Eg?|oj8PtqHxg7cM{;|Up=6HnJEh6hHkr65_>m|3S4qyeh%27
zVQQ|sGU5ha&K1j(mY}M(H}Jck+x#_zc;O&>&87*tghN+jg|4asbJAZlfQ5t<17i(^
zi`Je$PG`kRenOqDV&v<=AS=Z$R|HQs`jK
zPd8d=-oY-H+3MS&po8Vb_KVRR9UU_u#PSs@G-{#VE2V#?RGHObODu|1up{cjmlVIU
z60bFrXXT59r
zV>Xc$J^Yh~m$;yXPFVGzpfa`SyR?Twd%n6YwF^;irDA@1nli1`P}Cj{enM970`qf643+2CC?*{i0vWjJ)MRG0JtEnvPBs
zkJGNfWS+Q2t1HvYP$9fbu&CI~B6EQaJ&W~RMR!jy60tnNocoTdAT@bUQ=FG*i!Sgo
z`fpa2Q6M>R4~XQwKL08xfca%M*7Ji}HZD8+?D_{=e)b{eqR)5fWfeWOYP}QZf=d+*
z4Zv*XNco}C*!J){c$Z`Y{kV0v@C%H}XcE&ZO#{2_TAK$gEp5p;4k^b6YN^q0ESMOB$&F36tDns`lsZYw
zmK&_$Usx?s{#~(XP!wA}vel~PYE{Z*$-5KTu|bg1l;UE<<_b!yf1s8xaz9HwC}rU?
zYHVC5gn_E5%f7D0RNNQwL1{<$O{hb|RG>7IP6gnWMqkCv8>4?QAkXceD&0ocZbIaY
zB8NedOcLW43igwWCupzQphGy7hj4o*@hMXCSTTw!KcCO^hBJ;Bi6j&kY>9bX0IQc_g$G3OEgLKEk>v;+
zh806Kv?RGS_Dzy6Bj$HLf0;Ull8j<#Y%F-(;Ur?*BlF1+XoX&elwqFE-&kRU;qWNw
zFQ!ywVV^82wHnG8H9hd+>uT=4nZtNFk@(yA
z`Hj0}E?ZGe9lDHLXGoo799h{blgz3LB)YjIy8WFbgjOZGf({hM@;l{cOHAccG?0y8
zgGX`6{^;+lX(A|6*G9<&&^I+1rmVE^hPcS?^6H89#m(07zYwmk+h~Nw{_RXFSg4h|
zeIF6A96>j5p|$#P%TX0j;2Gjylx=x#P;ar;231ZM4{#>Ud$X7IeN*;b51Le`*wdRh7+vUo6W)oAodyOMrKUYdcCo<1Oqn=<*0K96E+
zm{Oq=9X;F^M{%$1$04;#Vtp|MUqfkeQXBxSIXL*8#$sFIvBR%uG2N^Y%Ck`Z7C;(6
ztOs}}%Z?IEUgwzBLttyzON)p!H|5umn=4gg^Ts*SahN(KZ)$O*)>&=>(+^voOQc1x
z#YPbCcG)<2DKPc7>k|R{St9tKGPd8zcN~bNvlze1V!8GHzvm3*;+fV
z>$(jWk=J=Q_vH}fWUD;sz90ft<r+8ZpmM+~)iP>XC|
zBtVQ_N!5GOQO49mBMHY|R2&-HO*KhCt~^eXvN294)xHxMZ>)
zYU(GyAUlrAEyi6-`prXON2K{zC~L&}jW|6tS8m>vrr-5nX_H8Jkcq1l!$}9neCiEB
zSIaHH5(AD!MTM_l)H%ErWu-}$dVQy0eAg0}(kN>a@d_o|Vr@7ivbu#`34?MkG6eeR
zwBro;=4gzioEN(ui?@Kj>$`f|dJL?tA`=J|f)IEa>wEy2ff8(w8MEQ%a-kvdkIeFq
zRIFdbRm)<6sz#gjwty;h1%a1+hg(f-TVB6+?xC+7TE~9()JTu{E&Ty^G3Vuq?$T1F
z1@%6lz{r4QKk;mFTclXh7P_-<^9VFZ2=eIpr8o@}b_w+B&Wx1{J5@Rs7Le_;kN^HH
zR2)M*`7Uit3d|KSVJYY<13qdYEApsqy8sFbi^!WSnc3LbCi$vi(>RAkuW&0Ti)?rc
zii`85w~H*MTR3KbMdBqIC1xsRkQn}dy%qmI7$1k#H?2!-0AUHraV-m%Cq?Jzmh5SE
zNWz8Rau3~)9Nve8_)2v{;>uwuh)rC%iHH!S%-lgt&r@g6bMCF~$}B@{$cpBr))VlW
z=)?jBQ(v?Kfs~>QOX^Sy+NGXEmY)ifZh0PG^=pE-Tt4k*$)KH#>Rq(>KSw1VM-9~l
zwU7-z{Y8V(_`!kX%m_i5l5(<4T{ln-*O-@RBNz)v2y#q2Q@UT!%u*^D)>k
zhcLt!_A?oVm*2-yfaQe27|$ckZl#YU#l=_9-MZY7h3
zc^>~L4PmYAfKhBTx}a1@r{gj!M`YBc{ytl=ckD2ZrUXfHrBiHSyI{`FMCRctQV@y+
zKt!i=iay~nFMbuxL~x_Ody0-cyU(w=wcB3aKfOLPHYfQr|!Pf&eiQlldh
zqnsz;N_D&I13`2fUT3lIYN#RvEqyR1Yh}bKwwFy|RQl5VW!*B!miPw?HWRtryCCI@
zWhdrty5h{}O?YB|+{)!%OqNLWvgszRrY0kC;#Svz=4726hr%$SKjcIJ@O)
zCpe#wT=Xdh4t;X!3By4l8}x9j0j0)swcha8C!-uWZYxuAJDNDZO!Iwj-;kegh8xRs$hHzN@}%_rq|(d~w;4s7ATDnF|#+^f)CoIdo7p
zKBrw$poAvAMaJ}yH)6SYY}P4P!?onra-(%5^J+`J%d%@+nDFlb^zEg^0!!uVqjgef
zi4nn~vJ~V;FY^*4
zp3cd~x!nGb7hqE}NaDFspgcY>JHHyUeENvx<2!E*IvJOA*re{?b6;>IQfx@2}zRJiS5D%IKSqOSJioeT3}{IQE_nUNf;TTi?pVxDiLK^Jqa_G|s>=YHAH=O6V6b4M*_g5cjDY2J=(>bl(G#>&~t
zyb&4F|4A9L@Ir{Te4_;k#ZD8}v{{Lbz__cn*l=Oix67xAFN~k19X4>H1?ok<6VR$-
zPaUG?CK#gcOZpj25LsA4OS2gi8a>EA>nUS-8>CI}4e%8>urTI6SlxQ6^?a6?d~s`C
zy>+6l?{Lan-4Wro&q7($t{k)G^TKh=Zb61TAL`CjVGF-b@5+?6x7qb_*L(gFLeB6h
zkVx}VLz|n5_jQc|#<^NaAV0dQw`?a;QAfcKV(WF=u=s0c&D-mf@!hJcr1@hoY!Z`R
zM6;8b@xY(bl+O-qW?xbxJbvvO>_nK-GBb(+r{Z=@)D-^okni-8Y6dF?PIU^!dq9b9~mqwYXO9R0s2W!(nZxVpsxTz$6&Zj!u=BPjRU5_
z$0h2q!*e2Q!Lu`PvPjMouoqzEjwDP=rn%5XRxOT1PFTR4>Q1)k&
z@f*mQ<{bI_Qf@Aj0B^U8L)(-?<3<`NBIwE6{j&T^DvGdvBd~9RvJ$q@V%|fFpfAY^
z4NNK>fMRH9NJv^5VfS}dApUgGbY?I$5}x7C$WDQ&Mxz(q?pSspg0bXQa&|}W%<#`z
zqea9Lue+0VQsN~Sv7ZBS;6^##>%&E)*5fvY$D0#04*SKA1?~#v)b1iL?JHRUFZC<=
zWCG!w)0*R4$+66%miR|HkHCi19)adl${KP0kJ<_4TB#s}q_{_NF`*rjTrjNG+Q%4H
zihwStmKx2?KZ$-GkS}FPn*n4L=jvk^;9e;eoP4!ht%MY97rBiYe+gc+!LEm&UQ`J(
zKW1kZw-78*0@!uD9^|;UeuTE$CNm>m+gHuuHu90)
zz(8&a2YuE>_g_!}$uki|#`y+Q!>%w&l*Vwgl;ksBr3!&7J?)T5eYgz*zC-oI97*hR
zMnT5#+&U9EqqFXO=XJZqetwwbb$H>x+o}yc>yMt-4d;Ec=NEW;wvqY*uC+$faX(NU
z8u}(Sz@w%kzrXpJ>G0T~?hj?Rh|LB~ccfbr0#u>(57QkP=M|e$jtoiCLXQzi%j+bC
zwk!z6ajey!AuFv~KNKWkE9M9C{KA`GEX}6PfbNIEe!x>hWsH1Q5)P0?C4*{*AakBD
zpF1kL1AqHfRjb2(wNG?lEFVEA(G3HU*UV%A$a8-(%KZM^IHr5`ZmqYW7t<*+_1B(J
zPv5Y;Ob#wY3Q#sd_oAbDW8j{FDMwm{IotP`8-_M=XX4`rj9qRVJUruvh}kfQ7j2Ne
zfKfj+Aj*Ly2$w}Ks@ZAJsQ=P5Ok(>YJ^=tl&_1O!9iN#RJrGThZ>i@5xMRKA8y`r)
z5H!5~ojNpT8L)c0K+Jo_7gYbrb`e2$c5Dq24r+*R#QY9iGISpEwOD3YGSmvWi
zijP2fOXVydRGGDzawoakp_aV`aL+ClB7@yVl_&^439MwA4yb?`I^c&1oM^@MT)(D5
zeVS(Ae(gWAA2|}-+BaY8u33H!Ssm{rS&ik!!DrH9>$HdbSvevvTK;=Vqr}*N8x?ZB
zdGH%~;u+=^fuoZe{_@CXNQm0yO9Q8$52GGlC$nyAcbC!Yv8MZ=Gjm4XArWAN~AAKYr^B%sskz
zPMtpA6XcJQ#v16s#f8Qc?Q$`?=<#sB==a_D8M~H?37G7#b|Z3Hu>@a$S+R5Tx?(yR
z`%9uC5&i2l&PvSz38a^GRjf*(om(qrCYm8kS3xtPJuGrT}$PS{kBTsaI?BmdMR5c|FGU=`3D6S-{8FhD!P;
zdUT_&dFiQ%(af2xtb)B6Dx9__MhojG#E`!lAZs$B=ngFRUC-a&xS6Ql-XDAo6sFPf
zJME1Tip&a(pR0fk$lzl?U4hTM{fzD%%S-5~>qGeUewk+BW@|a_t94`gj&^!2oKm&|
zMzuyGjK1#$Le|^hwEOvO`uV`#^)muoY-$Q9cCNiOB!~ZZMHiTtYg&QM?fhW~rq#r2h2MEgL
zZcgKOBglFlrMw?>iZxzI$p%5{z!0J+*1IxEygf6$zC7zc?EV5sfVzdHxP(S`D>VW;
ze+qYDj^;j*K(NB~Ptv|VFT4!tw@0WtZjU#l^@@&BL#zi$7@Q9fvKT;dtwE0Iin^|%
z9Hgdt857SNmlbQ;!>{9-q>JIX!e33RI5?wGo*r>xo)cc-pc73N-*jM9orSp6usAwP
z4_t4198&q{3q8%Jl@hj93n*QbZ2J`l_v6XX_$18keTQxPExYlaJZf1;wLBLql9@bN
z4on=nMN0GIUfkNo!iUBZw(Qat0AOAmBL)xYa2|<}%l7#7$Z)zyZjzA;Z%n&U;%b)(
z+~7nb78z0NJd9bP*`7v0*iubVRj4TI3lQMoK#3;U$+4W)QTk+J&LLs|#i-o^blUyh
zv&3NyZqsyPx6TTEaN0B2jiLj{%4o#Ou<7WICbJAaKg@6#?r&|=nN*Ohd!97og&^Yx
zv$5nLhpR|lS0^AgXw^vgFE^ycbhZ{i~O#zo;r@hu0Oa`
z+^NPY$m^4Ka}+iFH%MU_f=jJ>k*hXbM2uet1BqV?dBqT>EI>AEb<}AM-};-F4r(4b
z7h-k5n0t=~w6F#`0Yqy{KZgdJGyb&ZvXQD0TGZj3S*zUp8_J(;FWiGAN###sZxq&}fU3r#AB4jIGAu~IpPUs2b$<&v)JdwVR8dVGD@=~VEAOn%
zcZv5f9%f^x-yq!+?&|o2_#x;h(88}ToyTuXx5^uGPgr}zg%IXL@N{rk4OH>~A|`Wt
zSU~|rY|DpOC&>~#jw-%d`abUDAfS1HIc2pf;bDy{An0oK#}|Uncl)T;Fuy>lb101n
z8ZrwxkoCUDm||$BWTdqeQKY;P0yQV>&IeP0uMiYHW||=rhpUiJk~Kr5VNB3f?m>1%
z@b3qDsC@TQ#nmzVn_NK7XMpogR`(Rhl6(fEFDvD0*r*~O+i)YGUP&a@(2O
zhb%5S^#Y7L5HTCfqs5eSs?2G%->nN;5FB~1{G&k`)3rbuwMmVh@SJAR7y|C`&!oI)eV8Itk!>=wSNCL}5H
zJA!MKX`ByNAdOT=YXDtFxko>@lks75)XAVS+)jBwZ{y4HBOcFJVT#-bQNL=uX6cg$
zOm_cN;vc9ynQ4EMA`~5JMm+?|e)(mQ!TlT;Xn*0cw^gNW*MGvb;`eKUo7nTT(VDLM
zxG-rJZdO&vm%8pxDy)Ve79+aWQ5gB9-DI_$U54wWH0ZRf+bFuFMSm5|YRhwJTJp=H
zW1u{tMgQXkn3$O4iH623SPp$%uVs?qL>k~f3j
z-`{^)9&UMXFl0FeY$=wLmUDQZz&{u=(qW#D#np+W6h_2l(eZ;t+MT|ig&Dg{Gk!hh
zVRs+fZNa1{cxKXX#5R_U-6FAoHKh_by)fs8Zq;#{$(b#rtQGf*Ir(g~Akd30X0f7_
z%QRZdRwZRmp|~|$EKwPV_8BIckYi`2fv8v)oV8<9;xURihY))!iH}pFRw%#qE*8ot`vH|wO0NRwEXy-_uK!y0@TJIBVPJnCM5%Fa?P+^ga3xaPt`jfQBW1c>T56|O7*g70
zAbCw2CNl)ho_|`{O?7{q~#B$<0?ZJJbkG3&`
zM>+r|9Pjp{3?cue%50kMWgCue{DNaoT(82+weCu@x5E!)xA=JTqehx;tS|h>xn`s{
zXz_`EFKRp;m3=DVLn06P6>uk+6`t6UdvD|gv{h!^{ek-}56lG?rh-kgn+kIH29N%F
zfK`~B&|>>b?B%1wmWB}M!ywF9+KzB_+)-Q0?1z~AO#+ir?21NNJyf18;EG4=VfnhE
ze&!d^2mb~0h3@-V2b%3VhU)IoQ$(K%pJ7ECJwXq4(D_>KQUPvAtxd&Qg9(0@;?>h3}w44fgv7w?Ko;1GTZb
zA??h(aI0vG_&hVRQ0allG?dP1(sihx2ZDMcGLo`{%2fqofD+;53QN%K<>9G`a(_(#bw)Yx>_IigK!~aMNtQ*b)*DYaS_*(@1i2UNdRjWpfY4`4~p}$_;?VZmt
zuxy_$+x7?VWdvizK5&c+J~6JiPDkHg_ugH|H_qnj@~RnyTS_%;y=?NHZT&`5)o
zL@ufz=gNbF4omXD_=P|
zW`C`L(2;qrmq38Dg5TPsf^)LIs=D`_ZIpM
zvPoC=Dj?s_3XWOw4AU+JVl(0vo
zSlO$g)!7C!mq3x1T%>_oPWOlHhW+AlwV;xg5>DCJgo^26?gKTBKbG}qZ{L30+C{k8#jwCQJW~Z#n<12O1T7B
zDnq4^k-$eZqJR{Xk7mafBgF#)<*pjZ110OgTNT<#)2L*Rt>Am?Q9f1lUn*@p$r^=`
zqf!$+;>o(fhM^?>&K=~K#x+JeN?$TL5k8s8i5s%wp?ponWm?{@|HRzKI{1bg6kyuC
zTATC|gGI^dfdqBE&kvCdE}3nRBu(PdXksS)Wj@w-?_gYGu_=-umS3|%=6W=V*4etr
zS%0tq8DdOC?$6h)d(3;$(8q<{c(&EYU;Z5sZKXhj!diq;U)Cm2c}kA1kj2wHS){98
zU~WQLpo7>?fgD+Du=#wlMowd7k|REky6~`K%zFMubkfJ3Mal{S3uBMeYe12?(j$93
zM@2nSau!JjX_uuyLlW#;tecz8%{lyitD#hX8|d!??G{q_9B6Y_xHbv8q!OtK!{?rV
zL?aRLfiWJ87Ws{z2g8uW(w~Tz@%1w-Y1`dr%;dsX9K%I1u~b=Bx
zN>~(LMkSi>W?UKB*a$a&$l7(~hQ6e|GAevBnk_R;;k04f9m@>P3-AKC*p@dEI~>}L
z7j}F`SwgW!5z~+)A{h)6ddq0)5!Fw)KAc8N*7G55xg!G1$_7`sovz@g@EKy?%<3d0
zU_({NN8hOHyTLEj+YIIiv0ym%yM6<*G2@Wyn`j&|OtJz}b5hGFSi=ym@N3q|pH-XC
zrKDv=shD8#$F%Za;#O;?>Gtv+ikTBcW9SBbcdqF)I6Mh>P4NdcbFqSQZCnUP$eYB_
zIdhC=E7S>(oqvD47IXz_-5x=JAy^>6E$Z;dd{(5$0%n5jXG^G$(w23jO0%sr@>$5b
znP@A%g5z$t%}Nf&$h;^-+|LkYRDog9hWD>e%5&ZKC|BiKJYg=dfQv=*Y}V<=bY+x`
zr7z@3OTy!|Pp2Rxyew}x{qNA(-j6)v+;>nFBNJ{e%auVc9?XPX!vj|n0>P2Ye0a@r
zgcwagmp-rO`|Z1x_2}-j25T~-$zPu8eC}tfpo1mrh57q7HR4*Hta_%Cr5f+sJpTip
zazG$-Ou)5rWLi@JCa=>l{8xL`WhzQC`0Nc;PWVSjB?~P|pmKxI#41Lb?Zbh}2tK%V)Mj;jo^|v9x7gjyBwUhIJM-nMVHrxrf{3@FRMheJ#zZ+a?`$U=
zqA|iI3JCDvVK9JNm3J+>dSuklki8W2p&>{RG_Q5d_Yc)*8~s?WgQLZ0y`J+KK`gef
zOltyn=+ApY9qtc#lpjx)?}VkrX(2};Bv5o#6mEIWfyb_EH({`lM}_uBz9s&hWr2QS
zDl|`=6V@Pb3ku}Jh|Madw>&q%2JTKQoOeTup1Tm8HHg+s=SlZ5=ggruezpp>PoZv-x7-%x_QJe|1E6JH2ao@cS3l_;ozi_^5K1+icSJw1jD-_630
zbe7=HdL9Sapo0(w`?q%+b9nG{kiSVcmawV=_|EKt3qL5B1>_tqE%zv*fcu2IKuM*A
zq%AAxn6f8lGvuJ~6C_SI2l;~md0Mod-uvClr|XtrAs`kYnlqHx2f0FlS!VJ5pvq2B
zM@4~P$tQ-ufuO-Q=CxA*-3I;!3Q9OCLP${Y*kr-44yEWuY~2`5D4%l-Bl}2uAb;G^
zFx(+uf&yT?KWk1FFcrHV`7s#(`q>Pb%W+2bbUHtb?9XV{>x0^Jw)3UL=?++4!`1dCkJ{>i#+%B^&NPDONobgLJ
zme&|UPO&-Kj~w9DL<6w#A~Aui*GB_9Vp6H~j|UtSfE4=WIWgH4Vt1mHt_TT_B_)_G
zqq@T8)Y$vWBJ=W9__bSeBKN25g|{V7@EK@5Pj%ftgA2}|RfEIrgM?z%w~Ca@?hrYU
zI9n>RQ!d6KDfh^@O_Uz|X$Yrxi(jQO1#D8?2!i;qNfbcq<}8RL^fV$WiAO~@s@!aw
z)b|m=Kooh|jaDge2(Hj2hTUog@O}XFNeI8`+m+u-XtNh#I1zOb>%I=!RzOm@y&SB0T7FxK
zmLu@GTD{{w3{tKc^nu?3O^^4*(_}?;d!9y@+kzlR)hdGWJjU%N`b_b3VFnQ&mUNCF
z2aCPa8{H2tXyV3R%!L)Po7Q~rTvx4WV?Lk45S%jiL530s`?ns)3}!zN8X00o6YgzP
zE!%~@mbmXc_S=MUD-L}Dv?P8~K`i&8U6V=taVYn3F=qecp*rN%eANKxf|AwvkP5q+
z0m5cPA#A=bgJK&U?+(%%(I&Hz5?Xot2v9zbkhI
z&QoPD;j)Qp+Z~G+>3u*Xm~f6B-sRJ(HG)Od@I7j@0aG>6O-)V5kMq&7*E@v$JkhFT
zI;){_4Gw;Ae}m?*t^Ba2cuAPEMbiXfOffVv(k%>6yzA5YTLF(@EcFwfzRwM{(IjTl
zFQT%s33#(>H)x?Qs%6Q|BD=d!RU+G*x*Cc0lv0}sVOUcSg5Kfk6Q3d8y+KLrN^Nm!
zLt)J#h$$pPA6wj_=)pa%%*du{0sPu$Q%2I73jd%AB=YYhP9N&hoAOxjM=_Sni(NkmUsa$1hCT&oUVE*oG)-&EB2ScG%h5ktHaRII@rPaC-E)p>~fgvjj+w>
zr*+Y3R4<%-1tS##wYQW)wGM1ZGNIoYsa@|OC39Q|
zIf{S)Q#RinN3Yl6)`b;|BdLNiEf(8|Um)|>4dVI(@Roc+CiuE@0
zBx7k!0hc*&j4s5i
z(Y|9hbm_{q0jfPotX{}j$goyyEVvf&p@Gd2d9*BNRX{eE*QdwFq((SMjY@5oCiE26549U$Mv2cHMTleXN4g-5|4#dj0zD54`IbXEBoUCl@w#&}2oZm{WF#UjF
z*wCSf(Ad0s%_4pA>Tl8iN!0gzlBS$xC$jPajNs2
zbIwL?)26TlN-<|rSUBo@r
zLk~TK=l=087{>MO(;GYOG)R-;q!rPp4k2@V2*aDZ<9&ha@0R381G^LxivY`4t;Om!
z>zn~|+?Wv<$CuW+Zh-4fVPOHHB_*(YX<>o@YS@ZJV+;fBXteCWby>$Gf6ZDfUo;;J
z=FGyPd2_I8*%CyHOE~uF$m4k;lLpJiO`D*Yh8WG8mDPyVN3nY43S_5eVqm`k7|?$J
z`tS46@pEL-%8T1FIHhN|vRO0<3ny
z&tK!iThE0lT#cASAZ$np&WEx~m{)&eYvGvh@yV^1LqGLlRC*m}29Y2g15dnwr7VPp
z4*081BcL0ZP*-Pe8r$~nj({7Pkb)6moTXd2bCwZC1Z{YTv=Jv1jM?&g*6bNvIF?g>
zfZkj<)Dv=3s5OYFLNw@4V-?xJPe1*L`Sa!h5@Nu>o#^r~g!m;Q5X$zTfjh&{ojhqA
ziVKV5iCQUr%mERhuk@yucWIzzjztV17u3LTlTCn>^^lA0fd?MulzB-}3I6uCC-_Eb
zHTRD(Sk1s)mbNRRPhj$-vH0(Q{|`~VEgU^zG#cmT0zxc_IhYq!Tp}sNZ#5?KusnXu
zPt3_SlqX`OqbL&$UX`AFP|Tqyi}jfo;?%GmoH;Epl|K_qdSwVdMimSs~i>Z?*qPVz_<*Xx@uGyFq30M6I
za@~qC8}$ME-i8O%eTEKqdU?#Ku~^Q{c|5Dj$U5VF)i|=?s&v(AvJ!O@$TP@4N>hbZ
zA29rIfQN$O!gY9-huDI3`LLP~>n}_G!4txiNu%)Q8!sU&iDB%-acI=IG4sOJmUzNl
zu0iKp|1em#atS8#rGp+&K;5Z)vV*W%g9b>^umm4|{0^fnaShS6TQ5xoL|By)AR>+|
zTsAnEecS=^_R{D%{%!sg1jE>ASo{Q26?KA7&CS(7B61xvJBsUf2dA&
zfzk)31dYwZIZZ9zdgmQXm@tW02o4xN44S6dlrN=_^LhXMG+yw{x8Fj)wx#LiNg(d<
zpJHfmVg{uF`Ob`1U|7wnEU09TJCf6xB??>wx>7zPcC
zyC8AJb0tEh?q-@U&E=gc5O89_bamGi6k_%2LXLvLlBG-d@_L;ciy|5!b!`eMM4D{w(WqSLx_%?RaK>QvoZ0wFKdI$@V~#cG;3-V3M;%Wd0WKlQfzV~Y
zqhU-cB33AK20_fga-sO@k(*!&RwIfaE|8Qzfk1CmxO5W{;TtH6g(JVfFaP%#v?>t;
z0qbIs?uQ(O%%-gvPJzGbHUa?$*H95fsl4<6$^9DThQh&a@l3-fIL>iG>81Hw_3DE7
z=A<-o3!RW}3&)Ka&BZ4~zX3E>vHgGtED;J8>qtqBphs^WEV)S?IqG|i9Pu6V2IxOv
zH-tQV>w?B?jI(@$l8w%td%|*o8#DT6@*S@uURAsgnisAI*rLlaZ&eiI{1M&hu{N%$
z&_iur%hqV!t^<^}@4x*TbLUKFY7`>mhr}ntye$b4up{{FqxW(BHRt2+e|Z!OX3Yd*
z)@=&44$ZYGdi}+J;g%aN#t<=0&su;BB8`m|lghJBlHX(=Y`g8*)0aPcjQ<`%##J-2j!UNX0aU(%>gnokt
zBMb=)qd|ZKf(SRleQDTI|CmufVKhmY-k%0{-C)xTox1|nOO4Q_TMw2p%=a^0WtOys
z0E%eMQ8a7O78hT76H+3n9FqdP_1}N9Er^$+Wd4WwjlOE|gkzZx0CXSg5-K=qn~}rJ#h;OLc-JU#)s{0K(G9YB4(QXnH$rRi((A8d{G`dO
z&O-m_DZACsrn;Tk@XT}1VbJb-#(aKE1w
zjYh#tjyNq)5-o;~SJT`kh(sb-w{D%=`_7y>6VqnQ#>!Qzv10i`%$hk7^XJb-5zoUa
zLm8=Q$jr!ehOJN~jY5@{mdf=g#`QMjn1h#vh#-x|ubiA5hU@=r*+t7ft8wf758i=(
z5|lx}3fi~pNTXH`F)@y2b1(2YjlL{1iYx5sK?+29q~)!mE0m=zzr0tf3bpbV@A1Z
z^Z0h9O`CR9h-|F1rf%JPBO@yVMFs0HdGbVz88rgBnd#EC8?v&Rz;NTCiYa;T(x6Gg|LQ;D6_I-ZX853fK6Uvz_KBV^4B5IIm`pO
zAqk0-o{0ket$?mwyI_}HcI6_J>3B)!=D^xS-E=q84mNSIieIJBtOUd@hH+!ZfGXD2&uX^dW_z5c`bn+Jue=UT8#hB3v-taS&tl<%IV{g~J`?xp
zXHZ|Ac@cHi#W>@)7vapaFR4Ih{Pr^Z=FAI`k=+eZM0QFn!)5q>sfW8_ho
z!U%@S-!UGn-6ykc2}%kISVt5g`v~Ta_X|~CLR?}rL?~_%o5s9UU@QwqARj>e41IC{
z01yC4L_t(e-?4%R>l++ZcQ7cBnmdnF&(y`aCC2|mv8i^prvujC73D$CK7cE+ZWh+-B
zq;AOK`UePBuUduT!Xkv6PZ~~1Nuj|k4I#j|5XJ<|759f+`(n`;)~;DYgV;PQm_LuX
z8pdteO-p?9*R93}AHIWCYgTHE!$#9W8uRDOo`WyG_#A2^6cLDs%R>)ce|rShBVg8h
zx6J1XaG{M^LDNpXklVg9LLLSf1W&;9M=|xoH|UQ6+?MhIO9d_>ahDPFI$3ls!=VHW
zhg?EJ6s=f>pa1?Ke*EVHc>9WD@XufGgwJog04t}Af#m_6+5Mdi0;(lkc~uI|vt1u5
z3n5P^LYY`FW-`Z|3<*g!J>?3Ffrd-Ts*qU0qA^oTrKx)CMkK)0$z$Um_hmf;1`c5L
zmK!2CxBq}4JX#w}7&jWRXbBe1osDHn7eP!a`t<2XnFUOs#w$j#n6j2vSqgrDtF4d*Bf~*j|e3
zZ@CW-KKeJ@_26G{$<@EdslUC1IJgqTOkikKQ)IM0@UkRP5(5XFMlf8}Eo>VsM7F70
zwxB>&mHD(CfB@x;a~(Q>9LN~QvQ7JLIQg{m5J^p^Z0TIIc<0TRG3w`U5SL*C&2JEF
zcYci+EEqo(hP(<1}i~=*f&ypXS6CTmt>)DAtBFSWQ4taWPC7
zpl|XXQ)JC8B+sTj!3|=G%$$>54_FTH?~Fp+<7zUHzY++IUp0|
zZl<`g2m1!<56z((MY|D}%crF^D8f{0p@#wuTlPqa4uUj;ZasQozXK1)seCzb_09L-
z-ben1J05rfmtJ!VPCNTjge)JD&wx5qUuZChII&)YtdeBO6~{~C8Bz63UR(uL=klqw
z1OX}KLx(K}%lT)Zah4GwcO0wcOvl)_UO_-z5TlHQl*`tXdCPEV(QWFOm?c*hB7y~z
zX1RHz{0Y}ZMwPWqcH@3V1|hI`!hFu7cvN+sh7;$T?|*a#b&)6zJa9O)tK@)ghy(W9
z*SQ~m`Uy&ki!forWXzpEA3+dc|9yu7WKL&}(!s;`XE4MsBS&M=vK6qRX9CTjRLUOr
zC*UQ9hIyh7_KSrW1L#PsrcvPfn{UA^?sE)dPdVj8q^3le#nPw{g;>E6haG`mAAK~A
zKH>-*edJL%>c}JMl4unB)!`akb7l>|1(Ts{66}v(GpKkKWHS&g(B>^pD@*
zr>{T78!!A5kKT7LBCLa15fBsb(i%mCfKe9cpww@m0cNP;7V1z?k6H2!F;(Pjm`RrM
zBg8R>#@g0x+MrA4u4ql!XxpYeV};O#ZqD(cH`n4AN(zf9`^7vj#1O`!FvL|~r07~6
z67QIfUrGj2GaAFB(2$ju0$uaLwN=L<5X1E`0vLlJh;VP80?BT
zhD^teV3iWLAV^(^qM|}foG=!k4D}y01mK22H)|o~vv2=F0HPQ<@+ajAA?0oCs8JBc
z4H&cw!t9cRTSuN(u9DB
zASb6Wd1bqy<(nODU;!7eIFcPyeZ_OcpBv^B5m%Rpz;a&exMVkJjKhyQ8Moj02(I|u
z&G_|kr();b_d@G-ZIPCdri>t;Z*b?%=DZg``Jm%?6j2}BVO^{cO4_#8ZCwtgRdH*+
zM6#hyN%*~6HxRHi3V@qE-C&2@TsP;Y@$}!EkCYUZ8z7*3y!ZAS`1yygR5W&Yr%EW3
zOK3z7s2fywh)6}ALJJqe_>imT!qHRWGEMmw<5DW--$aPTQBqQjDHF%Je)k_Zh%E=K
zOE`Zl`q9v(2_t{`kzr1J)}x<11>5gGa0tRsVEMK<5=ldkUcDd^VC?9T%*9z{T{ZM?
zc#Du}Ws{xLujLFJJOme9dL#4=bx3~x@%Jb3!*`!Rblkv-Dpm3Je_z5oZ@mmPM85U<
z3wYzrIQ{p(FGA_9H(tV%?GS6ub
zPA#bGhMSL7Q^x_qn|@9VWH)ckpaI1MoNd^am)5Vm81lGu(p)|TI&VXS&@?B&;l#%#F0vH0-_So?tP2^vTD}3&%T?E8Lpb5g(e@CDQK-FkslvVj9r?
z^ykOviy*ggW9+@xUQFT5xu%AMNB;B(?s@P*Jn-nlc;MkjaR0*(&Bs{erg=+dqA&qn
z2=~zIcutvsY15`bf3GWA60OW`y$q{littIWv$LDvVEylh*_;z0I4`IHR3Q_ewr$&D
z=+L1s#0?Bv68z;6*a!kEa)w4C
zRU1#_ZXw`7D9}_c(3bBn^bM4u(Cd%PYqY1Wgg{NkC&{LDaR3#PE+}(;8Hag4jpGQX
zFa}%`sL4?ADxo~g{$)I7{Wyjjo9Z)cSzb45W5?K=$n22Wjr>buo+?Il{#Wg`T3%ay7p{eE%&}
zMen}z2Ht<~ZRqAsd4m`(kTKVw<(v=^(1D1=$)%Kgi1x&?H^h#9z&)@fmx!QQZgX6B
z-R(H&(4&yiqzUo_!*}EK$vbc1lXw1yPu`2uhwr_K_n2PHmkCe?
zS&eSyfl{VaA3I`{aYtI3d$Fprz-%YEEqEbRH?8gK$P(y2PnN%j=7b={PD9ed(;%>DNU%3BSfG3
znh!tu6o!8T`t?DJ=6S3kL?0T^+qG`PgMAF2eeo5H7zp`$?}HDZ3;MqM?2VL2#KrBo
z#~#Q`PuFOC{Mn~~5GhveGP+n71pypkQy2RkBaItB0bqL}JK4TnJ80X6ddzZd`ODLP
z!&6WH6_5Xg^z_qslKv;3`756MD~ayoPd$UD{`xEm3X0(_@)7?!8K8R*H&2C}v2K@M
z?jfbz>At0u?RYBds1T0FZtxVoxNYCQ9sc{uOStmFbGe^thJwNZ8V%I^x{qL)pluUB~
z01yC4L_t*h?mG;TK+lX7@v$zKe|Q7{Q(X`!gIoauL}+x%$;;)!5C8&%+{hVjn%69x
z%}E(!*dh!JM3{&zzpNO9Bo$KQigDASE`?kjPg@Cr>J7_QY7~?Qjw*#n$9L7-h4|&o
z4>4`zL<$gqjqtSKmx&nj+DBMBXAx4vjby;)uYhtmP7PbSSeX>`?Ki;fN%{>K2;s(d
z^5n75H!vc?c)^V6lc4{OHzP9(-MjaIir#>MLjVb|Z0Qm#m_HXH%*#&jMlzSyT@?j{
z3yv#3*_eo6_3F5yH%!dqnIs{`{wV@aCH@VC2YeFn`Wm6s;>HMnW>sw~xZ|AvX-_l68m(^+8yM
zabv!9WftlQeO66|Wd(x37_AEas#^%?#y%!dKtM6m}m?;}Httu!&yYfQntP!ujk5zLQu|I%_
zI6^hAv13Mo6NmEy)Jp^DHyAK*Fu)l%dC~+F<*#AA4Dy~iZ3+t3uBL%K3*EZ+g5`W4
zpg$&0ycRE7goO*{!3r~0brVzJ`g+8X$Kd!A&Twn(n$@fE$A|C4q=};u2oI(buS6v7
zR;Z2*hy>7v#BCo80On)7#)T4te(l0A%5dox5v*FZ8kYLNm;iZs&6uubEG+dg+}NYW
zmv#LS;uj)?#=c9hxB-ouv_LE@!T;WR3BSMbcX;B_`|-awUZP>}d(4|V3xx%1*=z{1
zhDMDVkspE9W0dJqPAsw2|AwW(oSYo6JJ2`{S4Eo7W#&W5)mK#+pB6_TNl}PbQq5jN;
zkL7%4HBe$P-i+j}{S&%MjI3~5u#eO?bLPx(^##n_BCk39?goRbFGP&3f@1Ss`v4OrPUhi#9og4Cjv%*L9)y318ntQFPa0mcW(_-T
z(Y%FXpeY)=X$p*ih}IU@ahuNBP*W_TCB=wEOHjCOoii%wR|18&5jX@v;K(=(;ntZ*
z5aHa2y8j-!V#NvqXOWSf4*i|kzWw{76~{V{#^4^^yP_HQ=ay@qer;FJ6omx^l!F*j
zXe<{I)@|HATSQPo9z=xA#z|!i_589_29|xy&d$a`2Oo^AtW1V16#jYhX5+KZKXAXf
zwR(;2AwX;l+P7qz*$>k%;3g&;%};6X(NG9$MYReZ=rj}x8(3L3le
zSFND%fkI~>GhKZ
zf8bG2=u99mJa{zCZGvB)cou$r@^5k6iD%>3<9>slJ^RDxCIZ{`GOS)ilpT^_#rWA6
z^Viq$^MBsOe7G
zWQd6Stt{QB_3pn5Vl>9;rzXT{#*iXM4>w@r#*U=)h3LD}E(n8E_a7Cljk-@o7-qIy
ztXLK3Eh84F7Hjd$LZ>PMc_v$|T-&ppXUmr_WQ2v~{A%8+9W3Xa<+zIk04mlMV|>Ui
zX@B)okIn@+)Q^Z$GXAN=pXJXo({*^tD+umR^e
z^IG!4G8viK$jauzBnE^Bjumb=bweA_fVi5vA*5xXX_HnE9-uALQ28RiR*6jKHf;iH
zs1d}vTqf=~|8{)%rjkG)!Z8*}#qlSdiEQ2E88D_8YiY1))20KCr4Bmgq_c1g^^y9I
zKjkc(aPqm-TW6q0uf9pNT(hDoVk)o8r_RL>&%B0T{_kz-qRFtuF~s;5cFD*o`04pK
z@%?jeU^x$woJE`~mVKh`ps%{Eu3fVVGp9|48hr)~9s*$n{Riy|;m&UKsP9-WpmSbC
z5anCU(PKt1hsA)Mc0~}Rq3?jfEF%~<<~!I>d0=w+n{x@)xIC&I5HUFA)U$EOp+`d^
zDOk!w`XdkBfjP6MxI7h#T1BIL4E>^UY)9RVkgyhzw5a9;FAujc7F$C?vK~;;vn3+JySb
zTF0k|wpAgk57a^g+8=m0hTrf2hMaK;It)DsEeGt1#x!;{Zr>H1_Z^NsF1(8FA>=#+
zw@$DKv1a~MsJsLWF=3nDI>odB!*)TPuwvSLjCt`3jCuV_EFRB&5#<#nf<@!!V)TE%
z!I)P*$I_{@$yo^~)>^b$WGLrG43@80iJwM}LPP=_us;o32ykHOZ)oW!v@8J#x&H|1
zMyVG+{`@lt%N;yyI3i(y;rs0k!#VJQ{t!Lqi0g+w80TgYr-@D!=a564HgE|Hl)DZ0
z6~c~mY~K;OFQHn%Djssn@@hfi0@e$yECdv)1ZhwhZ>dx0(9oqwTFjhFLm3Nblbt)Z
zM_}kZnZ@;RCRQ(7
z%H=XZdTJU(1VLaBrOXS*CN&a4{;E}&J$)M1u3FA~22J_Wu6xI>=*NA_fWG}XMy+5V
zC@C&>KO(<+)k+%9iV)!onDn%C=-dftI7>@ShfvO&r{6)%Z8*)7ziMxHmqE@+h-vQ{(o=
z`IyEm6>bq>nuWENhOMqli=w2c5WoENJqv{{o{?-{p!I4#F)E
zXT`vB&B(~kp>7(4zSKi;8jQaE816S1z11DC6PmVcy~8?ec@WB9v}HzX`Uw5S+A{qQw}*jgU^DRJ~$80w=G?n$hK
z#5;mrhU^NEUEVO%SC;jNXbA3Fyl6fQ|2p!p(x_2W^6jE4JOtEz1|XN{+^wg2Tza``
zT-ZNqCjt@S;Bo9*^cjG@haZbUr=5r0F1!|dUVJ@z9(pozI`seoN`Xm%r5jzj3^Ts|
zgi=Ad<002_YuV4Bn{P)sF@a>;o!IbxYz?wzN
zA?%M(H;D>&HJQ^g@pgcZKm8P(GT3|1-FV>c$US9$#5hMjVtl~)2@8ngNBZ!ikD=Fa
z8o>q)?1wyO3^b=UoS_Ovq8J5@na6+PErw%ykh{K_&I&s7bHoO?edA!r0L7acC
zLCjyUAnr#z?8<5_+Kv%OIWod9Dd95Y%6|RzH?A*W+R;ZG3fIwcjcYz7n1J~J%X|U-
z*;&~dK*+o?PzLB$b?-80Comh<6&2u(|NW2kus8q_)=r`aVuba@CFxg39}T836Q5!r
zLKmeMv70(`7T9YH9yAcSO`0$tL(HQ&L?4a>`lsF@#7$Vg7Bb{t<2E4HTOm%vN-i>F
zU4V!HFl>)b`CIdCipuA!t$t#n|E+TQ$~Blgbt*=V9L@8Wze(O3ym__8QFGaKm^8w4uon~uxQ_|1NPl_Ux);*w)hNk
zUjNwc2-L0yt3PwNU~-*-ZZy}eT3OZ`5pn+1?52<~aGS`mW}k(2_4r545YWjjA`rvL
zZ&Dz_MU!iv>Jrrv76M(Qg~~vetWcZLg-`RrYG}JsU(Ng(~+T1cWSvAde&y3M1mA
z{41fl7c105YRJN4N7E=}V@Ty2nBz}69TEhXF?|a3QxhU=hk1A-Fa{GRjB#&Qnl){X
z!;bhBkrAw0y9U#yOoA%lP{GtP)#ayBezwYu1@l=<{qCoFsb
z01yC4L_t)*z+BLH?7)j64TwMuDj=`A?lHvfkaqxrE0>qeF+B{52qumnhiJ42xlNl=FCEP}B<|C1Gx+9M>#EEVwF3DSq-A8`lFP0~
zzd?gtmWkuX;!lsXfT6ykYQo;b4|nxx
zyvinc3DFnf`a>{AjQ9y60aDX4u>0hM+wc-$3rgfEFyu|h
z2bmGJ8-xsVuN|X|hDHFALHtid03}rSVmWX&Sm2XSKLfjsbjts&w_MNI5F+;v3b?cV6?9z*wB3Qk86@LC@giE)?P<334)$<5}lTSVgpT7G(
z9(~|`^dHz4oNhqOvRuX6LJ%0>-~)$4#Gs(C2w#5n72x`&m019EvGGe&%TuUIoIP2x
zWGR09>1X;Z4nF80^yt=&z{cy4^7Uz90fh2!&F`+nsPDhQfByL#`t{MTg7e_*7{?~B
ztc*+u|JJU{haS{5qlmD-S}~hs`H;jYaQJZgDJMbqY(SJ*Nlc@s?AA}fF$2<4QgPfd
z$B_SclCBS0qt?aIgW^rtaPj#UxID^(=85Mn_bjbn%PN&iQRYYi$C?gtQw&_)ETeU4
zTNUzG4lo}QR5`7-gxP8BQ&DjVWvdYSwfSY-U$0%e7R#0`abH=@;0u;C8lZ&o7x2uW
zb0UTK>+fo%a4)-H&RiP9Mq=9JiC8puCYCRrkEM&|Vd=tom_L7>tEZ%-2*oAEFcju&
zXB-6taE2|zdJX5cQg&t*I(6uXE_`XFW5_J^>Q=b{gNG{;X*1V
zfKXi)ELj3;LpXVI4zcyR;TXlM74T0t
zV>&kUTNvmkXml(MqC$6wwA_XD?9~Tg+5{ddLAlrR*w`f-i>x|M
z$Xiu}ja(5fu5Z2d1`^GkdfIQ%q)BraH?{y-MIz1G6zsh79@zhYLm`n2SYM@^I>Q7J9CFB!Xx*j@;^nQY4`scA
zzv>7Ah6YMQc?vmpI=8F|fxtpPH3LJJ{*bPITC{|6BB6Ej-%_~f+u^-bJ^~svsFk4N
zFYJf*N8>?1yM|;gt!v@@xmdDvG4&D9yKjFG6}~zCg|Z()L@HFTo8$50MnZL_{)oIL
zjvG4))@=kU=G8Qz{%V@n5?5Sx3%d5`%P5OcKmCBe{P_VC7OaIp=Pw{4jS_7Sn9y+COPP+x`ld)2s&IX^Tf%*rljMzQ_evP9>TRI{rVxugX%01K`f+c
zmF~E)qfuO3%<_VR4n7Q>yL8p;6)0eP!gceYgQ&0Cc5!^FY{Y1MsF2YIY9#`5zxx6U
z$8((3SY<;P%A`;jgtCKJ1ja@X^3Xf;+Yj;ieYaxe^ocOh5ENdHwcW0LO0;I!YJ86b
zgaKn25GxS!4hY<(`jh1R)q1c#SO}|`I(-Ia&YBHFEcP9`Hx!o-KK_JVw{E^^9<4g0
z&R$ngNQ2YYP~*qYefHF78pb|F%z_vS<4cKob$-15`s-M*Z~;xsz{B_4i?*#>!6YgK
z;407u`*7>cH(}_|eOw)Hzw-`OtXv7*TWCF+1>~2_IIr+oo*2~RG7=HEKe~@S{%4nL
zf&dRaa4*`lYURSr5O?{cIh{d^BMvI6b?S)eGpTF1wuuOHZTkM^#~;|X
zMbjqD(6?_Na>^k0LWvSW#&tYXOn|#?`vZD(?Fz-yP;U#EQmKCZA^v(xd8oJ
zM5vvYP`*^Ih^UsNCuP-{l-R0Bn2rnaGBIpzQ_%clAekdEn1>U
z)24_P6=A{LxtKYFbB62C+_`hGc<~~v%3qD*SP2Svo`{x|ph=Ua(E9YnODgM9gPDGk
zYu&na&^U6y@#2#*B0^?Rzb@^OlFDn(UcHc$oy!geq$uVRzc0W10?}xcVN0>noFv{V
zcnU{BK|bF9;D1=XW)-+FGmk<0_8ri+ZClD@gysD05dc)%RSSpE@GXVD$)biUSiAt1
zE0-ElI2&Q99U8UJ&0P$Z<}E-_{mTMW>Z*u^l507^LkYq45IjRyzq$riLJx+IKXMQ5
zxcL&?PP*f!OX