mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
116
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8e0e2a343 | ||
|
|
4d0586a2aa | ||
|
|
67a15b5949 | ||
|
|
6bf69a82c8 | ||
|
|
d2efaa255b | ||
|
|
a721af4552 | ||
|
|
e6b18fd126 | ||
|
|
6ee88be549 | ||
|
|
1316725fdc | ||
|
|
187ce653ae | ||
|
|
da00fa6038 | ||
|
|
a36543c1b9 | ||
|
|
dea015dc91 | ||
|
|
333dc047c3 | ||
|
|
a51c17170e | ||
|
|
6ea73a9251 | ||
|
|
20c01d5b11 | ||
|
|
ce65d5f2ad | ||
|
|
cc45191c62 | ||
|
|
d897c9a1cf | ||
|
|
880b63d2a0 | ||
|
|
eb874339dd | ||
|
|
29d3fd48c1 | ||
|
|
cf6fabc070 | ||
|
|
62b7c4903b | ||
|
|
94b26f7606 | ||
|
|
ef01fb35b3 | ||
|
|
b5ea7112a9 | ||
|
|
95b00357b6 | ||
|
|
59145c48fc | ||
|
|
8dc850f845 | ||
|
|
eea84db05e | ||
|
|
ceca85365c | ||
|
|
44439c951b | ||
|
|
b2f8b03b3c | ||
|
|
afea6d6a1c | ||
|
|
2e341e3897 | ||
|
|
5459da5f9d | ||
|
|
b00a680d42 | ||
|
|
e3c496e1a4 | ||
|
|
eb831487a0 | ||
|
|
68594ac395 | ||
|
|
ec38fd11bf | ||
|
|
06f9ff6d9c | ||
|
|
257695ff8e | ||
|
|
2cfccc745f | ||
|
|
016c23934f | ||
|
|
896dc5b177 | ||
|
|
196646a7ff | ||
|
|
1b652ceb87 | ||
|
|
8abf349cfc | ||
|
|
ae5bcf9330 | ||
|
|
78d5fcf70c | ||
|
|
1ff315a1e6 | ||
|
|
08de6667ab | ||
|
|
d27f8e77f7 | ||
|
|
e248cd8bcf | ||
|
|
73d81afd4d | ||
|
|
7884a37c55 | ||
|
|
ad89a97106 | ||
|
|
0600b7843e | ||
|
|
6d896c781e | ||
|
|
930492058b | ||
|
|
101cee0cec | ||
|
|
7752325c90 | ||
|
|
6b284598cf | ||
|
|
94bcf524a2 | ||
|
|
98966def03 | ||
|
|
e87b03b6c2 | ||
|
|
edd494ec5f | ||
|
|
00721069e1 | ||
|
|
453a5383d2 | ||
|
|
e7b95ae579 | ||
|
|
56c2c29009 | ||
|
|
7beec7194a | ||
|
|
dcc814f40c | ||
|
|
b7e94e7068 | ||
|
|
eade261763 | ||
|
|
41a82fcf02 | ||
|
|
eecf74c001 | ||
|
|
23b4dfcd82 | ||
|
|
e017b275fe | ||
|
|
e8a809ea80 | ||
|
|
8006cc5db3 | ||
|
|
0ded279b55 | ||
|
|
aa5724c390 | ||
|
|
f21df2a9fb | ||
|
|
e549e15cb8 | ||
|
|
d07b59db4e | ||
|
|
a5a7e0c94c | ||
|
|
79d7117e6d | ||
|
|
3cf486730b | ||
|
|
996b096849 | ||
|
|
ffa7fcf839 | ||
|
|
a1c69f7405 | ||
|
|
534899bc2b | ||
|
|
03d91ffddd | ||
|
|
6280998bd8 | ||
|
|
02e2f3e8b5 | ||
|
|
41300f0a34 | ||
|
|
adbc083426 | ||
|
|
3754bcd1aa | ||
|
|
f2f909ca9c | ||
|
|
1c3f2f6571 | ||
|
|
8da1bdf690 | ||
|
|
a93325b312 | ||
|
|
2d03e4efc9 | ||
|
|
ab7c502c2a | ||
|
|
546bbcbe7c | ||
|
|
774d5ff321 | ||
|
|
98ceb5da1d | ||
|
|
34fb5e49f8 | ||
|
|
9b4aab2502 | ||
|
|
829c797726 | ||
|
|
85da3bb898 | ||
|
|
29b2653801 |
@@ -22,6 +22,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check package-lock.json version sync
|
||||
run: npm run check:lockfile
|
||||
|
||||
- name: Type check
|
||||
run: npm run typecheck
|
||||
|
||||
@@ -31,6 +34,32 @@ jobs:
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
|
||||
- name: Server boot smoke test
|
||||
run: |
|
||||
set -u
|
||||
if ! command -v tmux >/dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y tmux
|
||||
fi
|
||||
npx tsx src/index.ts web --port 3151 > /tmp/boot.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
trap "kill $SERVER_PID 2>/dev/null || true" EXIT
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS http://localhost:3151/api/status -o /dev/null; then
|
||||
echo "Server booted in ${i}s"
|
||||
exit 0
|
||||
fi
|
||||
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
||||
echo "Server exited before becoming ready. Logs:"
|
||||
cat /tmp/boot.log
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Server did not respond on /api/status within 30s. Logs:"
|
||||
cat /tmp/boot.log
|
||||
exit 1
|
||||
|
||||
# Note: The test suite is intentionally excluded from CI.
|
||||
# Tests spawn real tmux sessions and require a full system environment.
|
||||
# Run tests locally with: npx vitest run test/<file>.test.ts
|
||||
|
||||
+14
-1
@@ -53,7 +53,17 @@ scripts/remotion/out/
|
||||
# Artifacts that should not be tracked
|
||||
test-results/
|
||||
tmp/
|
||||
public
|
||||
# Root `public` (a symlink to scripts/remotion/public — local artifact). ANCHORED
|
||||
# with a leading slash so it does NOT also match src/web/public (a bare `public`
|
||||
# would swallow the whole web UI source dir and silently un-stage any new asset
|
||||
# added there). No trailing slash so it still matches the symlink, not just dirs.
|
||||
/public
|
||||
|
||||
# Opt-in gesture overlay runtime assets: large MediaPipe wasm + model (~27 MB)
|
||||
# fetched at build/install by scripts/fetch-gesture-assets.mjs, kept out of git.
|
||||
# (The gesture bundle itself, gesture-codeman.js, IS tracked.)
|
||||
src/web/public/gesture/wasm/
|
||||
src/web/public/gesture/*.task
|
||||
|
||||
# Claude Code plan tracking
|
||||
plan.json
|
||||
@@ -66,3 +76,6 @@ commands
|
||||
todo.md
|
||||
@fix_plan.md
|
||||
readme-preview.mjs
|
||||
|
||||
# Uploaded images land here under each session working dir (runtime artifact)
|
||||
.claude-images/
|
||||
|
||||
@@ -2,8 +2,27 @@ dist/
|
||||
coverage/
|
||||
node_modules/
|
||||
src/web/public/vendor/
|
||||
src/web/public/gesture/
|
||||
src/web/public/app.js
|
||||
src/web/public/styles.css
|
||||
src/web/public/mobile.css
|
||||
src/web/public/index.html
|
||||
# Hand-formatted public JS modules (never prettier-enforced; the new
|
||||
# check-public-assets.mjs still validates NUL bytes + JS syntax on these).
|
||||
src/web/public/constants.js
|
||||
src/web/public/image-input.js
|
||||
src/web/public/input-cjk.js
|
||||
src/web/public/keyboard-accessory.js
|
||||
src/web/public/notification-manager.js
|
||||
src/web/public/orchestrator-panel.js
|
||||
src/web/public/panels-ui.js
|
||||
src/web/public/ralph-panel.js
|
||||
src/web/public/ralph-wizard.js
|
||||
src/web/public/respawn-ui.js
|
||||
src/web/public/session-ui.js
|
||||
src/web/public/settings-ui.js
|
||||
src/web/public/sw.js
|
||||
src/web/public/terminal-ui.js
|
||||
src/web/public/voice-input.js
|
||||
src/web/public/upload.html
|
||||
scripts/remotion/
|
||||
|
||||
+267
@@ -1,5 +1,272 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Security hardening release: network-bind policy, auth lockout recovery, download/SVG hardening, dependency & supply-chain fixes, tmux launch reliability, and a full security-architecture doc.
|
||||
|
||||
**Network binding (COD-29, #107):**
|
||||
- The web server now defaults to binding `127.0.0.1` (loopback) instead of `0.0.0.0`, so a fresh install is reachable only from the same machine and needs no password. New `--host` / `-H` / `CODEMAN_HOST` flag to choose the bind host.
|
||||
- Binding a non-loopback host **without** `CODEMAN_PASSWORD` no longer refuses to start — it **starts and prints a loud warning** with the three ways to secure it (set `CODEMAN_PASSWORD`, bind loopback + an authenticated tunnel / `tailscale serve`, or acknowledge with `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`). This keeps Codeman "just working" for new users while making remote exposure a guided, explicit choice. Host classification lives in the new `src/web/network-auth-policy.ts` (handles `127.0.0.0/8`, `::1`, `::ffff:127.*`, bracketed IPv6).
|
||||
- A post-install security note now explains the loopback default and how to expose safely.
|
||||
|
||||
**Authentication (COD-29, #107):**
|
||||
- Auth lockout now recovers gracefully: the per-IP rate-limit (`429`) check runs **after** the cookie/credential checks, so a valid session cookie or correct password is never locked out by a prior attacker's failures from the same IP (important behind a shared-IP tunnel). Wrong credentials are still counted and still hit the limit, and a `Retry-After` header is returned.
|
||||
|
||||
**Downloads & content-type hardening (COD-29, #107):**
|
||||
- New session-scoped `POST /api/download` route: realpath-bounded to the session working dir, a sensitive-path blocklist (`/etc/shadow`, `~/.ssh/`, `.env`, `*credentials*`, …), `isFile()` + 50 MB cap, forced `attachment`.
|
||||
- Workspace `.svg` files are served as `application/octet-stream` + `attachment` + `nosniff` (closes a stored-XSS-via-SVG vector); `nosniff` now applies to all `file-raw` responses.
|
||||
|
||||
**Dependencies & supply chain (COD-28, #106):**
|
||||
- Bumped security-sensitive deps to patched versions (`@fastify/static` 9, `fastify` 5.8, `uuid` 14, `vitest` 4.1, …) and added `overrides` for patched transitives (`picomatch`, `basic-ftp`, `fast-uri`, `flatted`); `npm audit` goes from 7 advisories to 0.
|
||||
- New `npm run check:public-assets` (`scripts/check-public-assets.mjs`): scans `src/web/public/**` for literal NUL bytes and runs `node --check` on every `.js` file, plus a Prettier pass on maintained files. Removed literal NUL placeholders from `app.js`. Added `test/dependency-security.test.ts` and `test/frontend-public-tooling.test.ts`.
|
||||
|
||||
**tmux launch reliability (COD-31, #110):**
|
||||
- New tmux sessions and respawns launch from a stable `/tmp` and `cd` into the workspace inside the pane, avoiding `new-session` crashes when a FUSE/rclone-mounted workspace has a transient mount blip at launch. The `cd "<dir>" && <cmd>` form is fail-safe (the CLI never runs in `/tmp`) and the path is validated + double-quoted.
|
||||
|
||||
**Test stability (COD-30, #108):**
|
||||
- Cleared leaked auth env in the Vitest setup, corrected stale route status-code / SSE-lifecycle expectations to match shipped behavior, updated the mobile keyboard accessory expectations, and measured DOMContentLoaded via browser navigation timing. Also fixed the `WebServer` title tests for the new `host` constructor arg + async `renderIndexHtml`.
|
||||
|
||||
**Docs:**
|
||||
- New `docs/security-architecture.md` documenting the full model (network binding, auth pipeline, the tunnel `req.ip` caveat, file-serving hardening, supply-chain, multi-instance isolation, security headers, and recommended secure setups). CLAUDE.md updated accordingly.
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Session detach/undock, opt-in gesture-control overlay, multi-monitor spanning, new App-Settings toggles, and asset cache-busting.
|
||||
- **Session detach/undock + instance isolation (#103):** Detach a session into its own solo (popup) window from the tab strip. Adds multi-instance isolation primitives in `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`) keyed off `CODEMAN_INSTANCE`, so a beta can run side-by-side with prod without discovering/attaching to prod's live tmux sessions or clobbering its `state.json`. `CODEMAN_INSTANCE` defaults to the production layout (`~/.codeman`, `-L codeman`, port 3000), so master installs are unaffected. Adds `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`). The legacy `~/.claudeman` migration is now scoped to the default instance only. Hardened detach edge cases. Tests: `test/config/instance.test.ts`.
|
||||
- **Gesture-control overlay (Phase 5, opt-in via `CODEMAN_GESTURE=1`):** Camera hand-tracking overlay (self-hosted MediaPipe — wasm + model fetched at install/build via `scripts/fetch-gesture-assets.mjs` rather than committed). `CODEMAN_GESTURE=1` makes the feature _available_ (CSP widening + `/gesture/` assets + `window.__codemanGestureAvailable`); the per-user **Gesture Control (beta)** toggle (App Settings → Display → Input, default OFF) is the actual on/off and reloads the page to inject/remove the bundle. Dashboard-only (not solo popups). Labeled "(beta)" (#109).
|
||||
- **Multi-monitor button:** Header button (opt-in via App Settings → Display → Header Displays) that POSTs `/api/system/span-displays` to spawn `scripts/span-codeman.sh` — a maximized browser `--app` window sized to the union of all displays, so the gesture layer's floating panels can drag across the physical monitor seam. Tests: `test/routes/system-span-displays.test.ts`.
|
||||
- **New App-Settings toggles (#105):** Gesture control and the multi-monitor button are both opt-in (default OFF), with live show/hide on save.
|
||||
- **Asset cache-busting:** `renderIndexHtml` appends `?v=<mtime>` to every same-origin `.js`/`.css` reference; `index.html` is served `no-cache`, so a normal reload picks up edited modules/styles without a hard refresh. Tests: `test/render-index-html.test.ts`.
|
||||
- **Gesture Control toggle placement:** the toggle now lives inside the existing **Input** settings section (alongside Local Echo / CJK Input / Extended Keyboard Bar) instead of a duplicate "Input" section; only the toggle itself is hidden when `CODEMAN_GESTURE=1` is unset, leaving the rest of the section intact.
|
||||
- **Service env:** `scripts/codeman-web.service` now sets `CODEMAN_GESTURE=1` so the gesture feature is available on the local install (still gated behind the default-OFF per-user toggle).
|
||||
- **Docs:** CLAUDE.md updated for the orchestrator loop, multi-monitor/span-displays, cache-busting, gesture/multi-monitor toggles, and structural-count fixes.
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Thinking Effort now flows as a soft default the user can override in-session (PR #104, by @TeigenZhang).
|
||||
|
||||
Previously Codeman carried the effort setting as the `CLAUDE_CODE_EFFORT_LEVEL` env var, which Claude Code treats as a hard override — it locked effort for the whole session and rejected in-session `/effort` switching (including switching to `ultracode`). Effort is now injected at spawn time as a CLI soft default that `/effort` can still change freely in either direction:
|
||||
- Regular levels (`low`/`medium`/`high`/`xhigh`/`max`) are passed via `claude --effort <level>` (the settings `effortLevel` key silently drops `max`, so the flag is used instead).
|
||||
- `ultracode` (xhigh effort + standing dynamic-workflow orchestration) is passed via `claude --settings '{"ultracode":true}'`, since the `--effort` flag rejects it.
|
||||
|
||||
Details:
|
||||
- New `effort` field on the create-session, quick-start, and Ralph-loop request schemas; threaded through `Session._effort` to both spawn paths (tmux `buildSpawnCommand` and direct-PTY `buildInteractiveArgs`), persisted in `SessionState.effort`, and restored on reboot recovery.
|
||||
- `buildEffortCliArgs()` is the single, allowlist-validated source for both carriers (injection-safe).
|
||||
- Settings UI adds an "Ultracode (multi-agent workflows)" option to the Thinking Effort dropdown; the frontend no longer emits `CLAUDE_CODE_EFFORT_LEVEL`.
|
||||
- Legacy migration: sessions persisted with the old env var are auto-migrated into the new `effort` field, and the stale tmux env var is unset so respawned panes are no longer locked.
|
||||
- Adds `test/effort-injection.test.ts` (13 cases) covering carrier mapping, injection guards, args building, and constructor migration.
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Event-loop responsiveness fix, mobile image upload, response-viewer polish, and a mobile-UI trim.
|
||||
- **fix: avoid event-loop stalls from synchronous tmux/ps calls (#100):** The session manager ran `execSync` for tmux mouse-mode toggles, `list-panes`, and `ps`/`pgrep` resource-stat queries on the main thread. Under multi-session / many-pane load these blocking spawns froze Node's single event loop, stalling SSE broadcasts and PTY I/O (the ":3000 briefly unreachable, process never restarts" class of incident). Converted those calls to async `execAsync` and updated all callers to `await`. Added a lightweight `utils/event-loop-monitor.ts` that samples loop-delay and logs when a stall threshold is exceeded, started on web-server boot and stopped on shutdown — so future regressions leave a timestamped, quantified log line instead of vanishing silently.
|
||||
- **feat(web): mobile image upload to active session via paste dialog (#101):** The mobile keyboard-accessory paste dialog now attaches images, not just text — via a native picker (`accept=image/*` → camera / photo library / files) plus best-effort capture of images pasted into the textarea. Both paths reuse the existing `_uploadAndInsertImages()` → `POST /api/sessions/:id/paste-image` pipeline. Images are re-encoded client-side before upload (PNG→PNG to preserve transparency, everything else→JPEG, animated GIFs passed through untouched) so the bytes always match their declared extension — fixing the Android/MIUI case where a WebP/HEIF mislabeled as `image/jpeg` passed the extension allowlist but failed the server's magic-byte check. The server logs a precise diagnostic on any remaining magic-byte mismatch.
|
||||
- **feat(web): response-viewer transcript fallback + code-block rendering (#102):** A substantial response-viewer styling overhaul — proportional prose font (monospace kept for code), refined heading/code/blockquote/list styling, readable max content width, and a smoother slide-in animation; the `.rv-text` rules now also apply to `.response-viewer-body` so transcript-missing fallback content gets the same typography. Plus a `_renderMarkdown` null-safety fix (`text` → `src = text || ''`).
|
||||
- **feat(web): remove /compact button from the mobile keyboard accessory bar:** Dropped `/compact` from both the simple and extended accessory-bar layouts and the associated action handling. `/clear` retains its double-tap confirmation. Verified on a touch-emulated viewport that neither layout renders a compact action.
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **fix(respawn): auto-accept now fires on plan approvals after `Worked for X` line, and on AskUserQuestion menus**
|
||||
|
||||
Two related blockers in the respawn controller's auto-accept path:
|
||||
- Modern Claude Code emits `✻ Worked for Xm Ys` immediately before a plan-approval menu. `_detectCompletionMessage()` cancelled the auto-accept timer and `canAutoAccept()` then rejected on `completionMessageTime !== null`, so plan approvals **never** auto-accepted — the 10 s completion-confirm timer instead started a respawn cycle while the menu sat unanswered.
|
||||
- The same logic in `signalElicitation()` set a hard flag that blocked auto-accept whenever Claude Code fired the `elicitation_dialog` hook, contradicting the in-UI hint ("Auto-accept presses Enter for plan approvals **and default question options**"). AskUserQuestion menus were therefore never auto-accepted either.
|
||||
|
||||
Fix:
|
||||
- `_detectCompletionMessage()` no longer cancels the auto-accept timer; the auto-accept pre-filter is now the authoritative "is there a numbered selection menu?" gate.
|
||||
- `canAutoAccept()` and the AI-plan-check callback both accept `'watching'` AND `'confirming_idle'` states (covers the single-PTY-burst case where `Worked for` and the menu arrive together — `_detectCompletionMessage` returns early before the substantial-output check can demote state back to watching). `sendAutoAcceptEnter()` self-transitions back to `'watching'` before sending Enter.
|
||||
- `signalElicitation()` is now an affirmative hint that primes the auto-accept timer instead of blocking. Still gated on `config.autoAcceptPrompts` AND state ∈ {`watching`, `confirming_idle`} — never fires Enter when respawn is off or auto-accept is disabled.
|
||||
- AI plan-check prompt broadened to recognize AskUserQuestion / elicitation menus as valid for auto-accept (the verdict name `PLAN_MODE` is preserved for compatibility but now means "auto-accept this selection menu").
|
||||
- Removed the now-unused `elicitationDetected` field and its assignments.
|
||||
|
||||
Two new regression tests cover both the separate-PTY-chunk and single-PTY-chunk cases; the previously misleading "should NOT send Enter when completion message was detected" test was renamed and re-scoped to clarify it tests the **no-menu** path (which still correctly rejects via the pre-filter).
|
||||
|
||||
**docs(web): correct `sendPendingCtrlL` comment** — removed the stale "called by foo/bar" note from the dead-call-graph helper after #99.
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Response viewer & terminal-stability improvements, plus test/error-handling hardening.
|
||||
- **Copy button on code blocks (#98):** Every fenced code block in the response viewer now has a one-click copy button pinned to its top-right, outside the `<pre>` scroll container so it stays put during horizontal scroll. ASCII diagrams keep their line-wrap toggle alongside it. Copy prefers the async Clipboard API and falls back to a hidden-textarea + `execCommand` path, so it works over plain HTTP (tunnel) too, with a brief ✓/✕ feedback state.
|
||||
- **Fix: stop auto-sending Ctrl+L from session-selection paths (#99):** A fast page refresh or SSE reconnect could fire two programmatic Ctrl+L (`\x0c`) sends within Claude Code 2.x's "clear conversation" confirmation window, silently wiping the active conversation. Removed the automatic Ctrl+L sends from `selectSession()`, `restoreTerminalSize()`, and the dead `sendPendingCtrlL()` path; redraws now rely on resize/SIGWINCH. User-initiated Ctrl+L still works. Trade-off: an occasional transient stale Ink frame right after refresh that self-heals on the next keypress — far preferable to silent data loss.
|
||||
- **Test & error-handling hardening (#97):** Repaired route-test harness error rendering via a dedicated `route-error-handler.ts`, and stopped the AI idle/plan checkers from spawning real processes during tests.
|
||||
|
||||
## 0.6.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix new-session crash after a tmux upgrade and isolate Codeman sessions on a dedicated tmux socket.
|
||||
- **Pane file-descriptor limit**: raise `ulimit -Sn` before launching the CLI (in both the spawn and respawn paths) so the newer tmux + macOS launchd combination — which hands panes a low soft `nofile` limit (256) that recent Claude Code refuses to start under — no longer kills every freshly spawned session on startup.
|
||||
- **Single-socket isolation**: all Codeman-owned tmux sessions now live on a dedicated socket (`tmux -L codeman`, overridable via `CODEMAN_TMUX_SOCKET`), fully separated from the user's default tmux server. The socket name is validated and shell-escaped at every call site.
|
||||
- **Drop the drift-prone per-session `tmuxSocket` field**: session reconciliation collapses to a single `list-panes` query against the one socket, eliminating live sessions being wrongly marked dead ("session not found") and duplicate "Restored:" tabs. Stale per-session socket tags and duplicate records are cleaned from disk on load (dedup by `muxName`, keeping the real entry over `restored-` placeholders).
|
||||
- **Route remaining bare-`tmux` call sites through the socket**: the window-size query on re-attach (previously fell back to 120×40 and lost scrollback) and the send-key route (Shift+Enter / Ctrl+Enter newline).
|
||||
- **SSH chooser scripts** (`tmux-manager.sh`, `tmux-chooser.sh`) route every tmux call through the dedicated socket.
|
||||
|
||||
## 0.6.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Resume Conversation: fixes and folder drill-down.
|
||||
- **fix(history)**: `decodeProjectKey()` now uses longest-join-first backtracking with on-disk validation, so sibling directories sharing a prefix (e.g. `diary/` vs `diary-app/`) resolve to the correct path. Previously the greedy shortest-match decoder picked the shorter name and bailed, surfacing `$HOME` in the Resume Conversation list and resuming into the wrong folder. Greedy decode is kept as a fallback so history for deleted projects still resolves. (#92)
|
||||
- **fix(tabs)**: Drop the client-side resurrection of ended-session tabs. The old code cached open tabs in `localStorage` and rebuilt them as grayed-out stubs whenever the server no longer knew them, which left phantom tabs after closing a session on another device. The server is now the single source of truth; legacy `localStorage` keys are purged on init. Net -44 / +6 lines. (#93)
|
||||
- **feat(history)**: New "View all in this folder" drill-down on Resume Conversation. `GET /api/history/sessions` accepts `projectKey` (validated against `^[A-Za-z0-9_-]+$` before any filesystem access), `offset`, and `limit`; single-folder mode bypasses the 50-cap and returns `{ sessions, total }`. Frontend adds a modal listing 20 sessions per page with a "Show more" pagination button. Modal items omit their own "View all" button to prevent recursive entry points. (#94)
|
||||
|
||||
## 0.6.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ## Security: paste-image endpoint hardening (#90)
|
||||
|
||||
Addresses seven findings from the dismissed review of #84. Most exposed in tunneled deployments where `CODEMAN_PASSWORD` is set but the server is reachable beyond localhost.
|
||||
- **CSRF protection** on `POST /api/sessions/:id/paste-image`. Requires `Origin`/`Referer` to match `req.host`; non-browser clients (no `Origin` and no `Referer`) must send `X-Codeman-CSRF`. Defeats cross-origin `<form enctype="multipart/form-data">` submits that would otherwise plant arbitrary bytes into the victim's `.claude-images/` while their session cookie is live.
|
||||
- **Magic-byte validation** on uploaded images. Sniffs the first 12 bytes against PNG/JPEG/GIF/WebP/BMP signatures and rejects 415 on mismatch. Polyglot HTML-or-SVG-with-image-MIME no longer round-trips through the endpoint.
|
||||
- **Symlink-safe writes** on `.claude-images/`. `lstat` before the write, non-recursive `mkdir`, `O_EXCL|O_NOFOLLOW` on file open. A `node_modules` postinstall (or the agent itself) planting `.claude-images -> ~/.ssh/` no longer redirects pastes outside `workingDir`.
|
||||
- **Multipart parser swap** to `@fastify/multipart` with `limits: { fileSize: 10MB, files: 1, fields: 4 }`. Replaces a hand-rolled boundary scanner that matched the literal boundary anywhere in the body, hard-coded `\r\n` (silently corrupting LF-only clients), and had no part-count cap.
|
||||
- **Rate limit + GC**: token-bucket (30/min per IP+session) and hourly GC of `paste-*` files older than 7 days from each live session's `.claude-images/`. New `paste-image-gc.ts` started/stopped from `WebServer.start/stop`.
|
||||
- **Collision-free filenames**: `paste-${Date.now()}-${randomBytes(4)}${ext}`. Two tabs pasting in the same millisecond no longer silently last-write-wins.
|
||||
- **Bracketed-paste preservation**: text-only paste in `image-input.js` now goes through `terminal.paste(text)` instead of `sendInput(text)`, so xterm preserves `CSI 200~ ... CSI 201~` markers — Claude Code uses them as part of its prompt-injection defenses.
|
||||
|
||||
## Fix: duplicate multipart parser conflict
|
||||
|
||||
Removed a duplicate multipart content-type parser left behind after the swap above. The duplicate registration conflicted with `@fastify/multipart`'s own parser; uploads now flow through the plugin exclusively.
|
||||
|
||||
## WebGL renderer auto-fallback hardening (#91)
|
||||
|
||||
Follow-ups on the longtask auto-fallback shipped in #83.
|
||||
- `PerformanceObserver` is now disconnected on `onContextLoss` as well as on the trip path. Previously the observer outlived its disposed addon after a context loss, holding a closure reference over every longtask the page emitted.
|
||||
- Thresholds (`200ms / 3 longtasks / 30s window / 5s grace / 7d sticky-disable`) are hoisted to `WEBGL_FALLBACK` in `constants.js`. No more inline literals.
|
||||
- New `evaluateWebGLLongTaskTrip()` pure helper splits the rolling-window arithmetic from the `PerformanceObserver` callback so the trip math is unit-testable. New `test/webgl-fallback.test.ts` (9 tests, port 3166): trip inside window, no-trip when spread, sub-threshold filtering, stale-entry pruning, cumulative counting across batches, observer-dispose idempotency.
|
||||
|
||||
## CI: server boot smoke test
|
||||
|
||||
GitHub Actions now boots the server as a final step after typecheck/lint/format. Catches production-only ESM/CJS regressions that `tsx` masks in dev.
|
||||
|
||||
## Docs
|
||||
|
||||
`CLAUDE.md` frontend-module table updated to include `image-input.js` (overlooked when #84 landed).
|
||||
|
||||
## 0.6.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Terminal renderer hardening, SSE bandwidth cut, image paste, and a security tightening on the new live filter:
|
||||
- **Multi-primitive yield for write pacing** (#85): replaces six raw `requestAnimationFrame` callsites in the xterm.js write pipeline with a yielding helper that races `requestAnimationFrame`, `setTimeout(50)`, and a tick Worker. Keeps the terminal responsive when the tab is backgrounded or occluded — Chrome's intensive-throttling no longer stalls long writes.
|
||||
- **WebGL longtask auto-fallback** (#83): a `PerformanceObserver` watches for ≥200ms WebGL frames; three within a 30s window disposes the WebGL addon and falls back to the canvas renderer. Decision is persisted in localStorage for 7 days, and `?webgl=force` clears it.
|
||||
- **Per-client live SSE subscription filter** (#86): each connected client gets a stable UUID and can narrow its terminal stream to one session via `POST /api/events/subscribe` — no EventSource reconnect on tab switches. Cuts SSE bandwidth roughly N× when N sessions are open. Lifecycle/metadata events (`session:*`, `case:*`, `ralph:*`, `hook:*`) now broadcast to every client so sidebars stay in sync.
|
||||
- **Image paste and drag-and-drop into the terminal** (#84): `Ctrl+V` and dropped images upload to `POST /api/sessions/:id/paste-image`, save under `${workingDir}/.claude-images/paste-${ts}.${ext}` and type the path into the terminal. Hard 10MB cap, server-generated filename (no traversal), `.svg` deliberately excluded from the allowlist to avoid a same-origin XSS path through `file-raw`.
|
||||
- **SSE clientId validation**: the per-client identifier introduced in #86 is now constrained to `[A-Za-z0-9_-]{8,64}` at both ingress points. Without this, an authenticated attacker could send another tab's clientId to silently evict it from broadcasts, mutate any clientId's session filter to blackhole the victim's terminal stream, or grow `sseClientsById` unboundedly via long IDs. The subscribe payload is also capped at 64 session entries of ≤128 chars each.
|
||||
|
||||
## 0.6.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Finish the hostname-aware notification plumbing started in 0.6.7 and lock down the recent UI/runtime fixes with regression tests.
|
||||
- Browser Notification API (OS-level desktop pop-ups, layer 3 of the 5-layer notification system) now uses `${originalTitle}: ${title}` instead of the hardcoded `Codeman:` literal — so multi-host users running Codeman on laptop / dev box / NAS see `codeman:<host>: <event>` consistently across tab title, tab-flash, Web Push, and OS notifications.
|
||||
- Inline session rename hardened against three corner cases: IME composition commits (Chinese pinyin Enter no longer ships half-composed text as the session name), mid-rename SSE deletion (orphaned `<input>` no longer 404s on blur), and double-fire on stuck settle-once flag (closure-local `settled` boolean replaces the boolean instance flag).
|
||||
- Test coverage backfilled for two prior shipped fixes:
|
||||
- `<title>codeman:<host></title>` server-side templating (#82): 8 tests covering default `os.hostname()`, `--title-hostname` override, HTML-escape against `<script>`-style breakout, ampersand non-double-encoding, and template-tail byte-identical invariance.
|
||||
- tmux size-query helper (#80): 15 tests covering the browser-resize-between-attaches happy path, the query-then-die race, zero/negative/empty/non-numeric output fallbacks, and argv-form/timeout assertions that lock down the no-shell-interpolation guarantee. Inline 14-line query block extracted into a named `queryTmuxWindowSize()` export in `session.ts` so the test surface is a pure function.
|
||||
- Regression coverage added for `stripInkRedrawBloat` route helper.
|
||||
- CLAUDE.md and README.md updated to document dual-CLI env-prefix discipline (`CLAUDE_CODE_*` vs `OPENCODE_*`), the `xterm-zerolag-input` published-package side-effect of overlay edits, and the unified hostname prefix across tab title / tab-flash / OS notifications.
|
||||
|
||||
## 0.6.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- - **fix(client): preserve inline rename input across tab re-renders** (#81) — Right-click → rename on a session tab no longer loses keystrokes when SSE traffic from sibling sessions triggers a tab re-render. Adds an `_inlineRenameActive` guard at the top of `renderSessionTabs()` and `_fullRenameSessionTabs()` so the in-progress input isn't destroyed mid-typing. Also fixes a latent double-fire of `finishRename` (blur + Enter could both invoke it). Drive-by: safer DOM child clearing in place of `innerHTML = ''`.
|
||||
- **feat: hostname-aware window title** (#82) — The browser tab title is now `codeman:<hostname>` instead of the bare `Codeman` literal, so users running Codeman on multiple hosts (laptop, dev box, NAS) can tell at a glance which tab points at which backend. New `--title-hostname <name>` CLI flag overrides the detected `os.hostname()` when it's noisy or you want a cosmetic name. The title is templated into the served HTML on first byte (with narrow HTML escaping), so it's correct from the first paint and works without JavaScript. Title-flash logic now respects the per-host title.
|
||||
- **perf: larger terminal tail on tab switch** — `TERMINAL_TAIL_SIZE` raised from 128KB to 1MB. When switching back to a busy session tab you now get ~8× more scrollback restored immediately.
|
||||
- **fix: preserve response text in Ink redraw stripping** — `stripInkRedrawBloat()` rewritten from a first-VPA approach to cluster-based detection. The previous algorithm assumed all VPA escapes after the first one belonged to a single redraw region and discarded everything in between, which silently lost 100KB+ of legitimate Claude response text once a render had occurred. The new approach groups VPAs into clusters separated by ≥8KB gaps and only collapses clusters spanning ≥32KB, so streamed response content between redraw bursts is preserved.
|
||||
- **docs**: `CLAUDE.md` Additional Commands gains the `--title-hostname` row; `README.md` gets a "Hostname-Aware Window Title" subsection under Multi-Session Dashboard.
|
||||
|
||||
## 0.6.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Terminal scrollback significantly increased** — both the xterm.js viewport and the tmux backing buffer were bottlenecking how far back you could scroll. Three changes:
|
||||
- `DEFAULT_SCROLLBACK` raised from 20000 → 50000 lines (xterm.js, main terminal). The previous bump from 5000 only helped users with empty localStorage; existing users were stuck on whatever value they first picked up. The loader now treats `DEFAULT_SCROLLBACK` as a floor — if your stored value is below the new minimum, you're raised to it automatically.
|
||||
- Subagent / teammate terminals (`panels-ui.js`) were stuck at 5000; now use the same `DEFAULT_SCROLLBACK` constant (50000).
|
||||
- New tmux sessions now run with `history-limit 50000` (tmux defaults to 2000). This matters for hard-reload / re-attach — without it, only the last ~2000 lines survive the round-trip back into a fresh xterm.
|
||||
|
||||
**Tmux flicker on session re-attach fixed (PR #80 by @aakhter)**: the PTY now queries the existing tmux window size via `tmux display -p` before spawning, instead of hardcoding 120x40. Previously, every re-attach forced tmux to resize down to 120x40, causing a visible flicker and one frame of scrollback loss. The `-x 120 -y 40` flag was also dropped from `tmux new-session` so the initial size matches the first attaching client. Uses `execFileSync` (not shell) for safety and falls back to 120x40 on any error.
|
||||
|
||||
**Docs**: CLAUDE.md now documents two recurring foot-guns — the `xterm-zerolag-input` overlay code is duplicated between `packages/xterm-zerolag-input/src/` and inline inside `src/web/public/app.js`, so any overlay change must touch both; and the COM workflow explicitly includes a post-push `gh run watch` step to confirm CI before considering the release done.
|
||||
|
||||
## 0.6.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Mobile fix**
|
||||
- Android virtual keyboard: space character was silently dropped on touch devices using GBoard / SwiftKey / similar IMEs. Root cause: the input-event handler in `terminal-ui.js` treated any whitespace-only textarea value as proof that xterm had already processed the input. A lone space (`' '.trim() === ''`) tripped this guard, so the space was consumed but never forwarded. Now skips only when the textarea is truly empty (or whitespace from a non-space key). Reported and diagnosed by @coolk8 in #79.
|
||||
|
||||
**Docs**
|
||||
- `CLAUDE.md`: added Zod `.optional()`-vs-`null` gotcha (recurring trap from 0.6.3 / 0.6.4 incidents) and a more visible warning against running bare `npm test` (kills the host tmux session).
|
||||
- `docs/local-echo-overlay-plan.md`: marked SHIPPED, corrected xterm version reference (v5.3.0 → `@xterm/xterm` ^6.0.0).
|
||||
|
||||
## 0.6.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix "Failed to enable respawn: Invalid request body" error when selecting infinity duration (∞) in the respawn modal. Frontend was sending `durationMinutes: null`, which Zod's `.optional()` schema rejected (it accepts `undefined` only). The body now omits the field when no duration is selected.
|
||||
|
||||
## 0.6.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Fix**
|
||||
- Allowlist `opusContext1mEnabled` in `SettingsUpdateSchema`. Without this entry, the strict schema rejected `PUT /api/settings {"opusContext1mEnabled":...}` with `INVALID_INPUT`, so the toggle's value never persisted across reloads. The frontend was already reading and writing this key (`settings-ui.js:336/1137`, `session-ui.js:340`), so saves were silently failing — users never noticed because the load path falls back to `false` on missing keys, hiding the bug. (#78)
|
||||
|
||||
## 0.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Mobile UX**
|
||||
- Resume Conversation list (welcome page) reworked for narrow screens: 2-line title clamp so more of the first prompt is visible; case-aware subtitle that renders `#caseName` (or `#caseName/sub`) when `workingDir` matches a known case, otherwise falls back to the directory basename; inline `⋯` toggle that expands a detail panel with full prompt, full path, timestamp, size, and short session id; `/Users/<user>/` now collapses to `~/` alongside `/home/<user>/`. (#77)
|
||||
- Response viewer: ASCII diagram wrap toggle, dedicated mobile code-block layout, and chrome-stripping fallback when the model wraps its reply in extra markup. (#75)
|
||||
- Mobile keyboard accessory bar no longer triggers vertical scroll. (#72)
|
||||
|
||||
**Sessions & settings**
|
||||
- New `thinkingEffort` setting on session creation, with `xhigh` option and `/effort max` mobile shortcut. (#73)
|
||||
- `thinkingEffort` is now allowlisted in `SettingsUpdateSchema` so it round-trips through PATCH /api/settings.
|
||||
- `envOverrides` (`CLAUDE_CODE_*` / `OPENCODE_*`) are now passed to Claude via tmux env exports at spawn time instead of being written to `<case>/.claude/settings.local.json`. Eliminates UI/disk drift; the value lives on `Session._envOverrides`, is exported by `tmux-manager.buildEnvExports()`, and is persisted in `SessionState.envOverrides`. (#74)
|
||||
|
||||
**Fixes**
|
||||
- Eye icon (active-session indicator) now follows `/clear` to the new Claude conversation instead of getting stuck on the previous transcript. (#76)
|
||||
- `tmux-manager.reconcileSessions` now uses `|` as the field separator, fixing parsing when session names contain other delimiters. (#71)
|
||||
|
||||
**Docs**
|
||||
- CLAUDE.md: added `npm run knip` to the dead-code sweep table and a `Common Gotchas` entry documenting the `envOverrides` → tmux export flow.
|
||||
|
||||
## 0.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Internal cleanup and release hygiene:
|
||||
- **Dead-code sweep via knip**: added `knip.json` for dead-code detection and ran a full sweep — removed unused test files, unused scripts, and narrowed internal module exports to the minimum surface area actually consumed.
|
||||
- **Lockfile drift prevention**: `version-packages` now runs `npm install --package-lock-only` and verifies the lockfile is in sync via `scripts/check-lockfile-sync.mjs`; CI runs the same check on every push/PR so version drift fails the build instead of reaching production. Resolves the `package-lock.json` / `package.json` version mismatch that shipped in 0.6.0.
|
||||
- **Docs tightening**: archived 22 completed plan docs from `docs/`, corrected file/handler counts in `CLAUDE.md`, documented the lockfile step in the COM workflow, and removed footer redundancy.
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Community contributions from @aakhter:
|
||||
- **feat (#66): Tab reorder shortcuts** — `Ctrl+Shift+{` and `Ctrl+Shift+}` move the active session tab left/right, matching WezTerm convention. Order persists across reloads via `saveSessionOrder()`.
|
||||
- **feat (#67): Active tab visibility + Alt+N badges** — active tab now has a bright green border with color-matched glow, and the first 9 tabs display number badges hinting at the `Alt+N` switch shortcut. Badges update on reorder/rerender.
|
||||
- **feat (#68): Clipboard API** — new `POST /api/clipboard` accepting `{text}` broadcasts a `clipboard:write` SSE event; connected browsers attempt `navigator.clipboard.writeText()` with a manual-copy modal fallback when the page isn't focused. Auth-protected via the standard middleware. Useful for pushing snippets from remote sessions to the user's local clipboard.
|
||||
- **fix (#65): Android Shift+key double character** — pressing `Shift+A` on attached Android keyboards no longer produces "AA". Tracks xterm-handled keydown timestamps and skips the orphaned-input listener for 50ms after a real keydown, while still catching Gboard symbol-keyboard inputs (keyCode 229).
|
||||
|
||||
## 0.5.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -10,7 +10,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
| Type check | `tsc --noEmit` |
|
||||
| Lint | `npm run lint` (fix: `npm run lint:fix`) |
|
||||
| Format | `npm run format` (check: `npm run format:check`) |
|
||||
| Single test | `npx vitest run test/<file>.test.ts` |
|
||||
| Single test | `npm test -- test/<file>.test.ts` (or `npx vitest run --config config/vitest.config.ts test/<file>.test.ts`) — ⚠ **never** run bare `npm test`, see Testing section |
|
||||
| Build | `npm run build` (esbuild via `scripts/build.mjs`, NOT tsc — `tsc --noEmit` is type-check only) |
|
||||
| Production | `npm run build && systemctl --user restart codeman-web` |
|
||||
|
||||
## CRITICAL: Session Safety
|
||||
@@ -29,7 +30,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
2. **Frontend changes**: Use Playwright to load the page and assert the UI renders correctly. Use `waitUntil: 'domcontentloaded'` (not `networkidle` — SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values
|
||||
3. **Only after verification passes**, proceed with COM
|
||||
|
||||
The production server caches static files for 1 year (`maxAge: '1y'` in `server.ts`). After deploying frontend changes, users may need a hard refresh (Ctrl+Shift+R) to see updates.
|
||||
The production server caches static files for 1 year, `immutable` (`maxAge: '1y'` in `server.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=<mtime>` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an *absolute* URL or from JS rather than a `<script>/<link>` tag, it won't be auto-busted.
|
||||
|
||||
## COM Shorthand (Deployment)
|
||||
|
||||
@@ -48,11 +49,14 @@ When user says "COM":
|
||||
CHANGESET
|
||||
```
|
||||
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
|
||||
3. **Consume the changeset**: `npm run version-packages` (bumps versions in `package.json` files and updates `CHANGELOG.md`)
|
||||
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions)
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
||||
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
6. **Wait for CI**: after `git push`, find the run with `gh run list -L 1 --json databaseId,headBranch -q '.[0].databaseId'` and watch it with `gh run watch <id> --exit-status`. Confirm all checks pass before considering the release done.
|
||||
|
||||
**Version**: 0.5.13 (must match `package.json`)
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 0.9.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -68,17 +72,21 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
## Additional Commands
|
||||
|
||||
`npm run dev` = dev server. Default port: `3000`. Commands not in Quick Reference:
|
||||
`npm run dev` = dev server. Default port: `3000` (override with `--port` or the `CODEMAN_PORT` env var). To run this beta isolated alongside a prod Codeman, use `scripts/run-beta.sh` (sets `CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`). Commands not in Quick Reference:
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Dev with TLS | `npx tsx src/index.ts web --https` |
|
||||
| Override window title hostname | `npx tsx src/index.ts web --title-hostname <name>` (default: `os.hostname()` — `codeman:<name>` is used for tab title, title-flash, and OS desktop notification prefix) |
|
||||
| Bind a non-loopback host | `npx tsx src/index.ts web --host 0.0.0.0` (or `-H`; env `CODEMAN_HOST`; default `127.0.0.1`). Without `CODEMAN_PASSWORD` it **starts but warns loudly** — see Common Gotchas + `docs/security-architecture.md` |
|
||||
| Continuous typecheck | `tsc --noEmit --watch` |
|
||||
| Test coverage | `npm run test:coverage` |
|
||||
| Dead-code sweep | `npm run knip` (config in `knip.json`) |
|
||||
| Check public-asset formatting | `npm run check:public-assets` (prettier-checks `src/web/public/**` text assets; `scripts/check-public-assets.mjs`) |
|
||||
| Production start | `npm run start` |
|
||||
| Production logs | `journalctl --user -u codeman-web -f` |
|
||||
|
||||
**CI**: `.github/workflows/ci.yml` runs `typecheck`, `lint`, `format:check` on push to master/main and on PRs (Node 22). Tests excluded (they spawn tmux).
|
||||
**CI**: `.github/workflows/ci.yml` runs `check:lockfile`, `typecheck`, `lint`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s) on push to master/main and on PRs (Node 22). The unit test suite is excluded (it spawns tmux).
|
||||
|
||||
**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/**`.
|
||||
|
||||
@@ -87,7 +95,14 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink
|
||||
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly
|
||||
- **Global regex `lastIndex`** — Use `createAnsiPatternFull/Simple()` factories, not shared `g`-flag patterns in loops
|
||||
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
||||
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` 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 `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
|
||||
- **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 <level>` 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`
|
||||
- **Dual-CLI prefix discipline** — Codeman supports both Claude Code and OpenCode (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward both prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
|
||||
- **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 duplicated** — the local-echo overlay lives in BOTH `packages/xterm-zerolag-input/src/` (published to npm as a standalone library for external consumers — see README "Published Packages") AND inline inside `src/web/public/app.js` (runtime copy the web UI actually loads, since the page ships as plain JS without a bundler). Any change to overlay behavior MUST be applied to both, or dev and prod diverge — and a public API break in the package warrants a separate version bump for `xterm-zerolag-input` 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-<name>` + `-L codeman-<name>`), 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', …)`.
|
||||
|
||||
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
||||
|
||||
@@ -99,7 +114,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|--------|-----------|-------|
|
||||
| **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` | |
|
||||
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` | |
|
||||
| **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 |
|
||||
@@ -109,15 +124,15 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **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` | |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
|
||||
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~2.8K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
|
||||
| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
|
||||
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~3.4K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 14 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
||||
★ = Large file (>50KB). 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 package**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; copy embedded in `app.js`.
|
||||
|
||||
**Config**: `src/config/` — 9 files. Import from specific files, not barrel.
|
||||
**Config**: `src/config/` — 10 files. Import from specific files, not barrel.
|
||||
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
|
||||
|
||||
@@ -134,6 +149,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.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`.
|
||||
|
||||
**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`.
|
||||
|
||||
**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/`.
|
||||
@@ -148,15 +165,22 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Z-index layers**: subagent windows (1000), plan agents (1100), log viewers (2000), image popups (3000), local echo overlay (7).
|
||||
|
||||
**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** (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).
|
||||
|
||||
**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+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font).
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font).
|
||||
|
||||
### 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.
|
||||
|
||||
| Layer | Details |
|
||||
|-------|---------|
|
||||
| **Auth** | Optional HTTP Basic via `CODEMAN_USERNAME`/`CODEMAN_PASSWORD` env vars |
|
||||
| **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` |
|
||||
| **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 |
|
||||
@@ -167,11 +191,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### SSE Event Registry
|
||||
|
||||
~117 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
|
||||
~120 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
|
||||
|
||||
### API Routes
|
||||
|
||||
~124 handlers across 14 route files in `src/web/routes/`: system (36), sessions (25), orchestrator (10), ralph (9), plan (8), respawn (7), cases (7), files (5), mux (5), scheduled (4), push (4), teams (2), hooks (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~130 handlers across 15 route files in `src/web/routes/`: system (37, incl. `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (5), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
## Adding Features
|
||||
|
||||
@@ -193,22 +217,20 @@ All in `~/.codeman/`: `state.json` (sessions, settings, respawn), `mux-sessions.
|
||||
**CRITICAL: You are running inside a Codeman-managed tmux session.** Never run `npx vitest run` (full suite) — it spawns/kills tmux sessions and will crash your own session. Only run individual files:
|
||||
|
||||
```bash
|
||||
npx vitest run test/<specific-file>.test.ts # Single file (SAFE)
|
||||
npx vitest run -t "pattern" # By name (SAFE)
|
||||
# npx vitest run # DANGEROUS — DON'T DO THIS
|
||||
npm test -- test/<specific-file>.test.ts # Single file (SAFE, uses config/vitest.config.ts)
|
||||
npm test -- -t "pattern" # By name (SAFE)
|
||||
# npm test # DANGEROUS — runs full suite, DON'T DO THIS
|
||||
```
|
||||
|
||||
Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pass `--config config/vitest.config.ts`.
|
||||
|
||||
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s.
|
||||
|
||||
**Safety**: `test/setup.ts` snapshots pre-existing tmux sessions and never kills them. Only `registerTestTmuxSession()` sessions get cleaned up.
|
||||
|
||||
**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
|
||||
|
||||
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject()` in `test/routes/`. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles).
|
||||
|
||||
## Screenshots
|
||||
|
||||
Mobile screenshots in `~/.codeman/screenshots/`. API: `GET /api/screenshots`, `POST /api/screenshots`.
|
||||
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles).
|
||||
|
||||
## Debugging
|
||||
|
||||
@@ -220,27 +242,14 @@ curl localhost:3000/api/subagents | jq # Background agents
|
||||
cat ~/.codeman/state.json | jq # Persisted state
|
||||
```
|
||||
|
||||
Mobile screenshots: `~/.codeman/screenshots/`, accessed via `GET/POST /api/screenshots`.
|
||||
|
||||
## Performance & Limits
|
||||
|
||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 2MB, text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||
|
||||
## References
|
||||
**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`.
|
||||
|
||||
Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`, `orchestrator-loop-architecture.md`, `browser-testing-guide.md`. Agent Teams: `docs/agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
|
||||
## Scripts & Tunnel
|
||||
|
||||
## Scripts
|
||||
|
||||
Key: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh` (tunnel start/stop/url). Production: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`.
|
||||
|
||||
## Memory Leak Prevention
|
||||
|
||||
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: `npx vitest run test/memory-leak-prevention.test.ts`.
|
||||
|
||||
## Common Workflows
|
||||
|
||||
**Bug investigation**: Dev server → reproduce in browser → check terminal + `~/.codeman/state.json`.
|
||||
**Respawn changes**: Read `docs/respawn-state-machine.md` first. Use `MockSession` from `test/respawn-test-utils.ts`.
|
||||
|
||||
## Tunnel
|
||||
|
||||
`./scripts/tunnel.sh start|stop|url`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
|
||||
Key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh start|stop|url` (tunnel). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
|
||||
|
||||
@@ -226,6 +226,17 @@ Run **20 parallel sessions** with full visibility — real-time xterm.js termina
|
||||
|
||||
Every session runs inside **tmux** — sessions survive server restarts, network drops, and machine sleep. Auto-recovery on startup with dual redundancy. Ghost session discovery finds orphaned tmux sessions. Managed sessions are environment-tagged so the agent won't kill its own session.
|
||||
|
||||
### Hostname-Aware Window Title
|
||||
|
||||
Running Codeman on multiple hosts (laptop, dev box, NAS)? The browser tab title is `codeman:<hostname>` so you can tell which backend each tab points at without clicking in:
|
||||
|
||||
```bash
|
||||
codeman web # codeman:<os.hostname()>
|
||||
codeman web --title-hostname dev-box # codeman:dev-box (manual override for noisy hostnames)
|
||||
```
|
||||
|
||||
The title is templated into the served HTML on first byte, so it's correct from the very first paint and works without JavaScript. The same hostname prefix is applied to the tab-flash format (`⚠️ (N) codeman:<host>`) and to OS-level desktop notifications (`codeman:<host>: <event>`), so cross-host alerts in the system notification center are also unambiguous.
|
||||
|
||||
### Smart Token Management
|
||||
|
||||
| Threshold | Action | Result |
|
||||
@@ -401,9 +412,12 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|
||||
| `Ctrl+Enter` | Quick-start session |
|
||||
| `Ctrl+W` | Close session |
|
||||
| `Ctrl+Tab` | Next session |
|
||||
| `Alt+1`–`Alt+9` | Switch to tab N |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move active tab left / right |
|
||||
| `Ctrl+K` | Kill all sessions |
|
||||
| `Ctrl+L` | Clear terminal |
|
||||
| `Ctrl+Shift+R` | Restore terminal size |
|
||||
| `Ctrl+Shift+V` | Toggle voice input |
|
||||
| `Ctrl/Cmd +/-` | Font size |
|
||||
| `Escape` | Close panels |
|
||||
|
||||
@@ -446,6 +460,7 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|
||||
| `GET` | `/api/events` | SSE stream |
|
||||
| `GET` | `/api/status` | Full app state |
|
||||
| `POST` | `/api/hook-event` | Hook callbacks |
|
||||
| `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) |
|
||||
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
> **⚠️ ARCHIVED 2026-05-21 — superseded, kept for history.**
|
||||
> The headline items here were verified resolved: the P0 `{WORKING_DIR}` placeholder
|
||||
> is now replaced (`plan-orchestrator.ts:431`), and the "~66 dead functions in app.js"
|
||||
> are gone (app.js was modularized 15K→3K LOC). A fresh `npm run knip` sweep on
|
||||
> 2026-05-21 found only a handful of unused test helpers. Do not treat this as a live TODO.
|
||||
|
||||
# Codebase Cleanup Findings
|
||||
|
||||
Compiled from parallel analysis of the entire Codeman codebase by 3 research agents (2026-02-19).
|
||||
@@ -1,3 +1,9 @@
|
||||
> **⚠️ ARCHIVED 2026-05-21 — superseded, kept for history.**
|
||||
> The "Critical" structural items here are done: `server.ts` 6,736→2,065 LOC,
|
||||
> `app.js` 15,196→3,083 LOC, `types.ts` 1,443→12 LOC (now a barrel → `src/types/`).
|
||||
> The phase plans that executed this work are in `docs/archive/phase*-plan.md`.
|
||||
> Do not treat this as a live TODO; see CLAUDE.md for current architecture.
|
||||
|
||||
# Code Structure & Quality Findings
|
||||
|
||||
**Date**: 2026-02-28
|
||||
@@ -1,5 +1,7 @@
|
||||
# Local Echo Overlay — Implementation Plan
|
||||
|
||||
> **Status: SHIPPED.** Implementation lives in `packages/xterm-zerolag-input/src/` (overlay-renderer.ts, prompt-finder.ts, cell-dimensions.ts, zerolag-input-addon.ts) with the embedded copy in `src/web/public/app.js`. This document is retained as historical design context.
|
||||
|
||||
## Context
|
||||
|
||||
User accesses Codeman remotely from Thailand to Switzerland over Tailscale (~200-300ms RTT).
|
||||
@@ -18,9 +20,9 @@ redraws. A DOM overlay sits in a separate rendering layer (z-index 7) and doesn'
|
||||
with Ink's cursor management or screen redraws at all. When Ink redraws (server output arrives),
|
||||
we simply hide the overlay.
|
||||
|
||||
**Why it will look indistinguishable:** We use the DOM renderer (not canvas/WebGL) in our
|
||||
xterm.js v5.3.0, so both terminal text and overlay text are rendered by the same browser
|
||||
font engine with identical sub-pixel rendering.
|
||||
**Why it will look indistinguishable:** We use the DOM renderer (not canvas/WebGL), so both
|
||||
terminal text and overlay text are rendered by the same browser font engine with identical
|
||||
sub-pixel rendering. (Originally designed against xterm.js v5.3.0; project now on `@xterm/xterm` ^6.0.0 — the internal `_core._renderService.dimensions` access path still works in v6.)
|
||||
|
||||
## Key Technical Details (from research)
|
||||
|
||||
@@ -36,7 +38,7 @@ const top = cursorY * dims.css.cell.height; // CSS pixels, relative to .xterm-
|
||||
- `cursorY` = `terminal.buffer.active.cursorY` (0 to terminal.rows-1, ALREADY viewport-relative)
|
||||
- No scroll offset math needed
|
||||
|
||||
### Cell Dimensions (v5.3.0 — no public API, use internal)
|
||||
### Cell Dimensions (no public API in v5/v6 — use internal; public in v7+)
|
||||
```js
|
||||
const dims = terminal._core._renderService.dimensions;
|
||||
dims.css.cell.width // e.g., 8.4px
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
# Security Architecture
|
||||
|
||||
This document describes Codeman's security model: how it decides who may reach
|
||||
the web UI, how requests are authenticated, how the file-serving and tmux layers
|
||||
are hardened, and the recommended ways to expose an instance safely.
|
||||
|
||||
Codeman spawns and drives Claude/OpenCode CLIs with
|
||||
`--dangerously-skip-permissions`. **Anyone who can reach an unauthenticated
|
||||
instance can run arbitrary commands as your user.** The defaults below are chosen
|
||||
so that a fresh install is safe on the machine it runs on, while remote access is
|
||||
an explicit, guided opt‑in.
|
||||
|
||||
> TL;DR — Codeman binds **loopback only (`127.0.0.1`) by default**, so out of the
|
||||
> box it is reachable only from the same machine and needs no password. To reach
|
||||
> it from elsewhere, either put it behind an **authenticated tunnel**
|
||||
> (`tailscale serve` / `cloudflared`) **or** bind a wider host **and set
|
||||
> `CODEMAN_PASSWORD`**. If you bind a non‑loopback host with no password, Codeman
|
||||
> still starts but prints a **loud warning** telling you how to secure it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Network binding model
|
||||
|
||||
| Setting | Default | Source |
|
||||
|---------|---------|--------|
|
||||
| Bind host | `127.0.0.1` (loopback) | `--host` / `CODEMAN_HOST` → `WebServer` ctor |
|
||||
| Port | `3000` | `--port` / `CODEMAN_PORT` |
|
||||
| TLS | off (`--https` to enable) | `--https` |
|
||||
|
||||
### Bind host classification
|
||||
|
||||
`isLoopbackBindHost()` (`src/web/network-auth-policy.ts`) decides whether a bind
|
||||
host is loopback-only. It returns `true` for:
|
||||
|
||||
- `localhost`
|
||||
- any IPv4 in `127.0.0.0/8` (e.g. `127.0.0.1`, `127.42.0.9`)
|
||||
- IPv6 loopback `::1` (bracketed `[::1]` and the long form `0:0:0:0:0:0:0:1`)
|
||||
- IPv4‑mapped loopback `::ffff:127.*`
|
||||
|
||||
It returns `false` for `0.0.0.0`, `::` (all interfaces), LAN IPs, and hostnames.
|
||||
The classification is **fail‑safe in the dangerous direction**: any host that is
|
||||
not provably loopback is treated as non‑loopback (it never mistakes `0.0.0.0`
|
||||
for loopback). Shorthand forms like `127.1` or integer/octal IPs classify as
|
||||
non‑loopback (you'll get a warning, not a silent wide‑open bind) — use
|
||||
`127.0.0.1` for an unambiguous loopback bind.
|
||||
|
||||
### Startup policy (the "warn, don't block" rule)
|
||||
|
||||
At `WebServer.start()`:
|
||||
|
||||
| Bind host | `CODEMAN_PASSWORD` | Behavior |
|
||||
|-----------|--------------------|----------|
|
||||
| loopback (default) | unset | **Start.** Safe — reachable only from this machine. |
|
||||
| loopback | set | **Start.** Auth required even locally. |
|
||||
| non‑loopback | set | **Start.** Auth protects the open bind. |
|
||||
| non‑loopback | unset | **Start + LOUD warning** listing how to secure it. |
|
||||
| non‑loopback | unset, `--allow-unauthenticated-network` | **Start + terse acknowledged note.** |
|
||||
|
||||
> History: an earlier iteration (unreleased COD‑29) *refused to start* on a
|
||||
> non‑loopback bind without a password. That surprised setups that "just worked"
|
||||
> before, so **0.9.0 changed it to start‑and‑warn**. Loopback is still the safe
|
||||
> default; the warning (with three concrete fixes) replaces the hard failure.
|
||||
|
||||
The warning points at three ways to secure the instance:
|
||||
|
||||
1. `CODEMAN_PASSWORD=<password>` — turns on HTTP Basic auth (see §2).
|
||||
2. `--host 127.0.0.1` + an authenticated tunnel (`cloudflared` / `tailscale serve`).
|
||||
3. `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1`
|
||||
— explicitly accept the risk (downgrades the warning to a one‑line note). This
|
||||
flag is **only** an acknowledgement; it does not change reachability.
|
||||
|
||||
`CODEMAN_API_URL` (used by hooks/child processes) is always derived as a loopback
|
||||
address (`0.0.0.0`/`localhost`/`::1` → `127.0.0.1`) so in‑process hooks reach the
|
||||
server over loopback regardless of the public bind.
|
||||
|
||||
---
|
||||
|
||||
## 2. Authentication
|
||||
|
||||
Auth is **optional** and controlled by env vars captured at startup:
|
||||
|
||||
- `CODEMAN_USERNAME` (default `admin` when only a password is set)
|
||||
- `CODEMAN_PASSWORD`
|
||||
|
||||
When `CODEMAN_PASSWORD` is unset, no auth is enforced — which is why the default
|
||||
loopback bind matters. The auth pipeline (`src/web/middleware/auth.ts`,
|
||||
`onRequest` hook) runs in this order:
|
||||
|
||||
1. **Localhost‑only exemptions** (always first): `POST /api/hook-event` and the QR
|
||||
`/q/` short‑code path are exempt when `req.ip` is loopback (see §3).
|
||||
2. **Session cookie** check — a valid `codeman_session` cookie short‑circuits to
|
||||
allow.
|
||||
3. **HTTP Basic** check — correct credentials short‑circuit to allow and clear
|
||||
that IP's failure counter.
|
||||
4. **Rate‑limit gate** — if neither cookie nor credentials passed and the IP is
|
||||
locked out, return `429` with a `Retry-After` header.
|
||||
5. Otherwise return `401`, incrementing the IP's failure counter.
|
||||
|
||||
### Session cookies
|
||||
|
||||
On successful Basic auth the server issues `codeman_session`, an opaque
|
||||
server‑side token (`randomBytes(32)`), valid 24h with auto‑extend and device
|
||||
context for the audit log. Tokens are **not** client‑signed — they're validated
|
||||
by presence in a server‑side map, so they cannot be forged offline.
|
||||
|
||||
### Rate limiting / lockout recovery
|
||||
|
||||
Failed auth is tracked **per IP**: 10 failures → `429`, with a 15‑minute decay.
|
||||
The QR path has its own separate limiter.
|
||||
|
||||
The lockout check sits **after** the cookie/credential checks (step 4, not first).
|
||||
This is deliberate: a user with a **valid cookie or correct password recovers
|
||||
immediately** even while an attacker is hammering the same IP — important because
|
||||
all traffic through a tunnel shares one source IP (loopback). Wrong credentials
|
||||
are still counted and still hit the `429` at the threshold, so brute‑force
|
||||
protection is unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 3. Request‑origin trust & the tunnel caveat
|
||||
|
||||
`req.ip` is derived from the **TCP socket only** — Fastify runs with
|
||||
`trustProxy: false`, so `X-Forwarded-For` / `X-Real-IP` / `Forwarded` are
|
||||
**ignored**. A remote client cannot forge `req.ip` to `127.0.0.1`.
|
||||
|
||||
**However**, a reverse tunnel that connects to the server over loopback (e.g.
|
||||
`cloudflared --url http://localhost:3000`) makes **every tunneled request arrive
|
||||
with `req.ip = 127.0.0.1`**. The localhost‑only exemptions then treat those
|
||||
requests as local:
|
||||
|
||||
- `POST /api/hook-event` — auth‑exempt for loopback. Bounded impact: it is
|
||||
`HookEventSchema`‑validated and requires a valid in‑memory `sessionId`; it can
|
||||
drive respawn signals, SSE broadcasts, push notifications, and transcript
|
||||
watching — **not** arbitrary terminal input or file reads. It is a
|
||||
session‑disruption / notification‑spoofing surface, not RCE.
|
||||
- QR `/q/` — still protected by its own short‑code brute‑force limiter
|
||||
(10 failures / 60s against a 62⁶ space).
|
||||
|
||||
**Mitigation:** set `CODEMAN_PASSWORD` whenever a loopback‑connecting tunnel is
|
||||
up (it does not gate the hook‑event exemption, but it gates everything else and
|
||||
is the documented practice). Prefer `tailscale serve` (below), which authenticates
|
||||
at the tailnet layer so untrusted clients never reach the loopback port at all.
|
||||
A future hardening could gate the hook‑event exemption on a shared secret while a
|
||||
tunnel is active.
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended remote‑access setups
|
||||
|
||||
Ordered most‑to‑least recommended:
|
||||
|
||||
### A. Tailscale serve (recommended)
|
||||
|
||||
Bind loopback, let Tailscale front it on your tailnet with a real cert:
|
||||
|
||||
```bash
|
||||
codeman web --https # binds 127.0.0.1:3000
|
||||
tailscale serve --bg https / http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
Only devices on your tailnet can reach it; Tailscale handles identity. No app
|
||||
password and no `0.0.0.0` bind required. (This is the maintainer's production
|
||||
setup.)
|
||||
|
||||
### B. Authenticated cloudflared tunnel + password
|
||||
|
||||
```bash
|
||||
export CODEMAN_PASSWORD=<password>
|
||||
codeman web --https
|
||||
cloudflared tunnel --url https://localhost:3000
|
||||
```
|
||||
|
||||
Always set `CODEMAN_PASSWORD` here — the tunnel connects over loopback, so the
|
||||
hook‑event exemption (§3) would otherwise be reachable from the public URL.
|
||||
|
||||
### C. Direct LAN bind + password
|
||||
|
||||
```bash
|
||||
export CODEMAN_PASSWORD=<password>
|
||||
codeman web --https --host 0.0.0.0
|
||||
```
|
||||
|
||||
Exposes the port on all interfaces; the password is the only thing protecting it.
|
||||
|
||||
### Avoid
|
||||
|
||||
`--host 0.0.0.0` **without** a password. Codeman will start (and warn), but
|
||||
anyone on the network can control your Claude sessions. Never re‑expose `0.0.0.0`
|
||||
without a password.
|
||||
|
||||
---
|
||||
|
||||
## 5. File‑serving hardening
|
||||
|
||||
Three routes serve workspace files; all require a valid `sessionId` and run the
|
||||
shared path validator `validateSessionFilePath()` (`src/web/route-helpers.ts`):
|
||||
it `realpath`s the target **before** the boundary check and rejects anything that
|
||||
escapes the session working directory (`..`, absolute paths, and symlinks that
|
||||
resolve outside). The realpath‑before‑check ordering closes the validation‑time
|
||||
TOCTOU window.
|
||||
|
||||
| Route | Cap | Notes |
|
||||
|-------|-----|-------|
|
||||
| `file-content` | 10 MB | text preview |
|
||||
| `file-raw` | 50 MB | inline MIME map; **`X-Content-Type-Options: nosniff` on all responses** |
|
||||
| `POST /api/download` | 50 MB | forced `attachment`; sensitive‑path blocklist |
|
||||
|
||||
### SVG / content‑type XSS
|
||||
|
||||
A workspace `.svg` served inline as `image/svg+xml` is a stored‑XSS vector (SVG
|
||||
can carry `<script>`, same‑origin = full session control). `file-raw` therefore
|
||||
serves `.svg` as `application/octet-stream` + `Content-Disposition: attachment` +
|
||||
`nosniff`. With global `nosniff` + CSP `default-src 'self'`, other text types
|
||||
(`.html`, `.xml`, …) that fall through to `octet-stream` are not rendered as HTML
|
||||
either. Trusted QR/welcome SVGs are injected from API JSON (`innerHTML`), not via
|
||||
`file-raw`, so they are unaffected.
|
||||
|
||||
### Download sensitive‑path blocklist
|
||||
|
||||
`/api/download` additionally refuses a blocklist of sensitive paths
|
||||
(`/etc/shadow`, `~/.ssh/`, `.env`, `*credentials*`, `.aws/credentials`, …). This
|
||||
is **defense‑in‑depth, not the primary boundary** — the realpath containment is
|
||||
the control.
|
||||
|
||||
### Known limitation — `workingDir` scope
|
||||
|
||||
The file‑route boundary is the session's `workingDir`, and `POST /api/sessions`
|
||||
currently accepts an arbitrary absolute `workingDir` (validated as "exists + is a
|
||||
directory"). A session created with `workingDir=/` can therefore read files
|
||||
across the filesystem within that boundary. This is **pre‑existing** across all
|
||||
file routes and not widened by the recent changes. Recommended follow‑up:
|
||||
constrain `workingDir` to an allowlist (e.g. under the cases dir / `$HOME`).
|
||||
|
||||
---
|
||||
|
||||
## 6. tmux launch hardening (COD‑31)
|
||||
|
||||
New sessions and respawns launch the tmux server/pane from a stable `/tmp`
|
||||
(`TMUX_LAUNCH_CWD`) and then `cd` into the real workspace **inside** the pane,
|
||||
against the live mount table:
|
||||
|
||||
```
|
||||
respawn-pane -k -c /tmp -t <session> bash -c "cd <workingDir> && <cmd>"
|
||||
```
|
||||
|
||||
This avoids a class of failures on FUSE/rclone‑mounted workspaces where a
|
||||
transient mount blip at launch poisons tmux's long‑lived cwd and crashes
|
||||
`new-session`. Safety properties:
|
||||
|
||||
- **Fail‑safe cwd:** the command is `cd "<dir>" && <cmd>` — if `cd` fails the CLI
|
||||
does **not** run in `/tmp`; the pane dies with a visible error instead.
|
||||
- **No injection:** `workingDir` passes `isValidWorkingDir` (absolute, rejects
|
||||
`;&|$\`(){}<>'"` and newlines and `..`) and `isValidPath`, and is double‑quoted
|
||||
in the pane command. Paths with spaces work; metacharacters are rejected before
|
||||
reaching the shell.
|
||||
- It does not change which tmux socket is targeted, so instance isolation (§8) is
|
||||
preserved.
|
||||
|
||||
---
|
||||
|
||||
## 7. Supply‑chain & build‑asset hardening (COD‑28)
|
||||
|
||||
- **Dependency advisories:** security‑sensitive ranges are bumped to patched
|
||||
versions, and `overrides` force patched transitive deps (`picomatch`,
|
||||
`basic-ftp`, `fast-uri`, `flatted`). `test/dependency-security.test.ts` asserts
|
||||
these stay patched in the lockfile.
|
||||
- **Lockfile integrity:** `npm run check:lockfile` (CI on every push/PR) fails on
|
||||
drift between `package.json` and `package-lock.json`. All lockfile entries
|
||||
resolve to `registry.npmjs.org` with `sha512` integrity hashes.
|
||||
- **Public‑asset checker:** `npm run check:public-assets`
|
||||
(`scripts/check-public-assets.mjs`) scans `src/web/public/**` for literal NUL
|
||||
bytes and runs `node --check` on every `.js` file (syntax validation), plus a
|
||||
Prettier pass on maintained files. It uses `execFileSync` with argv arrays (no
|
||||
shell), so filenames/content cannot inject commands; `node --check` only parses,
|
||||
never executes. Large hand‑formatted/generated assets (`app.js`, the gesture
|
||||
bundle, vendored libs) are `.prettierignore`d for the style pass, but the NUL +
|
||||
syntax checks still cover them.
|
||||
|
||||
---
|
||||
|
||||
## 8. Multi‑instance isolation
|
||||
|
||||
The tmux socket (`tmux -L codeman[-<instance>]`) and data dir
|
||||
(`~/.codeman[-<instance>]`) are **process‑wide and shared by every Codeman on the
|
||||
machine**, derived from `CODEMAN_INSTANCE` (`src/config/instance.ts`). A second
|
||||
instance on the **same** socket discovers and attaches PTYs to the first
|
||||
instance's live sessions. To run instances side by side, give each a distinct
|
||||
`CODEMAN_INSTANCE` (scopes both dir + socket), or set `CODEMAN_TMUX_SOCKET` +
|
||||
`CODEMAN_DATA_DIR` individually. `CODEMAN_INSTANCE` defaults to empty = the
|
||||
production layout (`~/.codeman`, `-L codeman`, port 3000).
|
||||
|
||||
---
|
||||
|
||||
## 9. Transport security headers
|
||||
|
||||
`registerSecurityHeaders` applies on every response:
|
||||
|
||||
- `Content-Security-Policy: default-src 'self'` (widened only for `/gesture/`
|
||||
assets when `CODEMAN_GESTURE=1`, to load self‑hosted MediaPipe)
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options`
|
||||
- `Strict-Transport-Security` when served over HTTPS
|
||||
- CORS restricted to localhost origins
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick reference
|
||||
|
||||
| Env / flag | Effect |
|
||||
|------------|--------|
|
||||
| `CODEMAN_PASSWORD` (+ `CODEMAN_USERNAME`) | Enable HTTP Basic auth |
|
||||
| `--host` / `CODEMAN_HOST` | Bind host (default `127.0.0.1`) |
|
||||
| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning) |
|
||||
| `--https` | Enable TLS (adds HSTS) |
|
||||
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation |
|
||||
| `CODEMAN_GESTURE=1` | Make the gesture overlay available (widens CSP) |
|
||||
|
||||
**Audit log:** session lifecycle and server start are recorded in
|
||||
`~/.codeman/session-lifecycle.jsonl`.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"entry": [
|
||||
"scripts/*.mjs",
|
||||
"scripts/*.js",
|
||||
"scripts/watch-subagents.ts",
|
||||
"scripts/remotion/Root.tsx",
|
||||
"scripts/remotion/index.ts",
|
||||
"test/**/*.test.ts",
|
||||
"test/mobile/vitest.config.ts",
|
||||
"test/**/*.mjs"
|
||||
],
|
||||
"project": ["src/**/*.{ts,tsx}", "scripts/**/*.{ts,tsx,mjs,js}", "test/**/*.{ts,mjs}"],
|
||||
"ignoreExportsUsedInFile": true,
|
||||
"ignoreDependencies": ["@remotion/cli", "@remotion/transitions", "esbuild", "agent-browser"]
|
||||
}
|
||||
Generated
+4891
-1689
File diff suppressed because it is too large
Load Diff
+30
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.5.13",
|
||||
"version": "0.9.0",
|
||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -21,11 +21,14 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
|
||||
"format": "prettier --write 'src/**/*.ts'",
|
||||
"format:check": "prettier --check 'src/**/*.ts'",
|
||||
"format": "prettier --write 'src/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'",
|
||||
"format:check": "prettier --check 'src/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'",
|
||||
"check:public-assets": "node scripts/check-public-assets.mjs",
|
||||
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
|
||||
"changeset": "changeset",
|
||||
"version-packages": "changeset version",
|
||||
"version-packages": "changeset version && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs",
|
||||
"check:lockfile": "node scripts/check-lockfile-sync.mjs",
|
||||
"knip": "npx --yes knip@latest",
|
||||
"release": "changeset publish"
|
||||
},
|
||||
"workspaces": [
|
||||
@@ -50,7 +53,8 @@
|
||||
"dependencies": {
|
||||
"@fastify/compress": "^8.3.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/static": "^8.0.0",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
@@ -59,18 +63,18 @@
|
||||
"chalk": "^5.3.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^5.1.0",
|
||||
"fastify": "^5.8.5",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^10.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.29.8",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@remotion/cli": "4.0.429",
|
||||
"@remotion/transitions": "4.0.429",
|
||||
"@remotion/cli": "4.0.473",
|
||||
"@remotion/transitions": "4.0.473",
|
||||
"@types/node": "^20.19.33",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
@@ -78,7 +82,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"agent-browser": "^0.6.0",
|
||||
"esbuild": "^0.27.3",
|
||||
"eslint": "^9.0.0",
|
||||
@@ -87,16 +91,30 @@
|
||||
"pngjs": "^7.0.0",
|
||||
"prettier": "^3.4.0",
|
||||
"puppeteer": "^24.36.0",
|
||||
"remotion": "4.0.429",
|
||||
"remotion": "4.0.473",
|
||||
"tsx": "^4.15.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
|
||||
"@rspack/binding-linux-x64-gnu": "^1.7.7"
|
||||
},
|
||||
"overrides": {
|
||||
"basic-ftp": "^5.3.1",
|
||||
"fast-uri": "^3.1.2",
|
||||
"flatted": "^3.4.2",
|
||||
"anymatch": {
|
||||
"picomatch": "^2.3.2"
|
||||
},
|
||||
"micromatch": {
|
||||
"picomatch": "^2.3.2"
|
||||
},
|
||||
"readdirp": {
|
||||
"picomatch": "^2.3.2"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
|
||||
+1095
-891
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,6 @@
|
||||
"jsdom": "^24.1.3",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.1.9"
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ const PORTS = {
|
||||
|
||||
const results = [];
|
||||
|
||||
function isCodemanTitle(title) {
|
||||
return typeof title === 'string' && title.startsWith('codeman:');
|
||||
}
|
||||
|
||||
function logSection(title) {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(` ${title}`);
|
||||
@@ -88,7 +92,7 @@ async function main() {
|
||||
const page = await playwrightBrowser.newPage();
|
||||
await page.goto(`http://localhost:${PORTS.playwright}`);
|
||||
const title = await page.title();
|
||||
if (title !== 'Codeman') throw new Error(`Expected Codeman, got ${title}`);
|
||||
if (!isCodemanTitle(title)) throw new Error(`Expected codeman:<hostname>, got ${title}`);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
@@ -149,7 +153,7 @@ async function main() {
|
||||
const page = await puppeteerBrowser.newPage();
|
||||
await page.goto(`http://localhost:${PORTS.puppeteer}`);
|
||||
const title = await page.title();
|
||||
if (title !== 'Codeman') throw new Error(`Expected Codeman, got ${title}`);
|
||||
if (!isCodemanTitle(title)) throw new Error(`Expected codeman:<hostname>, got ${title}`);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
@@ -202,7 +206,7 @@ async function main() {
|
||||
agentBrowser(`open http://localhost:${PORTS.agentBrowser}`);
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
const title = agentBrowserJson('get title');
|
||||
agentBrowserAvailable = title.title === 'Codeman';
|
||||
agentBrowserAvailable = isCodemanTitle(title.title);
|
||||
console.log(' Browser launched');
|
||||
|
||||
// Test 1: Page load
|
||||
@@ -210,7 +214,7 @@ async function main() {
|
||||
agentBrowser(`open http://localhost:${PORTS.agentBrowser}`);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
const title = agentBrowserJson('get title');
|
||||
if (title.title !== 'Codeman') throw new Error(`Expected Codeman, got ${title.title}`);
|
||||
if (!isCodemanTitle(title.title)) throw new Error(`Expected codeman:<hostname>, got ${title.title}`);
|
||||
});
|
||||
|
||||
// Test 2: Element selection
|
||||
|
||||
@@ -32,6 +32,9 @@ run('chmod dist/index.js', 'chmod +x dist/index.js');
|
||||
// 2. Copy static assets (clean first to remove stale hashed files from previous builds)
|
||||
run('clean public', 'rm -rf dist/web/public');
|
||||
run('prepare dirs', 'mkdir -p dist/web dist/templates dist/web/public/vendor');
|
||||
// Fetch the opt-in gesture overlay's MediaPipe wasm + model into src/ (idempotent,
|
||||
// non-fatal, kept out of git) so the copy below carries them into dist/.
|
||||
run('gesture assets', 'node scripts/fetch-gesture-assets.mjs');
|
||||
run('copy web assets', 'cp -r src/web/public dist/web/');
|
||||
run('copy template', 'cp src/templates/case-template.md dist/templates/');
|
||||
|
||||
@@ -93,6 +96,7 @@ console.log('\n[build] content-hash cache busting');
|
||||
'ralph-wizard.js',
|
||||
'api-client.js',
|
||||
'subagent-windows.js',
|
||||
'image-input.js',
|
||||
'vendor/xterm-zerolag-input.js',
|
||||
];
|
||||
const manifest = {};
|
||||
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
// Fails if package-lock.json's version fields don't match package.json.
|
||||
// Changesets bumps package.json but NOT the lockfile — this catches that drift
|
||||
// (the top-level `version` in lockfiles is metadata, so `npm ci` won't flag it).
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const pkg = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8'));
|
||||
const lock = JSON.parse(readFileSync(resolve(repoRoot, 'package-lock.json'), 'utf8'));
|
||||
|
||||
const expected = pkg.version;
|
||||
const rootVersion = lock.version;
|
||||
const selfVersion = lock.packages?.['']?.version;
|
||||
|
||||
const mismatches = [];
|
||||
if (rootVersion !== expected) mismatches.push(` package-lock.json#.version = ${rootVersion} (expected ${expected})`);
|
||||
if (selfVersion !== expected) mismatches.push(` package-lock.json#.packages[""].version = ${selfVersion} (expected ${expected})`);
|
||||
|
||||
if (mismatches.length > 0) {
|
||||
console.error(`\nLockfile version drift detected (package.json is ${expected}):`);
|
||||
console.error(mismatches.join('\n'));
|
||||
console.error('\nFix: run `npm install --package-lock-only` and commit the updated package-lock.json.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Lockfile in sync with package.json (${expected}).`);
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, extname, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const publicRoot = resolve(repoRoot, 'src/web/public');
|
||||
const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier');
|
||||
const checkedExtensions = new Set(['.js', '.css', '.html', '.json']);
|
||||
|
||||
function collectTextAssets(dir) {
|
||||
const files = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...collectTextAssets(fullPath));
|
||||
continue;
|
||||
}
|
||||
if (checkedExtensions.has(extname(entry.name))) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function findNullByte(buffer) {
|
||||
for (let i = 0; i < buffer.length; i += 1) {
|
||||
if (buffer[i] === 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
const files = collectTextAssets(publicRoot);
|
||||
const failures = [];
|
||||
|
||||
for (const file of files) {
|
||||
const rel = relative(repoRoot, file);
|
||||
const data = readFileSync(file);
|
||||
const nullByteIndex = findNullByte(data);
|
||||
if (nullByteIndex !== -1) {
|
||||
failures.push(`${rel}: contains literal NUL byte at offset ${nullByteIndex}`);
|
||||
}
|
||||
|
||||
if (extname(file) === '.js') {
|
||||
try {
|
||||
execFileSync(process.execPath, ['--check', file], { cwd: repoRoot, stdio: 'pipe' });
|
||||
} catch (err) {
|
||||
failures.push(`${rel}: JavaScript syntax check failed\n${String(err.stderr || err.message).trim()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(prettierBin, ['--check', ...files], { cwd: repoRoot, stdio: 'pipe' });
|
||||
} catch (err) {
|
||||
failures.push(`Prettier public asset check failed\n${String(err.stdout || err.stderr || err.message).trim()}`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.join('\n\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Public asset checks passed (${files.length} files).`);
|
||||
@@ -12,6 +12,10 @@ KillMode=process
|
||||
Environment=NODE_ENV=production
|
||||
Environment=HOME=/home/arkon
|
||||
Environment=NODE_COMPILE_CACHE=/home/arkon/.codeman/compile-cache
|
||||
# Make the gesture-control overlay AVAILABLE (CSP widening + /gesture/ assets +
|
||||
# window.__codemanGestureAvailable). The actual on/off stays the per-user
|
||||
# `gestureControlEnabled` toggle in App Settings → Display → Input (default OFF).
|
||||
Environment=CODEMAN_GESTURE=1
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @fileoverview Fetch the gesture-overlay runtime assets (MediaPipe wasm + the
|
||||
* gesture-recognizer model) into src/web/public/gesture/ so Codeman can serve
|
||||
* them same-origin (a browser content-blocker otherwise blocks the public CDNs
|
||||
* and the overlay fails to start). These are large binaries (~27 MB) kept OUT of
|
||||
* git (ignored explicitly via `src/web/public/gesture/wasm/` + `*.task` in
|
||||
* .gitignore); they are fetched here at install (postinstall) and build time.
|
||||
*
|
||||
* Idempotent: skips files already present. Non-fatal: the gesture overlay is
|
||||
* opt-in (CODEMAN_GESTURE=1), so a fetch failure only warns — it must not break
|
||||
* `npm install` / `npm run build`. The build then copies src/web/public into
|
||||
* dist/ as usual, so prod gets these too.
|
||||
*
|
||||
* The @mediapipe/tasks-vision version MUST match the one bundled into the gesture
|
||||
* overlay (Ark0N/codeman-gesture-control) so the wasm loader matches its JS API.
|
||||
*/
|
||||
import { mkdirSync, existsSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const GESTURE = join(__dirname, '..', 'src', 'web', 'public', 'gesture');
|
||||
const WASM = join(GESTURE, 'wasm');
|
||||
|
||||
const MP_VERSION = '0.10.21'; // keep in sync with the gesture overlay's @mediapipe/tasks-vision
|
||||
const WASM_BASE = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MP_VERSION}/wasm`;
|
||||
const MODEL_URL =
|
||||
'https://storage.googleapis.com/mediapipe-models/gesture_recognizer/gesture_recognizer/float16/1/gesture_recognizer.task';
|
||||
|
||||
const ASSETS = [
|
||||
{ url: `${WASM_BASE}/vision_wasm_internal.js`, path: join(WASM, 'vision_wasm_internal.js') },
|
||||
{ url: `${WASM_BASE}/vision_wasm_internal.wasm`, path: join(WASM, 'vision_wasm_internal.wasm') },
|
||||
{ url: `${WASM_BASE}/vision_wasm_nosimd_internal.js`, path: join(WASM, 'vision_wasm_nosimd_internal.js') },
|
||||
{ url: `${WASM_BASE}/vision_wasm_nosimd_internal.wasm`, path: join(WASM, 'vision_wasm_nosimd_internal.wasm') },
|
||||
{ url: MODEL_URL, path: join(GESTURE, 'gesture_recognizer.task') },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
mkdirSync(WASM, { recursive: true });
|
||||
let fetched = 0;
|
||||
let skipped = 0;
|
||||
for (const a of ASSETS) {
|
||||
if (existsSync(a.path) && statSync(a.path).size > 0) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const res = await fetch(a.url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${a.url}`);
|
||||
writeFileSync(a.path, Buffer.from(await res.arrayBuffer()));
|
||||
fetched++;
|
||||
}
|
||||
console.log(`[gesture] MediaPipe assets ready (${fetched} fetched, ${skipped} cached) → ${GESTURE}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// Non-fatal: opt-in feature. Warn and exit 0 so install/build still succeed.
|
||||
console.warn(`[gesture] could not fetch MediaPipe assets — overlay disabled until fetched: ${err.message}`);
|
||||
});
|
||||
@@ -312,6 +312,20 @@ if (isGlobalInstall) {
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 4b. Fetch gesture-overlay runtime assets (MediaPipe wasm + model) for dev mode
|
||||
// (src/web/public/gesture/). Opt-in feature (CODEMAN_GESTURE=1); non-fatal.
|
||||
// Large binaries kept out of git; the build copies them into dist/.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
if (!isGlobalInstall) {
|
||||
try {
|
||||
execSync(`node "${join(import.meta.dirname, 'fetch-gesture-assets.mjs')}"`, { stdio: 'inherit' });
|
||||
} catch {
|
||||
// Non-fatal — the gesture overlay is opt-in.
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 5. Install git pre-commit hook (format check)
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -446,3 +460,16 @@ if (process.env.CI || process.env.CODEMAN_NO_AUTOSTART) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Security note — printed on every install path
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
console.log(colors.bold('Security:'));
|
||||
console.log(colors.dim(' Codeman binds ') + colors.cyan('127.0.0.1') + colors.dim(' (this machine only) — no password needed by default.'));
|
||||
console.log(colors.dim(' To reach it from another device, do ONE of:'));
|
||||
console.log(colors.dim(' • ') + colors.cyan('tailscale serve') + colors.dim(' / ') + colors.cyan('cloudflared tunnel') + colors.dim(' (recommended), or'));
|
||||
console.log(colors.dim(' • ') + colors.cyan('codeman web --host 0.0.0.0') + colors.dim(' AND set ') + colors.cyan('CODEMAN_PASSWORD'));
|
||||
console.log(colors.dim(' A non-loopback bind without a password still starts, but warns loudly.'));
|
||||
console.log(colors.dim(' Details: docs/security-architecture.md'));
|
||||
console.log('');
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useCurrentFrame } from 'remotion';
|
||||
import { colors, fonts } from '../lib/theme';
|
||||
|
||||
type OverlayChar = {
|
||||
char: string;
|
||||
confirmed: boolean;
|
||||
};
|
||||
|
||||
type TerminalScreenProps = {
|
||||
typed: string;
|
||||
cursorVisible: boolean;
|
||||
overlayChars?: OverlayChar[];
|
||||
fontSize?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a terminal area styled exactly like the real Codeman xterm.js terminal.
|
||||
* Background #0d0d0d, Fira Code font, block cursor, Claude Code prompt.
|
||||
*/
|
||||
export const TerminalScreen: React.FC<TerminalScreenProps> = ({
|
||||
typed,
|
||||
cursorVisible,
|
||||
overlayChars,
|
||||
fontSize = 28,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
// Block cursor — solid, no blink (matches Codeman's cursorBlink: false)
|
||||
const cursorOn = cursorVisible;
|
||||
// But add a subtle blink for video clarity so viewers notice it
|
||||
const cursorOpacity = cursorOn ? (Math.floor(frame / 20) % 2 === 0 ? 0.9 : 0.6) : 0;
|
||||
|
||||
const lineHeight = Math.round(fontSize * 1.35);
|
||||
const charWidth = fontSize * 0.6;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: '#0d0d0d',
|
||||
position: 'relative',
|
||||
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Previous terminal output lines (fake history for realism) */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
fontSize: fontSize * 0.65,
|
||||
lineHeight: `${Math.round(fontSize * 0.65 * 1.4)}px`,
|
||||
color: '#495057',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={{ color: '#339af0' }}>❯</span>
|
||||
<span style={{ color: '#495057' }}> claude --dangerously-skip-permissions</span>
|
||||
</div>
|
||||
<div style={{ color: '#3a3a3a', marginTop: 4 }}>
|
||||
╭────────────────────────────────────╮
|
||||
</div>
|
||||
<div style={{ color: '#3a3a3a' }}>
|
||||
│ Claude Code session active │
|
||||
</div>
|
||||
<div style={{ color: '#3a3a3a' }}>
|
||||
╰────────────────────────────────────╯
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active prompt line — this is where the typing happens */}
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
fontSize,
|
||||
lineHeight: `${lineHeight}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
}}
|
||||
>
|
||||
{/* Prompt character */}
|
||||
<span style={{ color: '#339af0', fontWeight: 700, marginRight: charWidth * 0.8 }}>❯</span>
|
||||
|
||||
{/* Typed text */}
|
||||
{overlayChars ? (
|
||||
// Zerolag mode: show overlay chars with confirmed/unconfirmed color
|
||||
overlayChars.map((oc, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
color: oc.confirmed ? '#e0e0e0' : '#7a7a7a',
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
{oc.char}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: '#e0e0e0', letterSpacing: '0.5px' }}>{typed}</span>
|
||||
)}
|
||||
|
||||
{/* Block cursor */}
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: charWidth,
|
||||
height: lineHeight * 0.85,
|
||||
background: `rgba(224, 224, 224, ${cursorOpacity})`,
|
||||
verticalAlign: 'text-bottom',
|
||||
marginLeft: 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# run-beta.sh — launch a BETA Codeman isolated from a production instance.
|
||||
#
|
||||
# Codeman's data dir (~/.codeman) and tmux socket (-L codeman) are process-wide
|
||||
# and shared by every instance on the machine. The code now DEFAULTS to that
|
||||
# production layout on port 3000 (safe for master / existing installs), so a beta
|
||||
# build no longer isolates itself automatically — this wrapper opts it in:
|
||||
#
|
||||
# CODEMAN_INSTANCE=beta → data dir ~/.codeman-beta + tmux socket codeman-beta
|
||||
# CODEMAN_PORT=5000 → listen on 5000 instead of 3000
|
||||
#
|
||||
# Result: the beta runs side-by-side with prod and can never discover/attach to
|
||||
# prod's live tmux sessions or clobber prod's state.json. Override either var to
|
||||
# run additional named instances, e.g. CODEMAN_INSTANCE=foo CODEMAN_PORT=5050.
|
||||
#
|
||||
# Usage: ./scripts/run-beta.sh [extra `codeman web` flags]
|
||||
# Build first (the beta runs the compiled dist): npm run build
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export CODEMAN_INSTANCE="${CODEMAN_INSTANCE:-beta}"
|
||||
export CODEMAN_PORT="${CODEMAN_PORT:-5000}"
|
||||
|
||||
DIST="$(cd "$(dirname "$0")/.." && pwd)/dist/index.js"
|
||||
if [ ! -f "$DIST" ]; then
|
||||
echo "dist not found at $DIST — run 'npm run build' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting beta Codeman: instance='$CODEMAN_INSTANCE' (~/.codeman-$CODEMAN_INSTANCE, -L codeman-$CODEMAN_INSTANCE) on port $CODEMAN_PORT"
|
||||
exec node "$DIST" web "$@"
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# span-codeman.sh — open a Codeman window stretched across ALL displays, so that
|
||||
# in-page floating session panels can be dragged from one physical monitor to
|
||||
# the other. Spawned by the header "multi-monitor" button (POST
|
||||
# /api/system/span-displays), or run by hand at the desk.
|
||||
#
|
||||
# ── PREREQUISITE (one-time, manual) ──────────────────────────────────────────
|
||||
# System Settings → Desktop & Dock → turn OFF "Displays have separate Spaces",
|
||||
# then LOG OUT and back in. Until you do, macOS keeps every window on a single
|
||||
# display and this script's window will clamp to one monitor instead of spanning.
|
||||
# (Equivalent CLI: `defaults write com.apple.spaces spans-displays -bool true`,
|
||||
# still needs a re-login. Revert with `-bool false`.)
|
||||
#
|
||||
# Why a maximized --app window and not fullscreen: browser fullscreen is
|
||||
# per-display and will NOT span. We size a windowed app to the union of all
|
||||
# displays instead. macOS only.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
URL="${1:-http://localhost:5000}"
|
||||
|
||||
# Union rect of all displays in top-left-origin points — exactly what Chromium's
|
||||
# --window-position/--window-size expect. Finder's desktop window bounds already
|
||||
# encloses every monitor (and handles a monitor placed left/above via a negative
|
||||
# origin), so no per-display math or coordinate flipping is needed.
|
||||
bounds=$(osascript -e 'tell application "Finder" to get bounds of window of desktop')
|
||||
X=$(echo "$bounds" | awk -F', *' '{print $1}')
|
||||
Y=$(echo "$bounds" | awk -F', *' '{print $2}')
|
||||
R=$(echo "$bounds" | awk -F', *' '{print $3}')
|
||||
B=$(echo "$bounds" | awk -F', *' '{print $4}')
|
||||
W=$((R - X))
|
||||
H=$((B - Y))
|
||||
echo "Display union: position ${X},${Y} size ${W}x${H}"
|
||||
|
||||
# Pick a Chromium-family browser. Brave leads the list — plain Google Chrome
|
||||
# bounced when launched this way on the desk machine (created its profile then
|
||||
# exited without a window). Force a specific one with, e.g.,
|
||||
# BROWSER="Google Chrome" ./span-codeman.sh
|
||||
app="${BROWSER:-}"
|
||||
if [ -z "$app" ]; then
|
||||
for c in "Brave Browser" "Google Chrome" "Google Chrome Beta" "Chromium" "Microsoft Edge"; do
|
||||
[ -x "/Applications/$c.app/Contents/MacOS/$c" ] && app="$c" && break
|
||||
done
|
||||
fi
|
||||
bin="/Applications/$app.app/Contents/MacOS/$app"
|
||||
[ -n "$app" ] && [ -x "$bin" ] || { echo "No Chrome-family browser found (BROWSER='$app')" >&2; exit 1; }
|
||||
|
||||
# A dedicated, PER-BROWSER profile forces a FRESH instance — an already-running
|
||||
# browser would hand the URL to itself and silently ignore the geometry flags.
|
||||
# Per-browser so a Chrome-made profile can't confuse Brave (or vice-versa).
|
||||
slug=$(echo "$app" | tr '[:upper:] ' '[:lower:]-')
|
||||
profile="$HOME/.codeman-gesture-$slug"
|
||||
|
||||
echo "Browser: $bin"
|
||||
echo "URL: $URL"
|
||||
|
||||
# Detach so the caller (terminal / web server) isn't blocked for the window's life.
|
||||
nohup "$bin" \
|
||||
--app="$URL" \
|
||||
--user-data-dir="$profile" \
|
||||
--window-position="${X},${Y}" \
|
||||
--window-size="${W},${H}" \
|
||||
--no-first-run \
|
||||
--no-default-browser-check \
|
||||
>/dev/null 2>&1 &
|
||||
|
||||
echo "Launched spanning window (pid $!)."
|
||||
echo "If it filled only one monitor, the 'separate Spaces' prerequisite above"
|
||||
echo "isn't active yet — toggle it off, log out/in, and re-run."
|
||||
+10
-3
@@ -32,6 +32,13 @@ set -e
|
||||
CODEMAN_STATE="$HOME/.codeman/state.json"
|
||||
CODEMAN_SESSIONS="$HOME/.codeman/mux-sessions.json"
|
||||
|
||||
# Dedicated tmux socket all Codeman sessions live on. MUST match
|
||||
# DEFAULT_CODEMAN_TMUX_SOCKET / CODEMAN_TMUX_SOCKET in src/tmux-manager.ts —
|
||||
# otherwise list-sessions would enumerate the user's default tmux server
|
||||
# (missing the real Codeman sessions, surfacing unrelated ones).
|
||||
CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}"
|
||||
TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET")
|
||||
|
||||
|
||||
# iPhone 17 Pro portrait width (conservative)
|
||||
MAX_WIDTH=44
|
||||
@@ -286,7 +293,7 @@ parse_sessions() {
|
||||
|
||||
# Get PID from tmux
|
||||
local pid
|
||||
pid=$(tmux display-message -t "$session_name" -p '#{pane_pid}' 2>/dev/null || echo "0")
|
||||
pid=$("${TMUX_CMD[@]}" display-message -t "$session_name" -p '#{pane_pid}' 2>/dev/null || echo "0")
|
||||
|
||||
SESSION_PIDS+=("$pid")
|
||||
MUX_NAMES+=("$session_name")
|
||||
@@ -314,7 +321,7 @@ parse_sessions() {
|
||||
fi
|
||||
|
||||
i=$((i + 1))
|
||||
done < <(tmux list-sessions 2>/dev/null || true)
|
||||
done < <("${TMUX_CMD[@]}" list-sessions 2>/dev/null || true)
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
@@ -462,7 +469,7 @@ attach_session() {
|
||||
echo -e "${D}(Ctrl+B D to detach)${R}"
|
||||
sleep 0.3
|
||||
|
||||
tmux attach-session -t "$mux_name"
|
||||
"${TMUX_CMD[@]}" attach-session -t "$mux_name"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
+17
-8
@@ -20,6 +20,13 @@ REVERSE='\033[7m'
|
||||
# Use the same path as codeman (src/tmux-manager.ts)
|
||||
SESSIONS_FILE="${HOME}/.codeman/mux-sessions.json"
|
||||
|
||||
# Dedicated tmux socket all Codeman sessions live on. MUST match
|
||||
# DEFAULT_CODEMAN_TMUX_SOCKET / CODEMAN_TMUX_SOCKET in src/tmux-manager.ts —
|
||||
# otherwise this script would talk to the user's default tmux server and never
|
||||
# see (or could mis-target) Codeman's sessions.
|
||||
CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}"
|
||||
TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET")
|
||||
|
||||
|
||||
# Cached data
|
||||
CACHED_JSON=""
|
||||
@@ -92,7 +99,7 @@ declare -A ALIVE_CACHE
|
||||
check_alive() {
|
||||
local mux_name=$1
|
||||
if [[ -z "${ALIVE_CACHE[$mux_name]+x}" ]]; then
|
||||
if tmux has-session -t "$mux_name" 2>/dev/null; then
|
||||
if "${TMUX_CMD[@]}" has-session -t "$mux_name" 2>/dev/null; then
|
||||
ALIVE_CACHE[$mux_name]=1
|
||||
else
|
||||
ALIVE_CACHE[$mux_name]=0
|
||||
@@ -111,8 +118,10 @@ kill_session() {
|
||||
local mux_name=$(get_session_field $idx "muxName")
|
||||
local pid=$(get_session_field $idx "pid")
|
||||
|
||||
# SAFETY: Never kill own tmux session
|
||||
local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "")
|
||||
# SAFETY: Never kill own tmux session. Queried on the Codeman socket; if run
|
||||
# from a session on a different socket this returns empty (no match), which
|
||||
# is fine — you can't be "inside" a Codeman-socket session you didn't attach to.
|
||||
local current_session=$("${TMUX_CMD[@]}" display-message -p '#{session_name}' 2>/dev/null || echo "")
|
||||
if [[ -n "$current_session" && "$mux_name" == "$current_session" ]]; then
|
||||
echo -e "${RED}BLOCKED: Cannot kill own tmux session: $mux_name${NC}"
|
||||
return 1
|
||||
@@ -120,7 +129,7 @@ kill_session() {
|
||||
|
||||
pkill -TERM -P $pid 2>/dev/null
|
||||
kill -TERM -$pid 2>/dev/null
|
||||
tmux kill-session -t "$mux_name" 2>/dev/null
|
||||
"${TMUX_CMD[@]}" kill-session -t "$mux_name" 2>/dev/null
|
||||
kill -KILL $pid 2>/dev/null
|
||||
|
||||
# Remove from JSON
|
||||
@@ -345,7 +354,7 @@ interactive_menu() {
|
||||
clear
|
||||
echo -e "${CYAN}Attaching... (Ctrl+B D to detach)${NC}"
|
||||
sleep 0.3
|
||||
tmux attach-session -t "$mux_name"
|
||||
"${TMUX_CMD[@]}" attach-session -t "$mux_name"
|
||||
tput civis
|
||||
need_full_redraw=1
|
||||
force_refresh
|
||||
@@ -474,7 +483,7 @@ main() {
|
||||
[[ -z "${2:-}" ]] && { echo "Usage: $0 attach <N>"; exit 1; }
|
||||
force_refresh
|
||||
local mux_name=$(get_session_field $(($2-1)) "muxName")
|
||||
check_alive "$mux_name" && tmux attach-session -t "$mux_name" || echo "Session dead or not found"
|
||||
check_alive "$mux_name" && "${TMUX_CMD[@]}" attach-session -t "$mux_name" || echo "Session dead or not found"
|
||||
;;
|
||||
kill)
|
||||
[[ -z "${2:-}" ]] && { echo "Usage: $0 kill <N|N,M|N-M>"; exit 1; }
|
||||
@@ -492,8 +501,8 @@ main() {
|
||||
;;
|
||||
kill-all)
|
||||
force_refresh
|
||||
# SAFETY: Never kill own tmux session
|
||||
local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "")
|
||||
# SAFETY: Never kill own tmux session (queried on the Codeman socket)
|
||||
local current_session=$("${TMUX_CMD[@]}" display-message -p '#{session_name}' 2>/dev/null || echo "")
|
||||
local killed=0
|
||||
for ((i=CACHED_COUNT-1; i>=0; i--)); do
|
||||
local mux_name=$(get_session_field $i "muxName")
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export type AiIdleCheckConfig = AiCheckerConfigBase;
|
||||
type AiIdleCheckConfig = AiCheckerConfigBase;
|
||||
|
||||
export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR';
|
||||
|
||||
|
||||
+18
-11
@@ -40,13 +40,13 @@ import {
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export type AiPlanCheckConfig = AiCheckerConfigBase;
|
||||
type AiPlanCheckConfig = AiCheckerConfigBase;
|
||||
|
||||
export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR';
|
||||
|
||||
export type AiPlanCheckResult = AiCheckerResultBase<AiPlanCheckVerdict>;
|
||||
|
||||
export type AiPlanCheckState = AiCheckerStateBase<AiPlanCheckVerdict>;
|
||||
type AiPlanCheckState = AiCheckerStateBase<AiPlanCheckVerdict>;
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
@@ -64,20 +64,27 @@ const DEFAULT_PLAN_CHECK_CONFIG: AiPlanCheckConfig = {
|
||||
const VERDICT_PATTERN = /^\s*(PLAN_MODE|NOT_PLAN_MODE)\b/i;
|
||||
|
||||
/** The prompt sent to the AI plan checker */
|
||||
const AI_PLAN_CHECK_PROMPT = `Analyze this terminal output from a running Claude Code session. Determine if the terminal is currently showing a PLAN MODE APPROVAL PROMPT or not.
|
||||
const AI_PLAN_CHECK_PROMPT = `Analyze this terminal output from a running Claude Code session. Determine if the terminal is currently showing a NUMBERED SELECTION MENU that is waiting for the user to press Enter on the highlighted default option.
|
||||
|
||||
A plan mode approval prompt is a numbered selection menu that Claude Code shows when it wants the user to approve a plan before proceeding. It typically has these characteristics:
|
||||
A qualifying menu has all of these characteristics:
|
||||
- A numbered list of options (e.g., "1. Yes", "2. No", "3. Type your own")
|
||||
- A selection indicator arrow (❯ or >) pointing to one of the options
|
||||
- Text asking for approval like "Would you like to proceed?" or "Ready to implement?"
|
||||
- The prompt appears at the BOTTOM of the output (most recent content)
|
||||
- A selection indicator arrow (❯ or >) pointing to one of the options (the default)
|
||||
- The menu appears at the BOTTOM of the output (most recent content)
|
||||
- It is asking the user to choose, not just displaying numbered information
|
||||
|
||||
NOT a plan mode prompt:
|
||||
This includes BOTH:
|
||||
- Plan-mode approval prompts ("Would you like to proceed?" / "Ready to implement?")
|
||||
- AskUserQuestion / elicitation dialogs (Claude Code's numbered question menus)
|
||||
|
||||
NOT a qualifying menu:
|
||||
- Claude actively working (spinners, "Thinking", tool execution)
|
||||
- A completed response with no selection menu
|
||||
- An AskUserQuestion/elicitation dialog (different format, free-text input)
|
||||
- A completed response with no selection menu visible
|
||||
- A free-text input field with no numbered options
|
||||
- A numbered LIST in the assistant's prose with no selection arrow
|
||||
- Network lag or mid-output pause
|
||||
- Any state without a visible numbered selection menu
|
||||
- Any state without a visible selector arrow on a numbered option
|
||||
|
||||
The verdict name PLAN_MODE is historical — it now means "auto-accept this selection menu by pressing Enter on the default".
|
||||
|
||||
Terminal output (most recent at bottom):
|
||||
---
|
||||
|
||||
@@ -99,7 +99,7 @@ const LOG_FILE_MENTION_PATTERN = /([/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log
|
||||
/**
|
||||
* Events emitted by BashToolParser.
|
||||
*/
|
||||
export interface BashToolParserEvents {
|
||||
interface BashToolParserEvents {
|
||||
/** New Bash tool with file paths started */
|
||||
toolStart: [tool: ActiveBashTool];
|
||||
/** Bash tool completed */
|
||||
@@ -111,7 +111,7 @@ export interface BashToolParserEvents {
|
||||
/**
|
||||
* Configuration options for BashToolParser.
|
||||
*/
|
||||
export interface BashToolParserConfig {
|
||||
interface BashToolParserConfig {
|
||||
/** Session ID this parser belongs to */
|
||||
sessionId: string;
|
||||
/** Whether the parser is enabled (default: true) */
|
||||
|
||||
+14
-4
@@ -483,19 +483,29 @@ program
|
||||
program
|
||||
.command('web')
|
||||
.description('Start the web interface')
|
||||
.option('-p, --port <port>', 'Port to listen on', '3000')
|
||||
.option('-H, --host <host>', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1')
|
||||
.option('-p, --port <port>', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000')
|
||||
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
|
||||
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
|
||||
.option(
|
||||
'--allow-unauthenticated-network',
|
||||
'Allow non-loopback web access without CODEMAN_PASSWORD (dangerous; terminal control is exposed)'
|
||||
)
|
||||
.action(async (options) => {
|
||||
const { startWebServer } = await import('./web/server.js');
|
||||
const host = options.host;
|
||||
const port = parseInt(options.port, 10);
|
||||
const https = !!options.https;
|
||||
const titleHostname = options.titleHostname;
|
||||
const allowUnauthenticatedNetwork = !!options.allowUnauthenticatedNetwork;
|
||||
const protocol = https ? 'https' : 'http';
|
||||
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
||||
|
||||
console.log(chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`));
|
||||
console.log(chalk.cyan(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`));
|
||||
|
||||
try {
|
||||
const server = await startWebServer(port, https);
|
||||
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://localhost:${port}`));
|
||||
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork);
|
||||
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://${displayHost}:${port}`));
|
||||
if (https) {
|
||||
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @fileoverview Per-instance isolation: data directory + tmux socket.
|
||||
*
|
||||
* Codeman keeps all runtime state under `~/.codeman` and runs its tmux sessions
|
||||
* on a dedicated socket (`tmux -L codeman`). Both are PROCESS-WIDE and SHARED by
|
||||
* every Codeman instance on the machine — so a second instance pointed at the
|
||||
* same socket will discover and attach to the first instance's live sessions,
|
||||
* and two instances sharing `~/.codeman/state.json` will clobber each other.
|
||||
*
|
||||
* To let a beta build coexist with a production one, this module derives both
|
||||
* the data dir and the tmux socket from a single "instance" name:
|
||||
* - default (unset/empty) → `~/.codeman` + `tmux -L codeman` (prod layout)
|
||||
* - `CODEMAN_INSTANCE=beta` → `~/.codeman-beta` + `tmux -L codeman-beta`
|
||||
* - `CODEMAN_INSTANCE=foo` → `~/.codeman-foo` + `tmux -L codeman-foo`
|
||||
*
|
||||
* The DEFAULT is the production layout so this is safe to ship to master: an
|
||||
* existing install keeps reading `~/.codeman`. To run a beta ALONGSIDE prod,
|
||||
* launch it with `CODEMAN_INSTANCE=beta` (and a distinct port, see below) —
|
||||
* `scripts/run-beta.sh` does both. The port is unrelated to the instance and is
|
||||
* set separately via `--port` / `CODEMAN_PORT` (see `src/cli.ts`).
|
||||
*
|
||||
* Individual overrides still win: `CODEMAN_DATA_DIR` (absolute data dir) and
|
||||
* `CODEMAN_TMUX_SOCKET` (socket name, validated in tmux-manager).
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Instance name. Empty string (the default) = production layout (`~/.codeman`,
|
||||
* `-L codeman`), so this is safe on master and existing installs are untouched.
|
||||
* Set `CODEMAN_INSTANCE=beta` (e.g. via `scripts/run-beta.sh`) to run an
|
||||
* isolated beta alongside prod.
|
||||
*/
|
||||
export const CODEMAN_INSTANCE = process.env.CODEMAN_INSTANCE ?? '';
|
||||
|
||||
const INSTANCE_SUFFIX = CODEMAN_INSTANCE ? `-${CODEMAN_INSTANCE}` : '';
|
||||
|
||||
/** Default tmux socket for this instance. `CODEMAN_TMUX_SOCKET` still overrides. */
|
||||
export const DEFAULT_TMUX_SOCKET = `codeman${INSTANCE_SUFFIX}`;
|
||||
|
||||
let _ensured = false;
|
||||
|
||||
/**
|
||||
* Absolute path to this instance's data directory (created on first use). All
|
||||
* persisted state (`state.json`, `mux-sessions.json`, settings, push keys,
|
||||
* lifecycle log, screenshots, certs, …) lives here.
|
||||
*/
|
||||
export function getDataDir(): string {
|
||||
const dir = process.env.CODEMAN_DATA_DIR || join(homedir(), `.codeman${INSTANCE_SUFFIX}`);
|
||||
if (!_ensured) {
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
_ensured = true;
|
||||
} catch {
|
||||
/* best-effort; individual writers also mkdir as needed */
|
||||
}
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Join one or more segments onto this instance's data directory. */
|
||||
export function dataPath(...segments: string[]): string {
|
||||
return join(getDataDir(), ...segments);
|
||||
}
|
||||
@@ -48,7 +48,7 @@ const STREAM_INACTIVITY_TIMEOUT_MS = INACTIVITY_TIMEOUT_MS;
|
||||
/**
|
||||
* Represents an active file stream.
|
||||
*/
|
||||
export interface FileStream {
|
||||
interface FileStream {
|
||||
/** Unique stream identifier */
|
||||
id: string;
|
||||
/** Session this stream belongs to */
|
||||
@@ -74,7 +74,7 @@ export interface FileStream {
|
||||
/**
|
||||
* Options for creating a file stream.
|
||||
*/
|
||||
export interface CreateStreamOptions {
|
||||
interface CreateStreamOptions {
|
||||
/** Session ID requesting the stream */
|
||||
sessionId: string;
|
||||
/** Path to the file to stream */
|
||||
@@ -94,7 +94,7 @@ export interface CreateStreamOptions {
|
||||
/**
|
||||
* Result of creating a stream.
|
||||
*/
|
||||
export interface CreateStreamResult {
|
||||
interface CreateStreamResult {
|
||||
success: boolean;
|
||||
streamId?: string;
|
||||
error?: string;
|
||||
|
||||
@@ -84,6 +84,42 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a subset of env keys from .claude/settings.local.json.env if present.
|
||||
* Used during the disk→tmux-setenv migration: when the caller is actively setting
|
||||
* a fresh value for a Codeman-managed key, any stale disk entry for THAT KEY is
|
||||
* superseded and should be removed. Keys NOT in `keysToRemove` are left alone
|
||||
* (they may be user-managed). No-op if the file/keys don't exist.
|
||||
*/
|
||||
export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly string[]): Promise<void> {
|
||||
if (keysToRemove.length === 0) return;
|
||||
|
||||
const settingsPath = join(casePath, '.claude', 'settings.local.json');
|
||||
if (!existsSync(settingsPath)) return;
|
||||
|
||||
let existing: Record<string, unknown>;
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
return; // Malformed — don't rewrite it
|
||||
}
|
||||
|
||||
const env = existing.env as Record<string, string> | undefined;
|
||||
if (!env) return;
|
||||
|
||||
let changed = false;
|
||||
for (const key of keysToRemove) {
|
||||
if (key in env) {
|
||||
delete env[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
|
||||
existing.env = env;
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates env vars in .claude/settings.local.json for the given case path.
|
||||
* Merges with existing env field; removes vars set to empty string.
|
||||
|
||||
@@ -17,11 +17,6 @@ import { KeyedDebouncer } from './utils/index.js';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface ImageWatcherEvents {
|
||||
'image:detected': (event: ImageDetectedEvent) => void;
|
||||
'image:error': (error: Error, sessionId?: string) => void;
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
/** Supported image file extensions (lowercase) */
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ClaudeMode,
|
||||
SessionMode,
|
||||
OpenCodeConfig,
|
||||
EffortLevel,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
@@ -63,6 +64,10 @@ export interface CreateSessionOptions {
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
|
||||
effort?: EffortLevel;
|
||||
}
|
||||
|
||||
/** Options for respawning a dead pane. */
|
||||
@@ -77,6 +82,10 @@ export interface RespawnPaneOptions {
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** Resume a previous Claude conversation when respawning */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
|
||||
effort?: EffortLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,6 +103,9 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
/** Which backend this instance uses */
|
||||
readonly backend: 'tmux';
|
||||
|
||||
/** The dedicated tmux socket name all sessions live on (e.g. "codeman"). */
|
||||
readonly muxSocket: string;
|
||||
|
||||
// ========== Lifecycle ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -60,21 +60,6 @@ const POST_PHASE_DELAY_MS = 1000;
|
||||
// Events
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface OrchestratorLoopEvents {
|
||||
stateChanged: (state: OrchestratorState, prevState: OrchestratorState) => void;
|
||||
planProgress: (phase: string, detail: string) => void;
|
||||
planReady: (plan: OrchestratorPlan) => void;
|
||||
phaseStarted: (phase: OrchestratorPhase) => void;
|
||||
phaseCompleted: (phase: OrchestratorPhase) => void;
|
||||
phaseFailed: (phase: OrchestratorPhase, reason: string) => void;
|
||||
taskAssigned: (task: OrchestratorTask, sessionId: string) => void;
|
||||
taskCompleted: (task: OrchestratorTask) => void;
|
||||
taskFailed: (task: OrchestratorTask, error: string) => void;
|
||||
verificationResult: (phase: OrchestratorPhase, result: VerificationResult) => void;
|
||||
completed: (stats: OrchestratorStats) => void;
|
||||
error: (error: Error) => void;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// OrchestratorLoop
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -68,7 +68,7 @@ export interface DetailedPlanResult {
|
||||
|
||||
export type ProgressCallback = (phase: string, detail: string) => void;
|
||||
|
||||
export interface PlanSubagentEvent {
|
||||
interface PlanSubagentEvent {
|
||||
type: 'started' | 'progress' | 'completed' | 'failed';
|
||||
agentId: string;
|
||||
agentType: 'research' | 'planner';
|
||||
@@ -80,7 +80,7 @@ export interface PlanSubagentEvent {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type SubagentCallback = (event: PlanSubagentEvent) => void;
|
||||
type SubagentCallback = (event: PlanSubagentEvent) => void;
|
||||
|
||||
// ============================================================================
|
||||
// JSON Repair Helper
|
||||
|
||||
@@ -7,10 +7,4 @@
|
||||
|
||||
export { RESEARCH_AGENT_PROMPT } from './research-agent.js';
|
||||
export { PLANNER_PROMPT } from './planner.js';
|
||||
export {
|
||||
PHASE_EXECUTION_PROMPT,
|
||||
TEAM_LEAD_PROMPT,
|
||||
VERIFICATION_PROMPT,
|
||||
REPLAN_PROMPT,
|
||||
SINGLE_TASK_PROMPT,
|
||||
} from './orchestrator.js';
|
||||
export { PHASE_EXECUTION_PROMPT, TEAM_LEAD_PROMPT, REPLAN_PROMPT, SINGLE_TASK_PROMPT } from './orchestrator.js';
|
||||
|
||||
@@ -57,22 +57,6 @@ Suggested teammate roles:
|
||||
Each teammate should focus on their assigned task area. Monitor their progress.
|
||||
When ALL tasks are complete and you've verified the results, output: <promise>{COMPLETION_PHRASE}</promise>`;
|
||||
|
||||
/**
|
||||
* Verification prompt — asks Claude to verify phase completion.
|
||||
*
|
||||
* Placeholders:
|
||||
* - {PHASE_NAME}: Phase name
|
||||
* - {CRITERIA}: Numbered verification criteria
|
||||
* - {PASS_PHRASE}: Phrase to output on success
|
||||
* - {FAIL_PHRASE}: Phrase to output on failure
|
||||
*/
|
||||
export const VERIFICATION_PROMPT = `Review the work done in "{PHASE_NAME}". Check these criteria:
|
||||
|
||||
{CRITERIA}
|
||||
|
||||
If ALL criteria are met, respond with: {PASS_PHRASE}
|
||||
If ANY criteria fail, respond with: {FAIL_PHRASE} and explain what failed.`;
|
||||
|
||||
/**
|
||||
* Replan prompt — gives failure context and asks for recovery.
|
||||
*
|
||||
|
||||
+2
-2
@@ -8,12 +8,12 @@
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import webpush from 'web-push';
|
||||
import type { VapidKeys, PushSubscriptionRecord } from './types.js';
|
||||
import { Debouncer } from './utils/index.js';
|
||||
import { getDataDir } from './config/instance.js';
|
||||
|
||||
const DATA_DIR = join(homedir(), '.codeman');
|
||||
const DATA_DIR = getDataDir();
|
||||
const KEYS_FILE = join(DATA_DIR, 'push-keys.json');
|
||||
const SUBS_FILE = join(DATA_DIR, 'push-subscriptions.json');
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ const YAML_LINE_PATTERN = /^([a-zA-Z_-]+):\s*"?([^"\n]+)"?\s*$/gm;
|
||||
/**
|
||||
* Ralph Loop configuration from .claude/ralph-loop.local.md
|
||||
*/
|
||||
export interface RalphLoopConfig {
|
||||
interface RalphLoopConfig {
|
||||
enabled: boolean;
|
||||
iteration: number;
|
||||
maxIterations: number | null;
|
||||
|
||||
+1
-9
@@ -34,19 +34,11 @@ import { RalphLoopStatus, getErrorMessage } from './types.js';
|
||||
/**
|
||||
* Events emitted by RalphLoop
|
||||
*/
|
||||
export interface RalphLoopEvents {
|
||||
started: () => void;
|
||||
stopped: () => void;
|
||||
taskAssigned: (taskId: string, sessionId: string) => void;
|
||||
taskCompleted: (taskId: string) => void;
|
||||
taskFailed: (taskId: string, error: string) => void;
|
||||
error: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for RalphLoop
|
||||
*/
|
||||
export interface RalphLoopOptions {
|
||||
interface RalphLoopOptions {
|
||||
/** How often to check for new tasks (default from config) */
|
||||
pollIntervalMs?: number;
|
||||
/** Minimum time to run before stopping (null = no minimum) */
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
* Key exports:
|
||||
* - `RalphTracker` class — main tracker, extends EventEmitter
|
||||
* - `RalphTrackerEvents` interface — typed event map
|
||||
* - Re-exports: `EnhancedPlanTask`, `CheckpointReview` from ralph-plan-tracker
|
||||
*
|
||||
* Key methods: `processData(data)` — feed terminal output, `getState()`,
|
||||
* `getTodos()`, `getCompletionHistory()`, `getPlanTasks()`, `reset()`
|
||||
@@ -66,9 +65,6 @@ import { RalphStallDetector } from './ralph-stall-detector.js';
|
||||
import { RalphStatusParser } from './ralph-status-parser.js';
|
||||
import { STALE_DATA_MAX_AGE_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.js';
|
||||
|
||||
// Re-export sub-module types for backward compatibility
|
||||
export type { EnhancedPlanTask, CheckpointReview } from './ralph-plan-tracker.js';
|
||||
|
||||
// ========== Configuration Constants ==========
|
||||
// Note: MAX_TODOS_PER_SESSION and MAX_LINE_BUFFER_SIZE are imported from config modules
|
||||
|
||||
@@ -390,32 +386,6 @@ const P2_PRIORITY_PATTERNS = [
|
||||
* @event circuitBreakerUpdate - Fired when circuit breaker state changes
|
||||
* @event exitGateMet - Fired when dual-condition exit gate is met
|
||||
*/
|
||||
export interface RalphTrackerEvents {
|
||||
/** Emitted when loop state changes */
|
||||
loopUpdate: (state: RalphTrackerState) => void;
|
||||
/** Emitted when todo list is modified */
|
||||
todoUpdate: (todos: RalphTodoItem[]) => void;
|
||||
/** Emitted when completion phrase detected (loop finished) */
|
||||
completionDetected: (phrase: string) => void;
|
||||
/** Emitted when tracker auto-enables from disabled state */
|
||||
enabled: () => void;
|
||||
/** Emitted when a RALPH_STATUS block is parsed */
|
||||
statusBlockDetected: (block: RalphStatusBlock) => void;
|
||||
/** Emitted when circuit breaker state changes */
|
||||
circuitBreakerUpdate: (status: CircuitBreakerStatus) => void;
|
||||
/** Emitted when dual-condition exit gate is met (completion indicators >= 2 AND EXIT_SIGNAL: true) */
|
||||
exitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
|
||||
/** Emitted when iteration count hasn't changed for an extended period (stall warning) */
|
||||
iterationStallWarning: (data: { iteration: number; stallDurationMs: number }) => void;
|
||||
/** Emitted when iteration count hasn't changed for critical period (stall critical) */
|
||||
iterationStallCritical: (data: { iteration: number; stallDurationMs: number }) => void;
|
||||
/** Emitted when a common/risky completion phrase is detected (P1-002) */
|
||||
phraseValidationWarning: (data: {
|
||||
phrase: string;
|
||||
reason: 'common' | 'short' | 'numeric';
|
||||
suggestedPhrase: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* RalphTracker - Parses terminal output to detect Ralph Wiggum loops and todos
|
||||
|
||||
+39
-88
@@ -27,7 +27,7 @@
|
||||
* - `RespawnController` class — state machine, extends EventEmitter
|
||||
* - `RespawnConfig` interface — all configuration options
|
||||
* - `RespawnState` type — union of all state machine states
|
||||
* - `DetectionStatus`, `ActiveTimerInfo`, `RespawnEvents` — status/event types
|
||||
* - `DetectionStatus`, `ActiveTimerInfo` — status types
|
||||
*
|
||||
* Key methods: `start()`, `stop()`, `getStatus()`, `getConfig()`,
|
||||
* `getDetectionStatus()`, `getActiveTimers()`, `getAggregateMetrics()`,
|
||||
@@ -46,8 +46,8 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Session } from './session.js';
|
||||
import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js';
|
||||
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
|
||||
import { AiIdleChecker, type AiCheckState } from './ai-idle-checker.js';
|
||||
import { AiPlanChecker } from './ai-plan-checker.js';
|
||||
import type { TeamWatcher } from './team-watcher.js';
|
||||
import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
|
||||
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
|
||||
@@ -99,13 +99,13 @@ const PLAN_MODE_SELECTOR_PATTERN = /[❯>]\s*\d+\./;
|
||||
* Each layer provides a confidence signal that Claude has finished working.
|
||||
*/
|
||||
/** Active timer info for UI display */
|
||||
export interface ActiveTimerInfo {
|
||||
interface ActiveTimerInfo {
|
||||
name: string;
|
||||
remainingMs: number;
|
||||
totalMs: number;
|
||||
}
|
||||
|
||||
export interface DetectionStatus {
|
||||
interface DetectionStatus {
|
||||
/** Layer 0: Stop hook received (highest priority - definitive signal) */
|
||||
stopHookReceived: boolean;
|
||||
/** Timestamp when Stop hook was received */
|
||||
@@ -488,70 +488,13 @@ export interface RespawnConfig {
|
||||
* @event error - Fired on errors
|
||||
* @event log - Fired for debug logging
|
||||
*/
|
||||
/** Timer info for countdown display */
|
||||
export interface TimerInfo {
|
||||
name: string;
|
||||
durationMs: number;
|
||||
endsAt: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Action log entry for detailed UI feedback */
|
||||
export interface ActionLogEntry {
|
||||
interface ActionLogEntry {
|
||||
type: string;
|
||||
detail: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface RespawnEvents {
|
||||
/** State machine transition */
|
||||
stateChanged: (state: RespawnState, prevState: RespawnState) => void;
|
||||
/** New respawn cycle started */
|
||||
respawnCycleStarted: (cycleNumber: number) => void;
|
||||
/** Respawn cycle finished */
|
||||
respawnCycleCompleted: (cycleNumber: number) => void;
|
||||
/** Command sent to session */
|
||||
stepSent: (step: string, input: string) => void;
|
||||
/** Step completed (ready indicator detected) */
|
||||
stepCompleted: (step: string) => void;
|
||||
/** Detection status update for UI display */
|
||||
detectionUpdate: (status: DetectionStatus) => void;
|
||||
/** Auto-accept sent for plan mode approval */
|
||||
autoAcceptSent: () => void;
|
||||
/** AI idle check started */
|
||||
aiCheckStarted: () => void;
|
||||
/** AI idle check completed with verdict */
|
||||
aiCheckCompleted: (result: AiCheckResult) => void;
|
||||
/** AI idle check failed */
|
||||
aiCheckFailed: (error: string) => void;
|
||||
/** AI idle check cooldown state changed */
|
||||
aiCheckCooldown: (active: boolean, endsAt: number | null) => void;
|
||||
/** AI plan check started */
|
||||
planCheckStarted: () => void;
|
||||
/** AI plan check completed with verdict */
|
||||
planCheckCompleted: (result: AiPlanCheckResult) => void;
|
||||
/** AI plan check failed */
|
||||
planCheckFailed: (error: string) => void;
|
||||
/** Timer started for countdown display */
|
||||
timerStarted: (timer: TimerInfo) => void;
|
||||
/** Timer cancelled */
|
||||
timerCancelled: (timerName: string, reason?: string) => void;
|
||||
/** Timer completed */
|
||||
timerCompleted: (timerName: string) => void;
|
||||
/** Verbose action log for detailed UI feedback */
|
||||
actionLog: (action: ActionLogEntry) => void;
|
||||
/** Error occurred */
|
||||
error: (error: Error) => void;
|
||||
/** Debug log message */
|
||||
log: (message: string) => void;
|
||||
/** Stuck state warning emitted */
|
||||
stuckStateWarning: (state: RespawnState, durationMs: number) => void;
|
||||
/** Stuck state recovery triggered */
|
||||
stuckStateRecovery: (state: RespawnState, durationMs: number, attempt: number) => void;
|
||||
/** Respawn blocked by external signal */
|
||||
respawnBlocked: (data: { reason: string; details: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert milliseconds to a non-negative whole number of seconds for countdown display.
|
||||
* Rounds up so that e.g. 1200 ms shows as 2 s (never under-reports remaining time).
|
||||
@@ -570,7 +513,7 @@ const DEFAULT_CONFIG: RespawnConfig = {
|
||||
sendInit: true, // send /init after /clear
|
||||
completionConfirmMs: 10000, // 10 seconds of silence after completion message
|
||||
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
|
||||
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions)
|
||||
autoAcceptPrompts: true, // auto-accept numbered selection menus (plan approvals + question dialogs)
|
||||
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
|
||||
aiIdleCheckEnabled: true, // use AI to confirm idle state
|
||||
aiIdleCheckModel: AI_CHECK_MODEL,
|
||||
@@ -680,9 +623,6 @@ export class RespawnController extends EventEmitter {
|
||||
/** Whether any terminal output has been received since start/last-auto-accept */
|
||||
private hasReceivedOutput: boolean = false;
|
||||
|
||||
/** Whether an elicitation dialog (AskUserQuestion) was detected via hook signal */
|
||||
private elicitationDetected: boolean = false;
|
||||
|
||||
// ========== Hook-Based Detection State (Layer 0 - Highest Priority) ==========
|
||||
|
||||
/** Whether a Stop hook was received (definitive idle signal from Claude Code) */
|
||||
@@ -1426,7 +1366,12 @@ export class RespawnController extends EventEmitter {
|
||||
this.clearWorkingPatternWindow();
|
||||
this.workingDetected = false;
|
||||
this.completionMessageTime = now;
|
||||
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
|
||||
// Don't cancel the auto-accept timer here — modern Claude Code emits "Worked for X"
|
||||
// immediately before a plan-approval menu, and the auto-accept pre-filter is
|
||||
// responsible for distinguishing menu-present from menu-absent. Cancelling here
|
||||
// would silently block auto-accept for every plan approval and AskUserQuestion
|
||||
// dialog. If no menu is in the buffer, the pre-filter rejects and the
|
||||
// completion-confirm timer (started below) drives the normal idle flow.
|
||||
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
|
||||
|
||||
// In watching state, start completion confirmation timer
|
||||
@@ -1474,7 +1419,6 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
this.workingDetected = true;
|
||||
this.promptDetected = false;
|
||||
this.elicitationDetected = false; // Clear on new work cycle
|
||||
this.resetHookState(); // Clear hook signals on new work
|
||||
this.lastWorkingPatternTime = now;
|
||||
|
||||
@@ -2279,11 +2223,11 @@ export class RespawnController extends EventEmitter {
|
||||
* @returns True if auto-accept should proceed to the AI confirmation stage
|
||||
*/
|
||||
private canAutoAccept(): boolean {
|
||||
// Only auto-accept in watching state (not during a respawn cycle)
|
||||
if (this._state !== 'watching') return false;
|
||||
|
||||
// Don't auto-accept if a completion message was detected (normal idle handles it)
|
||||
if (this.completionMessageTime !== null) return false;
|
||||
// Allow auto-accept from 'watching' AND 'confirming_idle'. The latter is reached
|
||||
// when "Worked for X" was detected — which Claude Code now emits in the same PTY
|
||||
// burst as a plan-approval menu. `sendAutoAcceptEnter()` self-transitions back to
|
||||
// 'watching' before sending Enter. Reject any other state (respawn cycle, etc.).
|
||||
if (this._state !== 'watching' && this._state !== 'confirming_idle') return false;
|
||||
|
||||
// Don't auto-accept if disabled
|
||||
if (!this.config.autoAcceptPrompts) return false;
|
||||
@@ -2291,15 +2235,15 @@ export class RespawnController extends EventEmitter {
|
||||
// Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start)
|
||||
if (!this.hasReceivedOutput) return false;
|
||||
|
||||
// Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected
|
||||
if (this.elicitationDetected) {
|
||||
this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)');
|
||||
return false;
|
||||
}
|
||||
// Note: completionMessageTime and elicitationDetected used to block here, but both
|
||||
// legitimately co-occur with selection menus (Claude Code emits "Worked for X"
|
||||
// before plan approvals, and AskUserQuestion fires the elicitation hook). The
|
||||
// pre-filter below is the authoritative gate for "is there a numbered menu?".
|
||||
|
||||
// Stage 1: Pre-filter — check if buffer looks like plan mode
|
||||
// Stage 1: Pre-filter — check if buffer looks like a numbered selection menu
|
||||
// (covers both plan-mode approvals and AskUserQuestion dialogs)
|
||||
if (!this.isPlanModePreFilterMatch(this.terminalBuffer.value)) {
|
||||
this.log('Skipping auto-accept: pre-filter did not match plan mode patterns');
|
||||
this.log('Skipping auto-accept: pre-filter did not match selection-menu patterns');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2365,8 +2309,10 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
|
||||
if (result.verdict === 'PLAN_MODE') {
|
||||
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
|
||||
if (this._state !== 'watching') {
|
||||
// Don't send Enter if state moved into a respawn cycle while the check ran.
|
||||
// 'watching' and 'confirming_idle' are both valid — sendAutoAcceptEnter()
|
||||
// self-transitions to 'watching' before sending.
|
||||
if (this._state !== 'watching' && this._state !== 'confirming_idle') {
|
||||
this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
|
||||
return;
|
||||
}
|
||||
@@ -2425,13 +2371,18 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Signal that an elicitation dialog (AskUserQuestion) was detected via hook.
|
||||
* This prevents auto-accept from firing, since the user needs to make a selection.
|
||||
* The flag is cleared when working patterns are detected (new turn starts).
|
||||
* Used as a positive hint that a numbered selection menu is about to render —
|
||||
* we restart the auto-accept timer so the pre-filter gets a fresh shot at it
|
||||
* once the menu finishes drawing. The actual gate is `isPlanModePreFilterMatch()`
|
||||
* plus (optionally) the AI plan check; this hook just primes the timer.
|
||||
* No-op if respawn isn't `'watching'`/`'confirming_idle'` or `autoAcceptPrompts`
|
||||
* is off, so this can never fire Enter when the user has disabled auto-accept.
|
||||
*/
|
||||
signalElicitation(): void {
|
||||
this.elicitationDetected = true;
|
||||
this.cancelAutoAcceptTimer();
|
||||
this.log('Elicitation dialog signaled - auto-accept blocked until next work cycle');
|
||||
this.log('Elicitation dialog signaled - auto-accept will trigger if pre-filter matches');
|
||||
if (this.config.autoAcceptPrompts && (this._state === 'watching' || this._state === 'confirming_idle')) {
|
||||
this.startAutoAcceptTimer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -93,7 +93,7 @@ const DEFAULT_AUTO_COMPACT_THRESHOLD = 110_000;
|
||||
/**
|
||||
* Callbacks required by SessionAutoOps to interact with the parent Session.
|
||||
*/
|
||||
export interface AutoOpsCallbacks {
|
||||
interface AutoOpsCallbacks {
|
||||
/** Send a command via the terminal multiplexer */
|
||||
writeCommand: (command: string) => Promise<boolean>;
|
||||
/** Check if Claude is currently working */
|
||||
@@ -109,12 +109,6 @@ export interface AutoOpsCallbacks {
|
||||
/**
|
||||
* Events emitted by SessionAutoOps.
|
||||
*/
|
||||
export interface SessionAutoOpsEvents {
|
||||
/** Auto-compact was triggered and the /compact command was sent */
|
||||
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
|
||||
/** Auto-clear was triggered and the /clear command was sent */
|
||||
autoClear: (data: { tokens: number; threshold: number }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages auto-compact and auto-clear automation for a Session.
|
||||
|
||||
@@ -8,14 +8,15 @@
|
||||
* @module session-cli-builder
|
||||
*/
|
||||
|
||||
import type { ClaudeMode } from './types.js';
|
||||
import type { ClaudeMode, EffortLevel } from './types.js';
|
||||
import { isEffortLevel } from './types.js';
|
||||
import { getAugmentedPath } from './utils/index.js';
|
||||
|
||||
/**
|
||||
* Build Claude CLI permission flags based on the configured mode.
|
||||
* Returns an array of args to pass to the CLI.
|
||||
*/
|
||||
export function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): string[] {
|
||||
function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): string[] {
|
||||
switch (claudeMode) {
|
||||
case 'dangerously-skip-permissions':
|
||||
return ['--dangerously-skip-permissions'];
|
||||
@@ -31,6 +32,23 @@ export function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: strin
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the CLI args carrying the effort level as a SOFT default (switchable
|
||||
* in-session via /effort). The CLAUDE_CODE_EFFORT_LEVEL env var is deliberately
|
||||
* avoided — it hard-locks effort and blocks in-session `/effort` switching.
|
||||
*
|
||||
* Two carriers are needed because neither covers all levels:
|
||||
* - regular levels (incl. `max`) → `--effort <level>` (the settings `effortLevel`
|
||||
* key is enum(["low","medium","high","xhigh"]) with .catch(undefined), so `max`
|
||||
* would be SILENTLY dropped there)
|
||||
* - `ultracode` → `--settings '{"ultracode":true}'` (its own boolean settings key,
|
||||
* claude >= 2.1.154; rejected by the --effort flag)
|
||||
*/
|
||||
export function buildEffortCliArgs(effort?: EffortLevel): string[] {
|
||||
if (!effort || !isEffortLevel(effort)) return [];
|
||||
return effort === 'ultracode' ? ['--settings', '{"ultracode":true}'] : ['--effort', effort];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build args for an interactive Claude CLI session (direct PTY, non-mux fallback).
|
||||
*
|
||||
@@ -38,16 +56,19 @@ export function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: strin
|
||||
* @param claudeMode - Permission mode for the CLI
|
||||
* @param model - Optional model override (e.g., 'opus', 'sonnet')
|
||||
* @param allowedTools - Optional comma-separated allowed tools list
|
||||
* @param effort - Optional effort level, injected via --settings (overridable in-session)
|
||||
* @returns Array of CLI arguments
|
||||
*/
|
||||
export function buildInteractiveArgs(
|
||||
sessionId: string,
|
||||
claudeMode: ClaudeMode,
|
||||
model?: string,
|
||||
allowedTools?: string
|
||||
allowedTools?: string,
|
||||
effort?: EffortLevel
|
||||
): string[] {
|
||||
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
|
||||
if (model) args.push('--model', model);
|
||||
args.push(...buildEffortCliArgs(effort));
|
||||
return args;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
|
||||
import { appendFile, readFile, writeFile } from 'node:fs/promises';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname } from 'node:path';
|
||||
import type { LifecycleEventType, LifecycleEntry } from './types.js';
|
||||
import { dataPath } from './config/instance.js';
|
||||
|
||||
const MAX_LINES = 10_000;
|
||||
const TRIM_TO = 8_000;
|
||||
@@ -22,7 +22,7 @@ export class SessionLifecycleLog {
|
||||
private writeQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(filePath?: string) {
|
||||
this.filePath = filePath || join(homedir(), '.codeman', 'session-lifecycle.jsonl');
|
||||
this.filePath = filePath || dataPath('session-lifecycle.jsonl');
|
||||
const dir = dirname(this.filePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
|
||||
+10
-14
@@ -30,18 +30,6 @@ import { SessionState } from './types.js';
|
||||
/**
|
||||
* Events emitted by SessionManager
|
||||
*/
|
||||
export interface SessionManagerEvents {
|
||||
/** Fired when a new session starts successfully */
|
||||
sessionStarted: (session: Session) => void;
|
||||
/** Fired when a session stops (graceful or forced) */
|
||||
sessionStopped: (sessionId: string) => void;
|
||||
/** Fired when a session encounters an error */
|
||||
sessionError: (sessionId: string, error: string) => void;
|
||||
/** Fired when a session produces terminal output */
|
||||
sessionOutput: (sessionId: string, output: string) => void;
|
||||
/** Fired when a completion phrase is detected */
|
||||
sessionCompletion: (sessionId: string, phrase: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages multiple Claude sessions with lifecycle coordination.
|
||||
@@ -164,7 +152,7 @@ export class SessionManager extends EventEmitter {
|
||||
await session.start();
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
this.store.setSession(session.id, session.toState());
|
||||
this.updateSessionState(session);
|
||||
|
||||
this.emit('sessionStarted', session);
|
||||
return session;
|
||||
@@ -259,7 +247,15 @@ export class SessionManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private updateSessionState(session: Session): void {
|
||||
this.store.setSession(session.id, session.toState());
|
||||
// envOverrides is intentionally NOT on SessionState (API safety). For disk
|
||||
// persistence we augment the stored object with __envOverrides so reboot
|
||||
// recovery can restore them without leaking through any API serializer.
|
||||
// The key uses the reserved `__` prefix so it is visibly "internal" to any
|
||||
// future reader of state.json.
|
||||
const state = session.toState();
|
||||
const envOverrides = session.getEnvOverridesForPersist();
|
||||
const toStore = envOverrides ? { ...state, __envOverrides: envOverrides } : state;
|
||||
this.store.setSession(session.id, toStore as SessionState);
|
||||
}
|
||||
|
||||
/** Gets all sessions from persistent storage (including stopped). */
|
||||
|
||||
+120
-66
@@ -29,7 +29,7 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { execSync, execFileSync } from 'node:child_process';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as pty from 'node-pty';
|
||||
import {
|
||||
@@ -42,9 +42,11 @@ import {
|
||||
NiceConfig,
|
||||
DEFAULT_NICE_CONFIG,
|
||||
getErrorMessage,
|
||||
isEffortLevel,
|
||||
type ClaudeMode,
|
||||
type SessionMode,
|
||||
type OpenCodeConfig,
|
||||
type EffortLevel,
|
||||
} from './types.js';
|
||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||
@@ -121,6 +123,44 @@ const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
||||
|
||||
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
||||
|
||||
/** PTY fallback geometry when tmux can't be queried (matches pre-#80 hardcoded values). */
|
||||
const DEFAULT_PTY_COLS = 120;
|
||||
const DEFAULT_PTY_ROWS = 40;
|
||||
const TMUX_DISPLAY_TIMEOUT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Ask tmux for the current window geometry of `muxName` so a re-attaching PTY
|
||||
* client can spawn at the same size and avoid the resize-flicker / scrollback
|
||||
* loss documented in #80. Returns `{ cols: 120, rows: 40 }` on any failure
|
||||
* (tmux dead, muxName unknown, malformed output) — caller never has to
|
||||
* differentiate "tmux unreachable" from "size 120x40".
|
||||
*
|
||||
* `socket` MUST be the same dedicated socket the session lives on (`mux.muxSocket`);
|
||||
* querying the default server would never find the session and silently fall back.
|
||||
*
|
||||
* Argv form (execFileSync, not execSync) keeps `muxName` out of any shell so
|
||||
* a hostile session name can't inject options.
|
||||
*/
|
||||
export function queryTmuxWindowSize(muxName: string, socket: string): { cols: number; rows: number } {
|
||||
try {
|
||||
const sizeStr = execFileSync(
|
||||
'tmux',
|
||||
['-L', socket, 'display', '-t', muxName, '-p', '#{window_width} #{window_height}'],
|
||||
{
|
||||
timeout: TMUX_DISPLAY_TIMEOUT_MS,
|
||||
encoding: 'utf8',
|
||||
}
|
||||
).trim();
|
||||
const [w, h] = sizeStr.split(' ').map(Number);
|
||||
if (w > 0 && h > 0) {
|
||||
return { cols: w, rows: h };
|
||||
}
|
||||
} catch {
|
||||
/* fall back below */
|
||||
}
|
||||
return { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS };
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a JSON message from Claude CLI's stream-json output format.
|
||||
* Messages are newline-delimited JSON objects parsed from PTY output.
|
||||
@@ -154,63 +194,6 @@ export interface ClaudeMessage {
|
||||
* Event signatures emitted by the Session class.
|
||||
* Subscribe using `session.on('eventName', handler)`.
|
||||
*/
|
||||
export interface SessionEvents {
|
||||
/** Processed text output (ANSI stripped) */
|
||||
output: (data: string) => void;
|
||||
/** Parsed JSON message from Claude CLI */
|
||||
message: (msg: ClaudeMessage) => void;
|
||||
/** Error output from the session */
|
||||
error: (data: string) => void;
|
||||
/** Session process exited */
|
||||
exit: (code: number | null) => void;
|
||||
/** One-shot prompt completed with result and cost */
|
||||
completion: (result: string, cost: number) => void;
|
||||
/** Raw terminal data (includes ANSI codes) */
|
||||
terminal: (data: string) => void;
|
||||
/** Signal to clear terminal display (after mux attach) */
|
||||
clearTerminal: () => void;
|
||||
/** New background task started */
|
||||
taskCreated: (task: BackgroundTask) => void;
|
||||
/** Background task status changed */
|
||||
taskUpdated: (task: BackgroundTask) => void;
|
||||
/** Background task finished successfully */
|
||||
taskCompleted: (task: BackgroundTask) => void;
|
||||
/** Background task failed with error */
|
||||
taskFailed: (task: BackgroundTask, error: string) => void;
|
||||
/** Auto-clear triggered due to token threshold */
|
||||
autoClear: (data: { tokens: number; threshold: number }) => void;
|
||||
/** Auto-compact triggered due to token threshold */
|
||||
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
|
||||
/** Ralph loop state changed */
|
||||
ralphLoopUpdate: (state: RalphTrackerState) => void;
|
||||
/** Ralph todo list updated */
|
||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
|
||||
/** Ralph completion phrase detected */
|
||||
ralphCompletionDetected: (phrase: string) => void;
|
||||
/** RALPH_STATUS block detected */
|
||||
ralphStatusBlockDetected: (block: import('./types.js').RalphStatusBlock) => void;
|
||||
/** Circuit breaker state changed */
|
||||
ralphCircuitBreakerUpdate: (status: import('./types.js').CircuitBreakerStatus) => void;
|
||||
/** Dual-condition exit gate met */
|
||||
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
|
||||
/** Bash tool with file paths started */
|
||||
bashToolStart: (tool: ActiveBashTool) => void;
|
||||
/** Bash tool completed */
|
||||
bashToolEnd: (tool: ActiveBashTool) => void;
|
||||
/** Active Bash tools list updated */
|
||||
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
||||
/** CLI info (version, model, account) updated */
|
||||
cliInfoUpdated: (info: {
|
||||
version: string | null;
|
||||
model: string | null;
|
||||
accountType: string | null;
|
||||
latestVersion: string | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
// SessionMode is imported from types.ts (single source of truth)
|
||||
// Re-export for backwards compatibility with any external consumers
|
||||
export type { SessionMode } from './types.js';
|
||||
|
||||
/**
|
||||
* Core session class that wraps a PTY process running Claude CLI or a shell.
|
||||
@@ -330,6 +313,15 @@ export class Session extends EventEmitter {
|
||||
private _openCodeConfig: OpenCodeConfig | undefined;
|
||||
private _resumeSessionId: string | undefined;
|
||||
|
||||
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
||||
// at spawn, preserved across respawns via persisted state. Not written to .claude/settings.local.json.
|
||||
private _envOverrides: Record<string, string> | undefined;
|
||||
|
||||
// Claude CLI effort level — injected as a `--settings` soft default at spawn so the
|
||||
// user can still switch in-session via /effort (incl. ultracode). Never carried as
|
||||
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
||||
private _effort: EffortLevel | undefined;
|
||||
|
||||
// Session color for visual differentiation
|
||||
private _color: import('./types.js').SessionColor = 'default';
|
||||
|
||||
@@ -389,6 +381,10 @@ export class Session extends EventEmitter {
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** Resume a previous Claude conversation (used after server reboot) */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort?: EffortLevel;
|
||||
}
|
||||
) {
|
||||
super();
|
||||
@@ -436,6 +432,21 @@ export class Session extends EventEmitter {
|
||||
this._openCodeConfig = config.openCodeConfig;
|
||||
}
|
||||
|
||||
// Apply env overrides (exported at spawn, not persisted to disk).
|
||||
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
|
||||
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
|
||||
// and never export it as an env var again. Explicit config.effort wins over legacy.
|
||||
if (config.envOverrides && Object.keys(config.envOverrides).length > 0) {
|
||||
const { CLAUDE_CODE_EFFORT_LEVEL: legacyEffort, ...restOverrides } = config.envOverrides;
|
||||
this._envOverrides = Object.keys(restOverrides).length > 0 ? restOverrides : undefined;
|
||||
if (legacyEffort && isEffortLevel(legacyEffort)) {
|
||||
this._effort = legacyEffort;
|
||||
}
|
||||
}
|
||||
if (config.effort && isEffortLevel(config.effort)) {
|
||||
this._effort = config.effort;
|
||||
}
|
||||
|
||||
// Initialize task tracker and forward events (store handlers for cleanup)
|
||||
this._taskTracker = new TaskTracker();
|
||||
this._taskTrackerHandlers = {
|
||||
@@ -530,6 +541,15 @@ export class Session extends EventEmitter {
|
||||
return this._claudeSessionId;
|
||||
}
|
||||
|
||||
// Adopt a Claude conversation ID observed from an external source (e.g. hook
|
||||
// payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so
|
||||
// `_handleJsonMessage` never sees `session_id`; hooks are the only signal
|
||||
// that conveys a post-/clear conversation switch.
|
||||
adoptClaudeSessionId(newId: string): void {
|
||||
if (!newId || newId === this._claudeSessionId) return;
|
||||
this._claudeSessionId = newId;
|
||||
}
|
||||
|
||||
/** The tmux session name, if the session is running inside a mux */
|
||||
get muxName(): string | null {
|
||||
return this._muxSession?.muxName ?? null;
|
||||
@@ -846,9 +866,30 @@ export class Session extends EventEmitter {
|
||||
cliLatestVersion: this._cliLatestVersion || undefined,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
effort: this._effort,
|
||||
// envOverrides intentionally NOT on the public SessionState type — they must not
|
||||
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
|
||||
// can carry secrets). For disk persistence, session-manager calls
|
||||
// getEnvOverridesForPersist() and writes alongside state.
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a subset of env overrides safe for disk persistence (state.json).
|
||||
* Only non-sensitive `CLAUDE_CODE_*` keys are included. `OPENCODE_*` keys are
|
||||
* filtered out because the schema permits them and they can carry secrets
|
||||
* (e.g., OPENCODE_API_KEY); secrets must not land in `~/.codeman/state.json`.
|
||||
* Must NOT be included in any API-bound serializer — see toState() comment.
|
||||
*/
|
||||
getEnvOverridesForPersist(): Record<string, string> | undefined {
|
||||
if (!this._envOverrides) return undefined;
|
||||
const safe: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(this._envOverrides)) {
|
||||
if (key.startsWith('CLAUDE_CODE_')) safe[key] = value;
|
||||
}
|
||||
return Object.keys(safe).length > 0 ? safe : undefined;
|
||||
}
|
||||
|
||||
toDetailedState() {
|
||||
return {
|
||||
...this.toLightDetailedState(),
|
||||
@@ -963,11 +1004,13 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
// Attach to the mux session via PTY
|
||||
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default)
|
||||
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
|
||||
try {
|
||||
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cols: ptyCols,
|
||||
rows: ptyRows,
|
||||
cwd: this.workingDir,
|
||||
env: buildMuxAttachEnv(),
|
||||
});
|
||||
@@ -1014,6 +1057,8 @@ export class Session extends EventEmitter {
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
@@ -1026,6 +1071,8 @@ export class Session extends EventEmitter {
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
},
|
||||
spawnErrLabel: 'mux attachment',
|
||||
});
|
||||
@@ -1095,13 +1142,14 @@ export class Session extends EventEmitter {
|
||||
try {
|
||||
// Pass --session-id to use the SAME ID as the Codeman session
|
||||
// This ensures subagents can be directly matched to the correct tab
|
||||
const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools);
|
||||
const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools, this._effort);
|
||||
this.ptyProcess = pty.spawn('claude', args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: buildClaudeEnv(this.id),
|
||||
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||
});
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
|
||||
@@ -1346,6 +1394,7 @@ export class Session extends EventEmitter {
|
||||
workingDir: this.workingDir,
|
||||
mode: 'shell',
|
||||
niceConfig: this._niceConfig,
|
||||
envOverrides: this._envOverrides,
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
@@ -1353,6 +1402,7 @@ export class Session extends EventEmitter {
|
||||
mode: 'shell',
|
||||
name: this._name,
|
||||
niceConfig: this._niceConfig,
|
||||
envOverrides: this._envOverrides,
|
||||
},
|
||||
spawnErrLabel: 'shell mux attachment',
|
||||
});
|
||||
@@ -1488,7 +1538,8 @@ export class Session extends EventEmitter {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: buildClaudeEnv(this.id),
|
||||
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||
});
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
||||
@@ -1634,11 +1685,14 @@ export class Session extends EventEmitter {
|
||||
this._messages = this._messages.slice(-Math.floor(MAX_MESSAGES * 0.8));
|
||||
}
|
||||
|
||||
// Extract Claude session ID from messages (can be in any message type)
|
||||
// Support both sessionId (camelCase) and session_id (snake_case)
|
||||
// Extract Claude session ID from messages (can be in any message type).
|
||||
// Support both sessionId (camelCase) and session_id (snake_case).
|
||||
// The constructor seeds _claudeSessionId with this.id as a placeholder;
|
||||
// once Claude CLI emits its real session ID, adopt it so JSONL lookups
|
||||
// (e.g. /api/sessions/:id/last-response) can find the transcript file.
|
||||
const msgSessionId =
|
||||
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
|
||||
if (msgSessionId && !this._claudeSessionId) {
|
||||
if (msgSessionId && msgSessionId !== this._claudeSessionId) {
|
||||
this._claudeSessionId = msgSessionId;
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -39,6 +39,7 @@ import {
|
||||
TokenUsageEntry,
|
||||
} from './types.js';
|
||||
import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js';
|
||||
import { dataPath, CODEMAN_INSTANCE } from './config/instance.js';
|
||||
|
||||
/** Debounce delay for batching state writes (ms) */
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
@@ -89,8 +90,10 @@ export class StateStore {
|
||||
private _saveInFlight: Promise<void> | null = null;
|
||||
|
||||
constructor(filePath?: string) {
|
||||
// Migrate legacy data directory (~/.claudeman → ~/.codeman)
|
||||
if (!filePath) {
|
||||
// Migrate legacy data directory (~/.claudeman → ~/.codeman). Default (prod)
|
||||
// instance only — a named instance (e.g. beta) must never touch the shared
|
||||
// ~/.codeman / ~/codeman-cases layout, preserving instance isolation.
|
||||
if (!filePath && !CODEMAN_INSTANCE) {
|
||||
const legacyDir = join(homedir(), '.claudeman');
|
||||
const newDir = join(homedir(), '.codeman');
|
||||
if (existsSync(legacyDir) && !existsSync(newDir)) {
|
||||
@@ -105,7 +108,7 @@ export class StateStore {
|
||||
}
|
||||
}
|
||||
|
||||
this.filePath = filePath || join(homedir(), '.codeman', 'state.json');
|
||||
this.filePath = filePath || dataPath('state.json');
|
||||
this.ralphStatePath = this.filePath.replace('.json', '-inner.json');
|
||||
this.state = this.load();
|
||||
this.state.config.stateFilePath = this.filePath;
|
||||
|
||||
@@ -134,17 +134,6 @@ export interface SubagentToolResult {
|
||||
isError: boolean; // Whether result is an error
|
||||
}
|
||||
|
||||
export interface SubagentEvents {
|
||||
'subagent:discovered': (info: SubagentInfo) => void;
|
||||
'subagent:updated': (info: SubagentInfo) => void;
|
||||
'subagent:tool_call': (data: SubagentToolCall) => void;
|
||||
'subagent:tool_result': (data: SubagentToolResult) => void;
|
||||
'subagent:progress': (data: SubagentProgress) => void;
|
||||
'subagent:message': (data: SubagentMessage) => void;
|
||||
'subagent:completed': (info: SubagentInfo) => void;
|
||||
'subagent:error': (error: Error, agentId?: string) => void;
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude/projects');
|
||||
|
||||
@@ -17,14 +17,6 @@ import { getStore } from './state-store.js';
|
||||
/**
|
||||
* Events emitted by TaskQueue
|
||||
*/
|
||||
export interface TaskQueueEvents {
|
||||
/** Fired when a task is added to the queue */
|
||||
taskAdded: (task: Task) => void;
|
||||
/** Fired when a task is removed from the queue */
|
||||
taskRemoved: (taskId: string) => void;
|
||||
/** Fired when a task's state changes */
|
||||
taskUpdated: (task: Task) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Priority queue for managing tasks with dependency support.
|
||||
|
||||
@@ -153,16 +153,6 @@ export interface BackgroundTask {
|
||||
* @event taskCompleted - Task finished successfully
|
||||
* @event taskFailed - Task finished with error
|
||||
*/
|
||||
export interface TaskTrackerEvents {
|
||||
/** New task created */
|
||||
taskCreated: (task: BackgroundTask) => void;
|
||||
/** Task state updated */
|
||||
taskUpdated: (task: BackgroundTask) => void;
|
||||
/** Task completed successfully */
|
||||
taskCompleted: (task: BackgroundTask) => void;
|
||||
/** Task failed with error */
|
||||
taskFailed: (task: BackgroundTask, error: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskTracker - Detects and tracks background tasks in Claude Code sessions.
|
||||
|
||||
+331
-99
@@ -28,8 +28,8 @@ import { promisify } from 'node:util';
|
||||
const execAsync = promisify(exec);
|
||||
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
||||
import { writeFile, rename } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath, DEFAULT_TMUX_SOCKET } from './config/instance.js';
|
||||
import {
|
||||
ProcessStats,
|
||||
PersistedRespawnConfig,
|
||||
@@ -39,7 +39,9 @@ import {
|
||||
type ClaudeMode,
|
||||
type SessionMode,
|
||||
type OpenCodeConfig,
|
||||
type EffortLevel,
|
||||
} from './types.js';
|
||||
import { buildEffortCliArgs } from './session-cli-builder.js';
|
||||
import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir } from './utils/index.js';
|
||||
import type {
|
||||
TerminalMultiplexer,
|
||||
@@ -71,6 +73,12 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
||||
/** Default stats collection interval (2 seconds) */
|
||||
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||||
|
||||
/** Stable cwd for tmux server/pane launch; actual session cwd is reached inside the pane. */
|
||||
const TMUX_LAUNCH_CWD = '/tmp';
|
||||
|
||||
/** Claude Code native macOS recommendation for avoiding low nofile startup failures. */
|
||||
export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
|
||||
|
||||
/**
|
||||
* SAFETY: Test mode detection.
|
||||
* When running under vitest (VITEST env var is set automatically),
|
||||
@@ -87,7 +95,7 @@ const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||||
const IS_TEST_MODE = !!process.env.VITEST;
|
||||
|
||||
/** Path to persisted mux session metadata */
|
||||
const MUX_SESSIONS_FILE = join(homedir(), '.codeman', 'mux-sessions.json');
|
||||
const MUX_SESSIONS_FILE = dataPath('mux-sessions.json');
|
||||
|
||||
/** Regex to validate tmux session names (only allow safe characters) */
|
||||
const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/;
|
||||
@@ -98,6 +106,64 @@ const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
|
||||
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
|
||||
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
|
||||
|
||||
/** Dedicated tmux socket for new Codeman-owned sessions (instance-scoped:
|
||||
* `codeman` for prod, `codeman-beta` on the beta branch). */
|
||||
const DEFAULT_CODEMAN_TMUX_SOCKET = DEFAULT_TMUX_SOCKET;
|
||||
|
||||
/** Regex to validate tmux socket names passed to `tmux -L`. */
|
||||
const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
||||
|
||||
/**
|
||||
* Separator used in `tmux list-panes -F` output between session name and pid.
|
||||
*
|
||||
* Must NOT be a backslash-escape (e.g. `\t`, `\n`): under non-tty execution
|
||||
* contexts (launchd on macOS, systemd without TTYPath) tmux can emit such
|
||||
* escapes as the literal two characters `\` + letter rather than the control
|
||||
* byte, breaking the parser and causing every tracked session to be classified
|
||||
* as dead — which wipes state.json on restart. '|' is passed through verbatim
|
||||
* in every environment and is rejected by tmux's own session-name validation,
|
||||
* so it cannot appear inside `#{session_name}` and cause a false split.
|
||||
*/
|
||||
const PANE_LIST_SEP = '|';
|
||||
|
||||
/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
|
||||
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
|
||||
|
||||
/**
|
||||
* 构建 pane 启动前的 nofile 修复命令。
|
||||
*
|
||||
* macOS launchd/tmux 组合有时会让 pane 继承 256 的 soft nofile;
|
||||
* 新版 Claude Code 会在这种环境下直接退出。这里避免使用 $变量
|
||||
* 或命令替换,因为 fullCmd 目前经由双引号 bash -c 传递,外层
|
||||
* shell 会提前展开它们。
|
||||
*/
|
||||
export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT): string {
|
||||
const safeLimit = Number.isSafeInteger(targetLimit) && targetLimit > 0 ? targetLimit : CLAUDE_CODE_NOFILE_LIMIT;
|
||||
return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'`
|
||||
* into a Map of session-name → pane pid. Exported for unit testing.
|
||||
*
|
||||
* - Skips empty lines and lines without the separator.
|
||||
* - Skips entries with a non-numeric pid or empty name.
|
||||
*/
|
||||
export function parsePaneList(output: string): Map<string, number> {
|
||||
const result = new Map<string, number>();
|
||||
for (const line of output.split('\n')) {
|
||||
if (!line) continue;
|
||||
const sep = line.indexOf(PANE_LIST_SEP);
|
||||
if (sep === -1) continue;
|
||||
const name = line.slice(0, sep);
|
||||
const pid = parseInt(line.slice(sep + 1), 10);
|
||||
if (name && !Number.isNaN(pid)) {
|
||||
result.set(name, pid);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Characters unsafe in paths — shell metacharacters, quotes, and control chars */
|
||||
const UNSAFE_PATH_CHARS = /[;&|$`(){}<>'"\n\r]/;
|
||||
|
||||
@@ -123,6 +189,31 @@ function isValidPath(path: string): boolean {
|
||||
return SAFE_PATH_PATTERN.test(path);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Single-socket architecture: ALL Codeman sessions live on one dedicated tmux
|
||||
// socket (`tmux -L codeman`), isolated from the user's default tmux server.
|
||||
// The socket name is a process-wide constant (env-overridable for test/multi-
|
||||
// instance isolation) — it is never stored per-session, so it cannot drift.
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Resolve the process-wide Codeman tmux socket name. Always returns a valid
|
||||
* name: `CODEMAN_TMUX_SOCKET` env override if safe, else the built-in default.
|
||||
*/
|
||||
function resolveConfiguredTmuxSocket(): string {
|
||||
const raw = process.env.CODEMAN_TMUX_SOCKET ?? DEFAULT_CODEMAN_TMUX_SOCKET;
|
||||
if (!SAFE_TMUX_SOCKET_PATTERN.test(raw)) {
|
||||
console.warn(`[TmuxManager] Ignoring invalid CODEMAN_TMUX_SOCKET: ${JSON.stringify(raw)}`);
|
||||
return DEFAULT_CODEMAN_TMUX_SOCKET;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** Build the `tmux -L <socket>` command prefix. Socket name is shell-escaped. */
|
||||
function tmuxCommand(socket: string): string {
|
||||
return `tmux -L ${shellescape(socket)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Claude CLI permission flags for the tmux command string.
|
||||
* Validates allowedTools to prevent command injection.
|
||||
@@ -174,6 +265,20 @@ function buildOpenCodeCommand(config?: OpenCodeConfig): string {
|
||||
* Build the spawn command for any session mode.
|
||||
* Shared by createSession() and respawnPane() to avoid duplication.
|
||||
*/
|
||||
/**
|
||||
* Build the shell fragment carrying the effort level as a SOFT default
|
||||
* (see buildEffortCliArgs — `--effort <level>` for regular levels incl. max,
|
||||
* `--settings '{"ultracode":true}'` for ultracode; deliberately not the
|
||||
* CLAUDE_CODE_EFFORT_LEVEL env var, which hard-locks /effort switching).
|
||||
*
|
||||
* Injection-safe: effort is validated against the EFFORT_LEVELS allowlist inside
|
||||
* buildEffortCliArgs, so the single-quoted values contain no user-controlled characters.
|
||||
*/
|
||||
function buildEffortSettingsFlag(effort?: EffortLevel): string {
|
||||
const [flag, value] = buildEffortCliArgs(effort);
|
||||
return flag && value ? ` ${flag} '${value}'` : '';
|
||||
}
|
||||
|
||||
function buildSpawnCommand(options: {
|
||||
mode: SessionMode;
|
||||
sessionId: string;
|
||||
@@ -182,11 +287,13 @@ function buildSpawnCommand(options: {
|
||||
allowedTools?: string;
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
resumeSessionId?: string;
|
||||
effort?: EffortLevel;
|
||||
}): string {
|
||||
if (options.mode === 'claude') {
|
||||
// Validate model to prevent command injection
|
||||
const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
|
||||
const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
|
||||
const effortFlag = buildEffortSettingsFlag(options.effort);
|
||||
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
|
||||
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
|
||||
// fall back to a new session with --session-id so the pane doesn't die.
|
||||
@@ -194,11 +301,11 @@ function buildSpawnCommand(options: {
|
||||
options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined;
|
||||
const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools);
|
||||
if (safeResumeId) {
|
||||
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}`;
|
||||
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`;
|
||||
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}`;
|
||||
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
|
||||
return `${resumeCmd} || ${fallbackCmd}`;
|
||||
}
|
||||
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`;
|
||||
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
|
||||
}
|
||||
if (options.mode === 'opencode') {
|
||||
return buildOpenCodeCommand(options.openCodeConfig);
|
||||
@@ -210,7 +317,7 @@ function buildSpawnCommand(options: {
|
||||
* Set sensitive environment variables on a tmux session via setenv.
|
||||
* These are inherited by panes but not visible in ps output or tmux history.
|
||||
*/
|
||||
function setOpenCodeEnvVars(muxName: string): void {
|
||||
function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void {
|
||||
const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
|
||||
for (const key of sensitiveVars) {
|
||||
const val = process.env[key];
|
||||
@@ -218,7 +325,7 @@ function setOpenCodeEnvVars(muxName: string): void {
|
||||
// Shell-escape: wrap in single quotes, escape any inner single quotes
|
||||
const escaped = val.replace(/'/g, "'\\''");
|
||||
try {
|
||||
execSync(`tmux setenv -t '${muxName}' ${key} '${escaped}'`, {
|
||||
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
|
||||
encoding: 'utf8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
@@ -234,7 +341,7 @@ function setOpenCodeEnvVars(muxName: string): void {
|
||||
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
|
||||
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
|
||||
*/
|
||||
function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): void {
|
||||
function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, config?: OpenCodeConfig): void {
|
||||
if (!config) return;
|
||||
|
||||
let jsonContent: string | undefined;
|
||||
@@ -265,7 +372,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
|
||||
if (jsonContent) {
|
||||
const escaped = jsonContent.replace(/'/g, "'\\''");
|
||||
try {
|
||||
execSync(`tmux setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
|
||||
execSync(`${tmuxCmd} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
|
||||
encoding: 'utf8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
@@ -298,6 +405,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
|
||||
export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
readonly backend = 'tmux' as const;
|
||||
private sessions: Map<string, MuxSession> = new Map();
|
||||
private readonly tmuxSocket = resolveConfiguredTmuxSocket();
|
||||
private statsInterval: NodeJS.Timeout | null = null;
|
||||
private mouseSyncInterval: NodeJS.Timeout | null = null;
|
||||
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
|
||||
@@ -313,6 +421,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
/** The dedicated tmux socket all Codeman sessions live on (see {@link TerminalMultiplexer.muxSocket}). */
|
||||
get muxSocket(): string {
|
||||
return this.tmuxSocket;
|
||||
}
|
||||
|
||||
private tmux(): string {
|
||||
return tmuxCommand(this.tmuxSocket);
|
||||
}
|
||||
|
||||
// Load saved sessions from disk (NEVER called in test mode)
|
||||
private loadSessions(): void {
|
||||
if (IS_TEST_MODE) return;
|
||||
@@ -322,8 +439,40 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const content = readFileSync(MUX_SESSIONS_FILE, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
if (Array.isArray(data)) {
|
||||
// Dedup by muxName: one live tmux session must map to exactly one
|
||||
// tracked entry. A per-session socket-tag mismatch could historically
|
||||
// let the same session be tracked twice — once under its real UUID and
|
||||
// once under a "restored-<id>" placeholder — surfacing as duplicate tabs.
|
||||
// Single-socket unification removed that failure mode; this pass stays
|
||||
// to clean any stale duplicates already on disk. Keep the real (UUID)
|
||||
// entry and drop placeholder twins.
|
||||
let dropped = 0;
|
||||
const keptByMuxName = new Map<string, string>(); // muxName -> kept sessionId
|
||||
for (const session of data) {
|
||||
// Strip the obsolete per-session tmuxSocket tag (now a process-wide
|
||||
// constant). Left in place it would be written back by saveSessions()
|
||||
// and linger on disk as a zombie field forever.
|
||||
delete (session as { tmuxSocket?: unknown }).tmuxSocket;
|
||||
const muxName: string | undefined = session.muxName;
|
||||
const priorId = muxName ? keptByMuxName.get(muxName) : undefined;
|
||||
if (priorId) {
|
||||
const incomingIsPlaceholder = String(session.sessionId).startsWith('restored-');
|
||||
const priorIsPlaceholder = priorId.startsWith('restored-');
|
||||
// Drop the incoming unless it's the real twin of a placeholder we kept.
|
||||
if (incomingIsPlaceholder || !priorIsPlaceholder) {
|
||||
dropped++;
|
||||
continue;
|
||||
}
|
||||
this.sessions.delete(priorId);
|
||||
dropped++;
|
||||
}
|
||||
this.sessions.set(session.sessionId, session);
|
||||
if (muxName) keptByMuxName.set(muxName, session.sessionId);
|
||||
}
|
||||
// Persist the cleaned list so the stale duplicates don't reload.
|
||||
if (dropped > 0) {
|
||||
console.log(`[TmuxManager] Dropped ${dropped} duplicate mux session record(s) on load`);
|
||||
this.saveSessions();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -361,6 +510,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
/**
|
||||
* Build the array of environment export commands shared by createSession() and respawnPane().
|
||||
* Includes locale, mux markers, session identity, and API URL.
|
||||
*
|
||||
* User-supplied envOverrides are NOT inlined here — they go through applyEnvOverrides()
|
||||
* via `tmux setenv` so secret values (e.g., OPENCODE_API_KEY) never appear in the bash
|
||||
* command line (visible in `ps`). This also sidesteps shell-metachar injection via keys.
|
||||
*/
|
||||
private buildEnvExports(sessionId: string, muxName: string, mode: SessionMode): string[] {
|
||||
const exports = [
|
||||
@@ -377,6 +530,47 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return exports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply user-supplied env overrides to a tmux session via `tmux setenv`.
|
||||
* Values stay off the bash command line (not visible in `ps`), and are inherited
|
||||
* by new panes — including `respawn-pane`. Persists at tmux-session level, so
|
||||
* Codeman server restarts don't lose the setting as long as the tmux session lives.
|
||||
*
|
||||
* Key validation is strict (`/^[A-Z_][A-Z0-9_]*$/`) as defense-in-depth against
|
||||
* shell-metachar injection even if upstream schema check is bypassed.
|
||||
*/
|
||||
private applyEnvOverrides(muxName: string, envOverrides?: Record<string, string>): void {
|
||||
// Legacy cleanup: pre-0.7.2 set CLAUDE_CODE_EFFORT_LEVEL via setenv, which persists
|
||||
// on the tmux session and hard-locks /effort switching in every respawned pane.
|
||||
// Effort now flows as a `--settings` soft default (see buildEffortSettingsFlag),
|
||||
// so unconditionally unset the stale var before applying current overrides.
|
||||
try {
|
||||
execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} -u CLAUDE_CODE_EFFORT_LEVEL`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch {
|
||||
/* Non-critical — var may not exist */
|
||||
}
|
||||
if (!envOverrides) return;
|
||||
const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/;
|
||||
for (const [key, value] of Object.entries(envOverrides)) {
|
||||
if (!value) continue; // Skip empty — nothing to set
|
||||
if (!VALID_KEY.test(key)) {
|
||||
console.warn(`[TmuxManager] Skipping invalid env override key: ${JSON.stringify(key)}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[TmuxManager] Failed to set env override ${key}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the CLI binary directory and return the PATH export prefix string.
|
||||
* Returns '' if no override is needed (shell mode) or the binary dir is not found.
|
||||
@@ -400,8 +594,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* (not visible in ps output or tmux history, inherited by panes).
|
||||
*/
|
||||
private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void {
|
||||
setOpenCodeEnvVars(muxName);
|
||||
setOpenCodeConfigContent(muxName, openCodeConfig);
|
||||
const tmuxCmd = this.tmux();
|
||||
setOpenCodeEnvVars(tmuxCmd, muxName);
|
||||
setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -420,6 +615,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
} = options;
|
||||
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
||||
|
||||
@@ -466,6 +663,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
});
|
||||
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
@@ -473,7 +671,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
try {
|
||||
// Build the full command to run inside tmux
|
||||
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
|
||||
const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
|
||||
|
||||
// Create tmux session in three steps to handle cold-start (no server running)
|
||||
// and avoid the race where the command exits before remain-on-exit is set:
|
||||
@@ -484,8 +682,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// (Production uses systemd which has a clean env, but dev/test may be nested.)
|
||||
const cleanEnv = { ...process.env };
|
||||
delete cleanEnv.TMUX;
|
||||
execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}" -x 120 -y 40`, {
|
||||
cwd: workingDir,
|
||||
// Start the tmux server from a stable local cwd so FUSE/rclone workspace
|
||||
// blips do not poison tmux's long-lived getcwd state.
|
||||
execSync(`${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD}`, {
|
||||
cwd: TMUX_LAUNCH_CWD,
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
env: cleanEnv,
|
||||
@@ -493,7 +693,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
// Set remain-on-exit now that the server is running — must be before respawn-pane
|
||||
try {
|
||||
execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, {
|
||||
execSync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
@@ -507,11 +707,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this._configureOpenCode(muxName, openCodeConfig);
|
||||
}
|
||||
|
||||
// Replace the shell with the actual command (no echo in terminal)
|
||||
execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
|
||||
// so secret values stay off the bash command line. Must run before respawn-pane.
|
||||
this.applyEnvOverrides(muxName, envOverrides);
|
||||
|
||||
// Replace the shell with the actual command (no echo in terminal). Keep
|
||||
// pane launch in /tmp, then cd inside bash against the current mount table.
|
||||
const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
execSync(
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||
{
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
}
|
||||
);
|
||||
|
||||
// Wait for tmux session to be queryable
|
||||
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||
@@ -522,23 +731,30 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// It gets enabled dynamically when panes are split (agent teams).
|
||||
const configPromises: Promise<void>[] = [
|
||||
// Disable tmux status bar — Codeman's web UI provides session info
|
||||
execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
|
||||
execAsync(`${this.tmux()} set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
/* Non-critical — session still works with status bar */
|
||||
}),
|
||||
// Override global remain-on-exit with session-level setting
|
||||
execAsync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
|
||||
execAsync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
/* Already set globally as fallback */
|
||||
}),
|
||||
// Raise tmux scrollback from its 2000-line default so re-attach preserves
|
||||
// more context. Matches the xterm-side default in constants.js.
|
||||
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS })
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
/* Non-critical — falls back to tmux default */
|
||||
}),
|
||||
];
|
||||
|
||||
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
|
||||
if (!this.trueColorConfigured) {
|
||||
configPromises.push(
|
||||
execAsync(`tmux set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
|
||||
execAsync(`${this.tmux()} set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
|
||||
.then(() => {
|
||||
this.trueColorConfigured = true;
|
||||
})
|
||||
@@ -595,7 +811,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_pid}'`, {
|
||||
const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
@@ -621,7 +837,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (IS_TEST_MODE) return false;
|
||||
if (!isValidMuxName(muxName)) return false;
|
||||
try {
|
||||
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_dead}'`, {
|
||||
const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_dead}'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
@@ -647,6 +863,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
} = options;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
@@ -667,10 +885,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
});
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
const cmd = wrapWithNice(baseCmd, config);
|
||||
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
|
||||
const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
|
||||
|
||||
try {
|
||||
// For OpenCode: set sensitive env vars via tmux setenv before respawn
|
||||
@@ -678,9 +897,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this._configureOpenCode(muxName, openCodeConfig);
|
||||
}
|
||||
|
||||
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
// Re-apply user env overrides before respawn so the new shell inherits them.
|
||||
this.applyEnvOverrides(muxName, envOverrides);
|
||||
|
||||
const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||
await execAsync(
|
||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||||
{
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}
|
||||
);
|
||||
// Wait for the respawned process to start
|
||||
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||||
const pid = this.getPanePid(muxName);
|
||||
@@ -696,7 +922,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (IS_TEST_MODE) return false;
|
||||
|
||||
try {
|
||||
execSync(`tmux has-session -t "${muxName}" 2>/dev/null`, {
|
||||
execSync(`${this.tmux()} has-session -t "${muxName}" 2>/dev/null`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
@@ -836,7 +1062,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
// Strategy 3: Kill tmux session by name
|
||||
try {
|
||||
execSync(`tmux kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} catch {
|
||||
@@ -901,36 +1127,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const dead: string[] = [];
|
||||
const discovered: string[] = [];
|
||||
|
||||
// Batch: single tmux call to get all session names + pane PIDs (replaces N per-session subprocess calls)
|
||||
const activeSessions = new Map<string, number>();
|
||||
// Single batched query against the one socket Codeman owns. With a single
|
||||
// socket a session's location is a constant, so there is no per-session
|
||||
// socket tag to reconcile and no cross-socket ambiguity that could mark a
|
||||
// live session dead (the root cause of vanished/duplicate tabs).
|
||||
let active: Map<string, number>;
|
||||
try {
|
||||
const output = execSync("tmux list-panes -a -F '#{session_name}\t#{pane_pid}' 2>/dev/null || true", {
|
||||
const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
|
||||
for (const line of output.split('\n')) {
|
||||
if (!line) continue;
|
||||
const sep = line.indexOf('\t');
|
||||
if (sep === -1) continue;
|
||||
const name = line.slice(0, sep);
|
||||
const pid = parseInt(line.slice(sep + 1), 10);
|
||||
if (name && !Number.isNaN(pid)) {
|
||||
activeSessions.set(name, pid);
|
||||
}
|
||||
}
|
||||
active = parsePaneList(output);
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to list tmux panes:', err);
|
||||
active = new Map();
|
||||
}
|
||||
|
||||
// Check known sessions against the batch result (O(1) map lookup instead of subprocess per session)
|
||||
// Check tracked sessions against the live pane list.
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
const pid = activeSessions.get(session.muxName);
|
||||
const pid = active.get(session.muxName);
|
||||
if (pid !== undefined) {
|
||||
alive.push(sessionId);
|
||||
if (pid !== session.pid) {
|
||||
session.pid = pid;
|
||||
}
|
||||
if (pid !== session.pid) session.pid = pid;
|
||||
} else {
|
||||
dead.push(sessionId);
|
||||
this.sessions.delete(sessionId);
|
||||
@@ -938,13 +1156,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
// Discover unknown codeman/claudeman sessions from the same batch result
|
||||
// Discover untracked codeman/claudeman sessions on our socket. Dedup by
|
||||
// muxName (globally unique) so a name we already track never spawns a
|
||||
// second "Restored:" entry.
|
||||
const knownMuxNames = new Set<string>();
|
||||
for (const session of this.sessions.values()) {
|
||||
knownMuxNames.add(session.muxName);
|
||||
}
|
||||
|
||||
for (const [sessionName, pid] of activeSessions) {
|
||||
for (const [sessionName, pid] of active) {
|
||||
if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue;
|
||||
if (knownMuxNames.has(sessionName)) continue;
|
||||
|
||||
@@ -961,6 +1181,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
name: `Restored: ${sessionName}`,
|
||||
};
|
||||
this.sessions.set(sessionId, session);
|
||||
knownMuxNames.add(sessionName);
|
||||
discovered.push(sessionId);
|
||||
console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`);
|
||||
}
|
||||
@@ -981,19 +1202,23 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
try {
|
||||
const psOutput = execSync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
const psOutput = (
|
||||
await execAsync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
})
|
||||
).stdout.trim();
|
||||
|
||||
const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0);
|
||||
|
||||
let childCount = 0;
|
||||
try {
|
||||
const childOutput = execSync(`pgrep -P ${session.pid} | wc -l`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
const childOutput = (
|
||||
await execAsync(`pgrep -P ${session.pid} | wc -l`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
})
|
||||
).stdout.trim();
|
||||
childCount = parseInt(childOutput, 10) || 0;
|
||||
} catch {
|
||||
// No children or command failed
|
||||
@@ -1030,13 +1255,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// Step 1: Get descendant PIDs
|
||||
const descendantMap = new Map<number, number[]>();
|
||||
|
||||
const pgrepOutput = execSync(
|
||||
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}
|
||||
).trim();
|
||||
const pgrepOutput = (
|
||||
await execAsync(
|
||||
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}
|
||||
)
|
||||
).stdout.trim();
|
||||
|
||||
for (const line of pgrepOutput.split('\n')) {
|
||||
const [pidStr, childrenStr] = line.split(':');
|
||||
@@ -1061,10 +1288,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// Step 3: Single ps call
|
||||
const pidArray = Array.from(allPids);
|
||||
if (pidArray.length > 0) {
|
||||
const psOutput = execSync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
const psOutput = (
|
||||
await execAsync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
})
|
||||
).stdout.trim();
|
||||
|
||||
const processStats = new Map<number, { rss: number; cpu: number }>();
|
||||
for (const line of psOutput.split('\n')) {
|
||||
@@ -1152,11 +1381,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
clearInterval(this.mouseSyncInterval);
|
||||
}
|
||||
|
||||
this.mouseSyncInterval = setInterval(() => {
|
||||
this.mouseSyncInterval = setInterval(async () => {
|
||||
if (IS_TEST_MODE) return;
|
||||
|
||||
for (const session of this.sessions.values()) {
|
||||
const panes = this.listPanes(session.muxName);
|
||||
const panes = await this.listPanes(session.muxName);
|
||||
const count = panes.length;
|
||||
if (count === 0) continue;
|
||||
|
||||
@@ -1165,12 +1394,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
// Pane count changed — toggle mouse mode
|
||||
if (count > 1) {
|
||||
if (this.enableMouseMode(session.muxName)) {
|
||||
if (await this.enableMouseMode(session.muxName)) {
|
||||
this.lastPaneCount.set(session.muxName, count);
|
||||
}
|
||||
// If enableMouseMode fails, DON'T update lastPaneCount — retry next poll
|
||||
} else {
|
||||
if (this.disableMouseMode(session.muxName)) {
|
||||
if (await this.disableMouseMode(session.muxName)) {
|
||||
this.lastPaneCount.set(session.muxName, count);
|
||||
}
|
||||
}
|
||||
@@ -1268,21 +1497,21 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// Ink (Claude CLI's terminal framework) needs them split — sending both in a
|
||||
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline
|
||||
// character in the input buffer rather than as form submission.
|
||||
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
|
||||
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
|
||||
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} else if (textPart) {
|
||||
// Text only, no Enter
|
||||
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
|
||||
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} else if (hasCarriageReturn) {
|
||||
// Enter only
|
||||
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
|
||||
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
@@ -1301,7 +1530,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* Allows clicking to select panes in agent team split-pane layouts.
|
||||
* When mouse mode is on, tmux intercepts mouse events (slow selection, no browser copy).
|
||||
*/
|
||||
enableMouseMode(muxName: string): boolean {
|
||||
async enableMouseMode(muxName: string): Promise<boolean> {
|
||||
if (IS_TEST_MODE) return true;
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in enableMouseMode:', muxName);
|
||||
@@ -1309,7 +1538,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`tmux set-option -t "${muxName}" mouse on`, {
|
||||
await execAsync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
@@ -1325,7 +1554,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* Disable mouse mode for an existing tmux session.
|
||||
* Restores native xterm.js text selection and browser clipboard copy.
|
||||
*/
|
||||
disableMouseMode(muxName: string): boolean {
|
||||
async disableMouseMode(muxName: string): Promise<boolean> {
|
||||
if (IS_TEST_MODE) return true;
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in disableMouseMode:', muxName);
|
||||
@@ -1333,7 +1562,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`tmux set-option -t "${muxName}" mouse off`, {
|
||||
await execAsync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
@@ -1350,9 +1579,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* Called by TeamWatcher when teammates spawn/despawn panes.
|
||||
* Uses `tmux list-panes` for bulletproof detection — counts actual panes, not config.
|
||||
*/
|
||||
syncMouseMode(muxName: string): boolean {
|
||||
async syncMouseMode(muxName: string): Promise<boolean> {
|
||||
if (IS_TEST_MODE) return true;
|
||||
const panes = this.listPanes(muxName);
|
||||
const panes = await this.listPanes(muxName);
|
||||
if (panes.length > 1) {
|
||||
return this.enableMouseMode(muxName);
|
||||
} else {
|
||||
@@ -1364,7 +1593,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
* List all panes in a tmux session.
|
||||
* Returns structured info for each pane.
|
||||
*/
|
||||
listPanes(muxName: string): PaneInfo[] {
|
||||
async listPanes(muxName: string): Promise<PaneInfo[]> {
|
||||
if (IS_TEST_MODE) return [];
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in listPanes:', muxName);
|
||||
@@ -1372,10 +1601,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execSync(
|
||||
`tmux list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
const output = (
|
||||
await execAsync(
|
||||
`${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
)
|
||||
).stdout.trim();
|
||||
|
||||
return output
|
||||
.split('\n')
|
||||
@@ -1412,27 +1643,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
|
||||
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
|
||||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||
const tmux = this.tmux();
|
||||
|
||||
try {
|
||||
const hasCarriageReturn = input.includes('\r');
|
||||
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
|
||||
|
||||
if (textPart && hasCarriageReturn) {
|
||||
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
|
||||
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
|
||||
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} else if (textPart) {
|
||||
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
|
||||
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
} else if (hasCarriageReturn) {
|
||||
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
|
||||
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
@@ -1463,7 +1695,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||
|
||||
try {
|
||||
return execSync(`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
|
||||
return execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
@@ -1495,7 +1727,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||
|
||||
try {
|
||||
execSync(`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
|
||||
execSync(`${this.tmux()} pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
@@ -1523,7 +1755,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||
|
||||
try {
|
||||
execSync(`tmux pipe-pane -t ${shellescape(target)}`, {
|
||||
execSync(`${this.tmux()} pipe-pane -t ${shellescape(target)}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
@@ -1539,7 +1771,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
getAttachArgs(muxName: string): string[] {
|
||||
return ['attach-session', '-t', muxName];
|
||||
return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName];
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { createInterface } from 'node:readline';
|
||||
/**
|
||||
* Parsed transcript entry from the JSONL file
|
||||
*/
|
||||
export interface TranscriptEntry {
|
||||
interface TranscriptEntry {
|
||||
type: 'user' | 'assistant' | 'system' | 'result';
|
||||
timestamp: string;
|
||||
message?: {
|
||||
@@ -35,7 +35,7 @@ export interface TranscriptEntry {
|
||||
};
|
||||
}
|
||||
|
||||
export interface TranscriptContentBlock {
|
||||
interface TranscriptContentBlock {
|
||||
type: 'text' | 'tool_use' | 'tool_result';
|
||||
text?: string;
|
||||
name?: string;
|
||||
@@ -68,15 +68,6 @@ export interface TranscriptState {
|
||||
lastUpdateAt: string | null;
|
||||
}
|
||||
|
||||
export interface TranscriptWatcherEvents {
|
||||
'transcript:update': (state: TranscriptState) => void;
|
||||
'transcript:complete': (state: TranscriptState) => void;
|
||||
'transcript:tool_start': (toolName: string) => void;
|
||||
'transcript:tool_end': (toolName: string, isError: boolean) => void;
|
||||
'transcript:error': (error: Error) => void;
|
||||
'transcript:plan_mode': () => void;
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
/** How often to check for new content when file watching fails */
|
||||
@@ -435,7 +426,3 @@ export class TranscriptWatcher extends EventEmitter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Singleton Export ==========
|
||||
|
||||
export const transcriptWatcher = new TranscriptWatcher();
|
||||
|
||||
@@ -33,7 +33,7 @@ import { getErrorMessage } from './types.js';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface TunnelStatus {
|
||||
interface TunnelStatus {
|
||||
running: boolean;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
@@ -268,18 +268,3 @@ export function createInitialOrchestratorStats(): OrchestratorStats {
|
||||
replanCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Factory function for initial orchestrator persist state */
|
||||
export function createInitialOrchestratorPersistState(
|
||||
config: OrchestratorConfig = DEFAULT_ORCHESTRATOR_CONFIG
|
||||
): OrchestratorPersistState {
|
||||
return {
|
||||
state: 'idle',
|
||||
plan: null,
|
||||
currentPhaseIndex: 0,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
config,
|
||||
stats: createInitialOrchestratorStats(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,22 +230,6 @@ export interface TimingHistory {
|
||||
/**
|
||||
* Named respawn configuration preset for quick setup
|
||||
*/
|
||||
export interface RespawnPreset {
|
||||
/** Unique preset identifier */
|
||||
id: string;
|
||||
/** User-friendly preset name */
|
||||
name: string;
|
||||
/** Description of when to use this preset */
|
||||
description?: string;
|
||||
/** The respawn configuration (without enabled flag) */
|
||||
config: Omit<RespawnConfig, 'enabled'>;
|
||||
/** Duration in minutes (optional default) */
|
||||
durationMinutes?: number;
|
||||
/** Whether this is a built-in preset */
|
||||
builtIn?: boolean;
|
||||
/** Timestamp when created */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted respawn configuration for mux sessions.
|
||||
|
||||
+17
-8
@@ -40,6 +40,21 @@ export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedToo
|
||||
/** Session mode: which CLI backend a session runs */
|
||||
export type SessionMode = 'claude' | 'shell' | 'opencode';
|
||||
|
||||
/**
|
||||
* Valid Claude CLI effort levels (claude >= 2.1.154).
|
||||
* `ultracode` = xhigh effort + standing dynamic-workflow orchestration; it is a
|
||||
* separate `ultracode` settings key rather than an `effortLevel` value.
|
||||
*/
|
||||
export const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode'] as const;
|
||||
|
||||
/** Claude CLI effort level for new sessions (soft default, switchable via /effort in-session) */
|
||||
export type EffortLevel = (typeof EFFORT_LEVELS)[number];
|
||||
|
||||
/** Type guard: is the string a valid EffortLevel? */
|
||||
export function isEffortLevel(value: string | undefined): value is EffortLevel {
|
||||
return value !== undefined && (EFFORT_LEVELS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** OpenCode session configuration */
|
||||
export interface OpenCodeConfig {
|
||||
/** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */
|
||||
@@ -145,16 +160,10 @@ export interface SessionState {
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
||||
resumeSessionId?: string;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort?: EffortLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output captured from a session
|
||||
*/
|
||||
export interface SessionOutput {
|
||||
/** Standard output content */
|
||||
stdout: string;
|
||||
/** Standard error content */
|
||||
stderr: string;
|
||||
/** Exit code of the process, null if still running */
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Disposable, CleanupRegistration, CleanupResourceType } from '../ty
|
||||
/**
|
||||
* Options for setTimeout/setInterval with automatic cleanup.
|
||||
*/
|
||||
export interface TimerOptions {
|
||||
interface TimerOptions {
|
||||
/** Human-readable description for debugging */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @fileoverview Event-loop lag monitor.
|
||||
*
|
||||
* Node is single-threaded: any synchronous work (e.g. a blocking `execSync`)
|
||||
* freezes the whole event loop, so the HTTP server stops answering on its port
|
||||
* while the process stays alive and other ports are unaffected. Such stalls
|
||||
* self-heal and never restart the process, so a periodic loopback healthcheck
|
||||
* misses them entirely — they leave no trace.
|
||||
*
|
||||
* This monitor samples how late a fixed-interval timer actually fires versus when
|
||||
* it was scheduled; the excess is time the loop was blocked. When that exceeds a
|
||||
* threshold it logs the measured stall, turning otherwise-invisible "port briefly
|
||||
* unreachable" incidents into a timestamped, quantified log line.
|
||||
*
|
||||
* @module utils/event-loop-monitor
|
||||
*/
|
||||
|
||||
export interface EventLoopMonitorHandle {
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start sampling event-loop lag.
|
||||
*
|
||||
* @param sampleMs How often to sample (and the baseline interval lag is measured against).
|
||||
* @param thresholdMs Only stalls at or above this many ms are logged (noise floor).
|
||||
* @param log Sink for stall reports; defaults to console.warn (lands in the web log).
|
||||
*/
|
||||
export function startEventLoopMonitor(
|
||||
sampleMs = 1000,
|
||||
thresholdMs = 1000,
|
||||
log: (msg: string) => void = (m) => console.warn(m)
|
||||
): EventLoopMonitorHandle {
|
||||
let last = performance.now();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const now = performance.now();
|
||||
// Lag = elapsed beyond the scheduled interval = time the loop was blocked.
|
||||
const lag = Math.round(now - last - sampleMs);
|
||||
if (lag >= thresholdMs) {
|
||||
log(`[EventLoopLag] event loop blocked ~${lag}ms (at ${new Date().toISOString()})`);
|
||||
}
|
||||
last = now;
|
||||
}, sampleMs);
|
||||
|
||||
// Never keep the process alive solely for this monitor.
|
||||
timer.unref?.();
|
||||
|
||||
return {
|
||||
stop() {
|
||||
clearInterval(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
+5
-6
@@ -7,17 +7,16 @@
|
||||
*/
|
||||
|
||||
export { BufferAccumulator } from './buffer-accumulator.js';
|
||||
export { LRUMap, type LRUMapOptions } from './lru-map.js';
|
||||
export { CleanupManager, type TimerOptions } from './cleanup-manager.js';
|
||||
export { CleanupManager } from './cleanup-manager.js';
|
||||
export { Debouncer, KeyedDebouncer } from './debouncer.js';
|
||||
export { StaleExpirationMap, type StaleExpirationMapOptions } from './stale-expiration-map.js';
|
||||
export { startEventLoopMonitor } from './event-loop-monitor.js';
|
||||
export type { EventLoopMonitorHandle } from './event-loop-monitor.js';
|
||||
export { StaleExpirationMap } from './stale-expiration-map.js';
|
||||
export {
|
||||
ANSI_ESCAPE_PATTERN_FULL,
|
||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||
TOKEN_PATTERN,
|
||||
SPINNER_PATTERN,
|
||||
createAnsiPatternFull,
|
||||
createAnsiPatternSimple,
|
||||
stripAnsi,
|
||||
SAFE_PATH_PATTERN,
|
||||
execPattern,
|
||||
@@ -27,4 +26,4 @@ export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-si
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
|
||||
export { resolveOpenCodeDir, isOpenCodeAvailable } from './opencode-cli-resolver.js';
|
||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
/**
|
||||
* Configuration options for LRUMap.
|
||||
*/
|
||||
export interface LRUMapOptions<K, V> {
|
||||
interface LRUMapOptions<K, V> {
|
||||
/** Maximum number of entries before eviction */
|
||||
maxSize: number;
|
||||
/** Optional callback when an entry is evicted */
|
||||
|
||||
@@ -40,24 +40,6 @@ export const ANSI_ESCAPE_PATTERN_SIMPLE = /\x1b\[[0-9;]*[A-Za-z]/g;
|
||||
*/
|
||||
export const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/;
|
||||
|
||||
/**
|
||||
* Creates a fresh copy of ANSI_ESCAPE_PATTERN_FULL.
|
||||
* Use when you need a pattern without shared lastIndex state.
|
||||
*/
|
||||
export function createAnsiPatternFull(): RegExp {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fresh copy of ANSI_ESCAPE_PATTERN_SIMPLE.
|
||||
* Use when you need a pattern without shared lastIndex state.
|
||||
*/
|
||||
export function createAnsiPatternSimple(): RegExp {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return /\x1b\[[0-9;]*[A-Za-z]/g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips ANSI escape codes from text using the comprehensive pattern.
|
||||
* @param text - Text containing ANSI escape codes
|
||||
|
||||
@@ -22,7 +22,7 @@ interface TimedEntry<V> {
|
||||
/**
|
||||
* Configuration options for StaleExpirationMap.
|
||||
*/
|
||||
export interface StaleExpirationMapOptions<K, V> {
|
||||
interface StaleExpirationMapOptions<K, V> {
|
||||
/** Time-to-live in milliseconds before entries expire */
|
||||
ttlMs: number;
|
||||
/** How often to run cleanup (default: ttlMs / 2) */
|
||||
|
||||
+31
-13
@@ -8,7 +8,7 @@
|
||||
* - CORS (localhost only)
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { StaleExpirationMap } from '../../utils/index.js';
|
||||
import type { AuthSessionRecord } from '../ports/auth-port.js';
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
export const AUTH_COOKIE_NAME = 'codeman_session';
|
||||
|
||||
/** State returned from registerAuthMiddleware for cleanup in server stop() */
|
||||
export interface AuthState {
|
||||
interface AuthState {
|
||||
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
|
||||
authFailures: StaleExpirationMap<string, number> | null;
|
||||
qrAuthFailures: StaleExpirationMap<string, number> | null;
|
||||
@@ -69,6 +69,13 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
const authSessions = state.authSessions;
|
||||
const authFailures = state.authFailures;
|
||||
|
||||
function sendAuthRateLimit(reply: FastifyReply, clientIp: string): void {
|
||||
const remainingMs = authFailures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
|
||||
reply.header('Retry-After', String(retryAfterSeconds));
|
||||
reply.code(429).send('Too Many Requests — try again later');
|
||||
}
|
||||
|
||||
app.addHook('onRequest', (req, reply, done) => {
|
||||
// Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available.
|
||||
// Safe: validated by HookEventSchema, only triggers broadcasts.
|
||||
@@ -90,13 +97,6 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Rate limit: reject if too many failed attempts from this IP
|
||||
const failures = authFailures.get(clientIp) ?? 0;
|
||||
if (failures >= AUTH_FAILURE_MAX) {
|
||||
reply.code(429).send('Too Many Requests — try again later');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check session cookie first (avoids re-sending credentials on every request)
|
||||
// Use get() instead of has() so refreshOnGet extends the TTL on active sessions
|
||||
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
||||
@@ -140,6 +140,13 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limit only requests that failed to authenticate on this attempt.
|
||||
const failures = authFailures.get(clientIp) ?? 0;
|
||||
if (failures >= AUTH_FAILURE_MAX) {
|
||||
sendAuthRateLimit(reply, clientIp);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth failed — track failure count
|
||||
authFailures.set(clientIp, failures + 1);
|
||||
|
||||
@@ -154,13 +161,24 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
* Register security headers and CORS middleware on every response.
|
||||
*/
|
||||
export function registerSecurityHeaders(app: FastifyInstance, https: boolean): void {
|
||||
// Gesture-control overlay (opt-in via CODEMAN_GESTURE=1) runs MediaPipe, which
|
||||
// needs WebAssembly eval (script-src) and blob workers (worker-src). Its wasm
|
||||
// runtime + model are self-hosted under /gesture/ (same-origin, covered by
|
||||
// 'self'), so no CDN connect-src entries are needed. OFF by default so the
|
||||
// production CSP is byte-for-byte unchanged.
|
||||
const gesture = process.env.CODEMAN_GESTURE === '1';
|
||||
const scriptSrc =
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net" + (gesture ? " 'wasm-unsafe-eval'" : '');
|
||||
const connectSrc = "connect-src 'self' wss://api.deepgram.com";
|
||||
const workerSrc = gesture ? "; worker-src 'self' blob:" : '';
|
||||
const csp =
|
||||
`default-src 'self'; ${scriptSrc}; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; ` +
|
||||
`img-src 'self' data: blob:; ${connectSrc}; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'${workerSrc}`;
|
||||
|
||||
app.addHook('onRequest', (req, reply, done) => {
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
reply.header('X-Frame-Options', 'SAMEORIGIN');
|
||||
reply.header(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; connect-src 'self' wss://api.deepgram.com; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'"
|
||||
);
|
||||
reply.header('Content-Security-Policy', csp);
|
||||
if (https) {
|
||||
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
const EXPLICIT_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||
|
||||
export function isExplicitlyEnabled(value: string | undefined): boolean {
|
||||
return value !== undefined && EXPLICIT_TRUE_VALUES.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function isLoopbackBindHost(host: string): boolean {
|
||||
const normalized = host
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\[(.*)\]$/, '$1');
|
||||
if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') {
|
||||
return true;
|
||||
}
|
||||
if (isIP(normalized) === 4 && normalized.startsWith('127.')) {
|
||||
return true;
|
||||
}
|
||||
return normalized.startsWith('::ffff:127.');
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @fileoverview Periodic GC for paste-image files.
|
||||
*
|
||||
* Without cleanup, /api/sessions/:id/paste-image accumulates files indefinitely
|
||||
* under {workingDir}/.claude-images/. The route only triggers cleanup on
|
||||
* killMux=true session deletion, so long-lived sessions can fill disk under
|
||||
* heavy pasting. This sweeper bounds disk use by deleting `paste-*` files
|
||||
* older than MAX_AGE_MS from each live session's image dir on an interval.
|
||||
*
|
||||
* Conservative defaults — only files matching the `paste-` prefix are
|
||||
* considered, and we lstat (not stat) so a planted symlink cannot escape the
|
||||
* image dir.
|
||||
*/
|
||||
import fs from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { SessionPort } from './ports/index.js';
|
||||
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const INITIAL_DELAY_MS = 30 * 1000; // 30s after startup
|
||||
|
||||
export async function sweepPasteImagesOnce(
|
||||
ctx: Pick<SessionPort, 'sessions'>,
|
||||
now: number = Date.now()
|
||||
): Promise<{ scanned: number; deleted: number }> {
|
||||
const cutoff = now - MAX_AGE_MS;
|
||||
let scanned = 0;
|
||||
let deleted = 0;
|
||||
for (const session of ctx.sessions.values()) {
|
||||
const dir = join(session.workingDir, '.claude-images');
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(dir);
|
||||
} catch {
|
||||
continue; // dir absent — nothing to do
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (!name.startsWith('paste-')) continue;
|
||||
const p = join(dir, name);
|
||||
scanned += 1;
|
||||
try {
|
||||
const st = await fs.lstat(p);
|
||||
if (!st.isFile()) continue;
|
||||
if (st.mtimeMs < cutoff) {
|
||||
await fs.unlink(p);
|
||||
deleted += 1;
|
||||
}
|
||||
} catch {
|
||||
// best-effort: skip permission/race errors silently
|
||||
}
|
||||
}
|
||||
}
|
||||
return { scanned, deleted };
|
||||
}
|
||||
|
||||
export function startPasteImageGc(ctx: Pick<SessionPort, 'sessions'>): () => void {
|
||||
const initial = setTimeout(() => {
|
||||
void sweepPasteImagesOnce(ctx);
|
||||
}, INITIAL_DELAY_MS);
|
||||
const interval = setInterval(() => {
|
||||
void sweepPasteImagesOnce(ctx);
|
||||
}, SWEEP_INTERVAL_MS);
|
||||
if (typeof initial.unref === 'function') initial.unref();
|
||||
if (typeof interval.unref === 'function') interval.unref();
|
||||
return (): void => {
|
||||
clearTimeout(initial);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
@@ -11,5 +11,5 @@ export type { EventPort } from './event-port.js';
|
||||
export type { RespawnPort } from './respawn-port.js';
|
||||
export type { ConfigPort } from './config-port.js';
|
||||
export type { InfraPort, ScheduledRun } from './infra-port.js';
|
||||
export type { AuthPort, AuthSessionRecord } from './auth-port.js';
|
||||
export type { AuthPort } from './auth-port.js';
|
||||
export type { OrchestratorPort } from './orchestrator-port.js';
|
||||
|
||||
+669
-77
@@ -249,6 +249,9 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.ORCHESTRATOR_TASK_FAILED, '_onOrchestratorTaskFailed'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_COMPLETED, '_onOrchestratorCompleted'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'],
|
||||
|
||||
// Clipboard
|
||||
[SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'],
|
||||
];
|
||||
|
||||
|
||||
@@ -283,9 +286,31 @@ class CodemanApp {
|
||||
this.totalTokens = 0;
|
||||
this.globalStats = null; // Global token/cost stats across all sessions
|
||||
this.eventSource = null;
|
||||
// Stable per-page client ID — lets the server target this connection
|
||||
// for live filter updates (POST /api/events/subscribe) without forcing
|
||||
// an SSE reconnect on session switches.
|
||||
this._clientId = (typeof crypto !== 'undefined' && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: 'c-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
this.terminal = null;
|
||||
this.fitAddon = null;
|
||||
this.activeSessionId = null;
|
||||
|
||||
// ── Session detach / undock (beta) ───────────────────────────────────
|
||||
// A "solo window" is a popped-out browser window showing exactly one
|
||||
// session. Detected from the /session/:id URL path (robust even if a cached
|
||||
// service-worker shell loads), with the server-injected global as a fallback.
|
||||
this.soloSessionId = this._detectSoloSessionId();
|
||||
this.isSoloWindow = !!this.soloSessionId;
|
||||
this.detachedSessions = new Set(); // dashboard-side: ids currently popped out
|
||||
this.detachedWindows = new Map(); // dashboard-side: id -> WindowProxy
|
||||
this._detachWatchTimers = new Map(); // dashboard-side: id -> setInterval handle
|
||||
this.windowChannel = null; // BroadcastChannel for cross-window sync
|
||||
this._redockGrace = new Map(); // id -> timer: deferred redock (debounces popup reloads)
|
||||
this._detachPingPending = null; // Set of ids awaiting a liveness answer
|
||||
this._detachLivenessTimer = null; // periodic reconcile of channel-only detached windows
|
||||
this._detachOrphanStrikes = new Map(); // id -> consecutive unanswered roll-calls (redock at 2)
|
||||
|
||||
this._initGeneration = 0; // dedup concurrent handleInit calls
|
||||
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
||||
this._selectGeneration = 0; // cancel stale selectSession loads
|
||||
@@ -535,6 +560,11 @@ class CodemanApp {
|
||||
init() {
|
||||
// Initialize mobile detection first (adds device classes to body)
|
||||
MobileDetection.init();
|
||||
// Detach/undock: open the cross-window sync channel; if this is a solo
|
||||
// (popped-out) window, apply its minimal chrome immediately so the tab
|
||||
// strip never flashes before handleInit selects the target session.
|
||||
this._initWindowChannel();
|
||||
if (this.isSoloWindow) document.body.classList.add('solo-mode');
|
||||
// Initialize mobile handlers
|
||||
KeyboardHandler.init();
|
||||
SwipeHandler.init();
|
||||
@@ -614,14 +644,66 @@ class CodemanApp {
|
||||
this._webglAddon = new WebglAddon.WebglAddon();
|
||||
this._webglAddon.onContextLoss(() => {
|
||||
console.error('[CRASH-DIAG] WebGL context LOST — falling back to canvas renderer');
|
||||
this._webglAddon.dispose();
|
||||
_crashDiag.log('WEBGL_LOST');
|
||||
this._disableWebGLSticky('context-lost');
|
||||
this._disposeWebGLObserver();
|
||||
this._webglAddon?.dispose();
|
||||
this._webglAddon = null;
|
||||
});
|
||||
this.terminal.loadAddon(this._webglAddon);
|
||||
console.log('[CRASH-DIAG] WebGL renderer enabled');
|
||||
this._installWebGLLongTaskGuard();
|
||||
} catch (_e) { /* WebGL2 unavailable — canvas renderer used */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for sustained main-thread stalls that indicate WebGL/GPU trouble.
|
||||
* After WEBGL_FALLBACK.LONGTASK_COUNT long tasks (>=LONGTASK_MS each) within
|
||||
* WINDOW_MS, dispose the WebGL addon and persist a sticky disable so
|
||||
* subsequent reloads also use the DOM renderer. GRACE_MS skips initial-load
|
||||
* stalls. Force-re-enable: ?webgl=force.
|
||||
*/
|
||||
_installWebGLLongTaskGuard() {
|
||||
if (typeof PerformanceObserver === 'undefined' || this._webglLongTaskObserver) return;
|
||||
const installedAt = performance.now();
|
||||
const recent = [];
|
||||
try {
|
||||
this._webglLongTaskObserver = new PerformanceObserver((list) => {
|
||||
if (!this._webglAddon) return;
|
||||
const now = performance.now();
|
||||
if (now - installedAt < WEBGL_FALLBACK.GRACE_MS) return;
|
||||
if (evaluateWebGLLongTaskTrip(recent, list.getEntries(), now)) {
|
||||
console.warn(`[CRASH-DIAG] WebGL long-task threshold (${recent.length} stalls/${WEBGL_FALLBACK.WINDOW_MS}ms) — falling back to canvas renderer`);
|
||||
_crashDiag.log(`WEBGL_FALLBACK: ${recent.length}`);
|
||||
this._disableWebGLSticky('long-tasks');
|
||||
this._disposeWebGLObserver();
|
||||
this._webglAddon?.dispose();
|
||||
this._webglAddon = null;
|
||||
try { this.terminal.refresh(0, this.terminal.rows - 1); } catch {}
|
||||
}
|
||||
});
|
||||
this._webglLongTaskObserver.observe({ type: 'longtask', buffered: false });
|
||||
} catch { /* longtask not supported */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the WebGL longtask observer. Idempotent. Called from the trip
|
||||
* path, the onContextLoss handler, and any future terminal-teardown path —
|
||||
* the observer outlives its addon otherwise, holding a closure reference
|
||||
* over `this` for every long task the page emits.
|
||||
*/
|
||||
_disposeWebGLObserver() {
|
||||
if (!this._webglLongTaskObserver) return;
|
||||
try { this._webglLongTaskObserver.disconnect(); } catch {}
|
||||
this._webglLongTaskObserver = null;
|
||||
}
|
||||
|
||||
_disableWebGLSticky(reason) {
|
||||
try {
|
||||
localStorage.setItem('codeman-webgl-disabled', JSON.stringify({ reason, at: Date.now() }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Event Listeners (Keyboard Shortcuts, Resize, Beforeunload)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -639,6 +721,8 @@ class CodemanApp {
|
||||
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
|
||||
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
|
||||
{ key: 'V', ctrl: true, shift: true, action: () => VoiceInput.toggle() },
|
||||
{ key: '{', ctrl: true, shift: true, action: () => this.moveActiveTabLeft() },
|
||||
{ key: '}', ctrl: true, shift: true, action: () => this.moveActiveTabRight() },
|
||||
];
|
||||
|
||||
// Use capture to handle before terminal
|
||||
@@ -691,6 +775,278 @@ class CodemanApp {
|
||||
// SSE Connection
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* POST a live subscription update so the server filters terminal events
|
||||
* to the given session(s) for this client. Fire-and-forget — failures
|
||||
* are non-fatal because we'll still get every event we don't want
|
||||
* (just at higher cost), and the next reconnect carries the filter via
|
||||
* the SSE query string.
|
||||
*/
|
||||
_updateSseSubscription(sessionId) {
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
clientId: this._clientId,
|
||||
sessions: sessionId ? [sessionId] : null,
|
||||
});
|
||||
fetch('/api/events/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
keepalive: true,
|
||||
}).catch(() => { /* non-fatal */ });
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// Session detach / undock (beta/session-detach)
|
||||
//
|
||||
// Each detached window is just another normal client of the same session:
|
||||
// the server already fans one PTY's output out to N SSE/WS clients and merges
|
||||
// input from all of them, so a popped-out window is live with no extra server
|
||||
// plumbing. The dashboard tracks which sessions are out, marks their tabs, and
|
||||
// re-docks when the window closes. A BroadcastChannel keeps state in sync
|
||||
// across windows (and survives a dashboard reload via roll-call).
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Resolve the solo session id from the URL path (preferred) or the
|
||||
* server-injected global (fallback). Returns null for the normal dashboard. */
|
||||
_detectSoloSessionId() {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && typeof window.__CODEMAN_SOLO__ === 'string' && window.__CODEMAN_SOLO__) {
|
||||
return window.__CODEMAN_SOLO__;
|
||||
}
|
||||
const m = location.pathname.match(/^\/session\/([^/]+)\/?$/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop a session out into its own browser window. SINGLE, idempotent entry
|
||||
* point: the tab's pop-out icon calls this, and a future gesture layer
|
||||
* ("pinch to drop") calls the exact same method — so keep it cheap and
|
||||
* side-effect-light. Calling it again for an already-open window just raises
|
||||
* that window.
|
||||
* @param {string} id session id
|
||||
*/
|
||||
detachSession(id) {
|
||||
if (this.isSoloWindow) return; // a solo window can't spawn more
|
||||
if (!this.sessions.has(id)) return;
|
||||
// Already detached → raise the existing popup instead of opening (or
|
||||
// reloading) another. Mirrors the tab-click path: after a dashboard reload
|
||||
// we hold no WindowProxy ref, so this raises via the channel rather than
|
||||
// re-running window.open (which would reload the popup's terminal). Returns
|
||||
// false only when we owned a now-closed window (re-dock + fall through to
|
||||
// genuinely re-open below).
|
||||
if (this.detachedSessions.has(id) && this._raiseDetached(id)) return;
|
||||
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
|
||||
let win = null;
|
||||
try { win = window.open('/session/' + encodeURIComponent(id), 'codeman-session-' + id, features); } catch {}
|
||||
if (!win) {
|
||||
this.showToast?.('Pop-out blocked — allow popups for this site to detach a session', 'error');
|
||||
return;
|
||||
}
|
||||
this.detachedWindows.set(id, win);
|
||||
this._markDetached(id, true);
|
||||
this._watchDetachedWindow(id, win);
|
||||
this._postWindowMessage({ type: 'detached', id });
|
||||
try { win.focus(); } catch {}
|
||||
}
|
||||
|
||||
/** Raise the popup for an already-detached session. Returns true if the raise
|
||||
* was handled (caller should stop); false if we owned a now-closed window and
|
||||
* re-docked it (caller should fall through to inline / re-open). Unifies the
|
||||
* pop-out icon and tab-click paths so neither reloads a live popup. */
|
||||
_raiseDetached(id) {
|
||||
const win = this.detachedWindows.get(id);
|
||||
if (win && !win.closed) { try { win.focus(); } catch {} return true; }
|
||||
if (win && win.closed) { this._redock(id); return false; } // owned ref dead → redock + fall through
|
||||
// No local ref (dashboard reloaded): assume alive and raise via the channel.
|
||||
// A liveness ping (or the popup's own unload) heals the badge if it's gone.
|
||||
this._postWindowMessage({ type: 'focus-request', id });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Re-dock a session: close its window (which re-docks via its unload
|
||||
* announcement) and clear dashboard state now. */
|
||||
redockSession(id) {
|
||||
const win = this.detachedWindows.get(id);
|
||||
if (win && !win.closed) { try { win.close(); } catch {} }
|
||||
this._postWindowMessage({ type: 'close-request', id });
|
||||
this._redock(id);
|
||||
}
|
||||
|
||||
/** Clear all dashboard-side detached state/timers for a session. */
|
||||
_redock(id) {
|
||||
const t = this._detachWatchTimers.get(id);
|
||||
if (t) { clearInterval(t); this._detachWatchTimers.delete(id); }
|
||||
this._cancelPendingRedock(id);
|
||||
this._detachOrphanStrikes.delete(id);
|
||||
this.detachedWindows.delete(id);
|
||||
this._markDetached(id, false);
|
||||
}
|
||||
|
||||
/** Defer a channel-driven redock briefly. A popup *reload* emits 'redocked'
|
||||
* then re-announces 'detached'; the grace window lets that re-announce cancel
|
||||
* the redock, so a reload doesn't blip the dashboard badge. A real close
|
||||
* leaves the redock unanswered and it fires. */
|
||||
_scheduleRedock(id) {
|
||||
if (this._redockGrace.has(id)) return;
|
||||
const timer = setTimeout(() => { this._redockGrace.delete(id); this._redock(id); }, 1500);
|
||||
this._redockGrace.set(id, timer);
|
||||
}
|
||||
|
||||
_cancelPendingRedock(id) {
|
||||
const t = this._redockGrace.get(id);
|
||||
if (t) { clearTimeout(t); this._redockGrace.delete(id); }
|
||||
}
|
||||
|
||||
/** Toggle the "detached" marker on a tab (immediate DOM update + state set).
|
||||
* Full re-renders re-apply the class from this.detachedSessions. */
|
||||
_markDetached(id, on) {
|
||||
if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id);
|
||||
const container = this.$('sessionTabs');
|
||||
const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||
if (tab) tab.classList.toggle('detached', on);
|
||||
}
|
||||
|
||||
/** Poll a window we opened; when it closes, re-dock its tab. This is the
|
||||
* primary (reliable) close-detection path for windows this tab opened. */
|
||||
_watchDetachedWindow(id, win) {
|
||||
const prev = this._detachWatchTimers.get(id);
|
||||
if (prev) clearInterval(prev);
|
||||
const timer = setInterval(() => {
|
||||
if (!win || win.closed) {
|
||||
clearInterval(timer);
|
||||
this._detachWatchTimers.delete(id);
|
||||
this._redock(id);
|
||||
}
|
||||
}, 800);
|
||||
this._detachWatchTimers.set(id, timer);
|
||||
}
|
||||
|
||||
/** Open the cross-window BroadcastChannel and wire role-specific handlers. */
|
||||
_initWindowChannel() {
|
||||
if (typeof BroadcastChannel === 'undefined') return;
|
||||
try { this.windowChannel = new BroadcastChannel('codeman-windows'); }
|
||||
catch { this.windowChannel = null; return; }
|
||||
this.windowChannel.onmessage = (e) => this._onWindowMessage(e.data);
|
||||
if (this.isSoloWindow) {
|
||||
// Announce presence so the dashboard marks this session's tab detached —
|
||||
// even if this window was opened directly by URL rather than window.open.
|
||||
this._postWindowMessage({ type: 'detached', id: this.soloSessionId });
|
||||
// On close, tell the dashboard to re-dock. pagehide is the reliable signal
|
||||
// on modern browsers; beforeunload is a belt-and-suspenders fallback.
|
||||
const announceClose = () => this._postWindowMessage({ type: 'redocked', id: this.soloSessionId });
|
||||
window.addEventListener('pagehide', announceClose);
|
||||
window.addEventListener('beforeunload', announceClose);
|
||||
} else {
|
||||
// Dashboard: ask any already-open solo windows to re-announce themselves
|
||||
// (covers a dashboard reload while popups remain open), then keep
|
||||
// reconciling so a popup that died WITHOUT a 'redocked' (hard kill / crash)
|
||||
// eventually un-marks its tab.
|
||||
this._postWindowMessage({ type: 'roll-call' });
|
||||
this._startDetachLiveness();
|
||||
}
|
||||
}
|
||||
|
||||
_postWindowMessage(msg) {
|
||||
try { if (this.windowChannel) this.windowChannel.postMessage(msg); } catch {}
|
||||
}
|
||||
|
||||
_onWindowMessage(msg) {
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
if (this.isSoloWindow) {
|
||||
// Roll-call has no id (broadcast to all) — answer before the id filter.
|
||||
if (msg.type === 'roll-call') { this._postWindowMessage({ type: 'detached', id: this.soloSessionId }); return; }
|
||||
if (msg.id !== this.soloSessionId) return;
|
||||
if (msg.type === 'close-request') { try { window.close(); } catch {} }
|
||||
else if (msg.type === 'focus-request') { try { window.focus(); } catch {} }
|
||||
return;
|
||||
}
|
||||
// Dashboard side.
|
||||
if (msg.type === 'detached' && msg.id) {
|
||||
this._cancelPendingRedock(msg.id); // a re-announce (e.g. popup reload) cancels a deferred redock
|
||||
this._detachPingPending?.delete(msg.id); // and proves liveness for this tick
|
||||
this._detachOrphanStrikes.delete(msg.id); // any answer clears accumulated misses
|
||||
this._markDetached(msg.id, true);
|
||||
} else if (msg.type === 'redocked' && msg.id) {
|
||||
this._scheduleRedock(msg.id); // defer: a popup reload fires redocked→detached; grace avoids a badge blip
|
||||
} else if (msg.type === 'detach-request' && msg.id) {
|
||||
// Future gesture hook: another window asks the dashboard to detach a tab.
|
||||
this.detachSession(msg.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Dashboard: periodically reconcile detached tabs we hold no window ref for
|
||||
* (e.g. after a dashboard reload). Owned windows are covered by the
|
||||
* win.closed poll; channel-only ones can only be checked by asking them to
|
||||
* re-announce and re-docking any that stay silent. */
|
||||
_startDetachLiveness() {
|
||||
if (this._detachLivenessTimer) return;
|
||||
this._detachLivenessTimer = setInterval(() => this._pingDetached(), 5000);
|
||||
}
|
||||
|
||||
_pingDetached() {
|
||||
const orphans = [];
|
||||
for (const id of this.detachedSessions) {
|
||||
const win = this.detachedWindows.get(id);
|
||||
if (!win) orphans.push(id); // channel-only — must verify via re-announce
|
||||
else if (win.closed) this._redock(id); // owned & closed — heal now
|
||||
}
|
||||
if (!orphans.length) return;
|
||||
this._detachPingPending = new Set(orphans);
|
||||
this._postWindowMessage({ type: 'roll-call' });
|
||||
// Live popups answer 'detached' (clearing themselves above); survivors stay in
|
||||
// the pending set. Redock only after TWO consecutive unanswered roll-calls — a
|
||||
// backgrounded popup is timer-throttled and may miss a single 1.2s window, and
|
||||
// we don't want to wrongly un-mark a still-open tab. A later answer resets the
|
||||
// strike count (see _onWindowMessage).
|
||||
setTimeout(() => {
|
||||
if (!this._detachPingPending) return;
|
||||
for (const id of this._detachPingPending) {
|
||||
const strikes = (this._detachOrphanStrikes.get(id) || 0) + 1;
|
||||
if (strikes >= 2) { this._detachOrphanStrikes.delete(id); this._redock(id); }
|
||||
else this._detachOrphanStrikes.set(id, strikes);
|
||||
}
|
||||
this._detachPingPending = null;
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
/** Solo window: select the target session and apply minimal single-session
|
||||
* chrome. Called from handleInit once the session list has loaded. */
|
||||
_applySoloMode() {
|
||||
document.body.classList.add('solo-mode');
|
||||
const session = this.sessions.get(this.soloSessionId);
|
||||
if (!session) { this._showSoloSessionGone(); return; }
|
||||
// Force re-select (handleInit cleared terminal state above).
|
||||
this.activeSessionId = null;
|
||||
this.selectSession(this.soloSessionId);
|
||||
const name = this.getSessionName(session) || 'Session';
|
||||
const titleEl = document.getElementById('soloSessionTitle');
|
||||
if (titleEl) { titleEl.textContent = name; titleEl.style.display = ''; }
|
||||
const redock = document.getElementById('soloRedockBtn');
|
||||
if (redock) redock.style.display = '';
|
||||
document.title = name + ' — Codeman';
|
||||
if (this.notificationManager) this.notificationManager.originalTitle = document.title;
|
||||
// Neutralize the dashboard-only brand click in a solo window.
|
||||
const logo = document.querySelector('.header-brand .logo');
|
||||
if (logo) logo.onclick = (e) => { e.preventDefault(); };
|
||||
}
|
||||
|
||||
/** Solo window: the target session is gone (never existed, or ended while
|
||||
* this window was open). Show a friendly terminal state. */
|
||||
_showSoloSessionGone() {
|
||||
document.body.classList.add('solo-mode');
|
||||
if (document.querySelector('.solo-gone-overlay')) return;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'solo-gone-overlay';
|
||||
el.innerHTML = '<h2>Session unavailable</h2>'
|
||||
+ '<p>This session has ended or is no longer available.</p>'
|
||||
+ '<button class="btn-primary" onclick="window.close()">Close window</button>';
|
||||
document.body.appendChild(el);
|
||||
document.title = 'Session ended — Codeman';
|
||||
}
|
||||
|
||||
connectSSE() {
|
||||
// Check if browser is offline
|
||||
if (!navigator.onLine) {
|
||||
@@ -720,7 +1076,13 @@ class CodemanApp {
|
||||
this.setConnectionStatus('reconnecting');
|
||||
}
|
||||
|
||||
this.eventSource = new EventSource('/api/events');
|
||||
// Build URL with stable client ID and (if known) the active-session
|
||||
// filter so the server only streams session:terminal events for the
|
||||
// session we're rendering. Lifecycle/metadata events are sent globally
|
||||
// regardless of filter (server side).
|
||||
const _sseParams = new URLSearchParams({ clientId: this._clientId });
|
||||
if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId);
|
||||
this.eventSource = new EventSource(`/api/events?${_sseParams.toString()}`);
|
||||
|
||||
// Store all event listeners for cleanup on reconnect
|
||||
const listeners = [];
|
||||
@@ -838,6 +1200,12 @@ class CodemanApp {
|
||||
|
||||
_onSessionDeleted(data) {
|
||||
if (this._wsSessionId === data.id) this._disconnectWs();
|
||||
// Solo window whose session just ended → show the "unavailable" state.
|
||||
if (this.isSoloWindow && data.id === this.soloSessionId) {
|
||||
this._showSoloSessionGone();
|
||||
}
|
||||
// Dashboard: a detached session ended → clear its detached state/timers.
|
||||
if (this.detachedSessions.has(data.id)) this._redock(data.id);
|
||||
this._cleanupSessionData(data.id);
|
||||
if (this.activeSessionId === data.id) {
|
||||
this.activeSessionId = null;
|
||||
@@ -903,11 +1271,9 @@ class CodemanApp {
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = html;
|
||||
const frag = tpl.content;
|
||||
// Remove dangerous elements
|
||||
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
|
||||
el.remove();
|
||||
}
|
||||
// Strip dangerous attributes from all elements
|
||||
for (const el of frag.querySelectorAll('*')) {
|
||||
for (const attr of [...el.attributes]) {
|
||||
const name = attr.name.toLowerCase();
|
||||
@@ -921,24 +1287,234 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Serialize back via a container
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(frag);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI escape sequences and Claude CLI chrome (status bar, hints,
|
||||
* spinner, progress bar) from a terminal buffer so the response viewer can
|
||||
* show just the conversational text when the JSONL transcript is missing.
|
||||
*/
|
||||
_cleanTerminalBuffer(buf) {
|
||||
const stripped = buf
|
||||
// CSI sequences — params (0x30-0x3F includes digits, ?, ;, <, =, >),
|
||||
// intermediates (0x20-0x2F), final byte (0x40-0x7E). Catches \x1b[>c,
|
||||
// \x1b[>q, \x1b[?25l etc. that the previous regex missed.
|
||||
.replace(/\x1b\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]/g, '')
|
||||
// OSC sequences (window titles etc.) terminated by BEL or ST
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||||
// DCS / APC / PM / SOS sequences
|
||||
.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '')
|
||||
// SS2/SS3 + charset selects + single-char escapes
|
||||
.replace(/\x1b[NO()][A-Z0-9]?/g, '')
|
||||
.replace(/\x1b[>=<78cDEHM]/g, '')
|
||||
// Stray control chars (except \t \n)
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||
.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
// Drop Claude CLI chrome lines that aren't part of the response.
|
||||
const CHROME_PATTERNS = [
|
||||
/^\s*❯\s*/, // shell prompt
|
||||
/^\s*[⏵⏺⏸⏹]+\s*/, // status glyphs
|
||||
/^\s*✻\s*(Crunching|Crunched|Thinking)/i, // spinner lines
|
||||
/bypass permissions/i,
|
||||
/\bshift\+tab to cycle\b/i,
|
||||
/^\s*focus\s*$/,
|
||||
/^\s*new task\?/i,
|
||||
/\/clear to save/i,
|
||||
/^\s*─{5,}\s*$/, // horizontal dividers
|
||||
/\[(Opus|Sonnet|Haiku|GPT|Claude)[\s\S]*(tokens?|\$|¥|%|↑|↓)/i, // status bar
|
||||
/^\s*\[\d+[km]?\/\d+[km]?\]/i, // token counter
|
||||
/[█░▓▒]{3,}/, // progress bar
|
||||
/^\s*\(.*\s*(tokens?|context).*\)\s*$/i,
|
||||
];
|
||||
|
||||
const lines = stripped.split('\n');
|
||||
const kept = lines.filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return true; // keep blanks so paragraphs survive
|
||||
return !CHROME_PATTERNS.some((re) => re.test(line));
|
||||
});
|
||||
|
||||
return kept
|
||||
.join('\n')
|
||||
.replace(/[ \t]+$/gm, '')
|
||||
.replace(/\n{4,}/g, '\n\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap ASCII/box diagrams in fenced code blocks so marked.js preserves whitespace.
|
||||
* Claude often emits box-drawing diagrams without triple-backticks; without this
|
||||
* step, HTML collapses the whitespace and the diagram becomes unreadable prose.
|
||||
*/
|
||||
_preprocessAsciiArt(text) {
|
||||
// Only trigger on characters that rarely appear in prose:
|
||||
// U+2500-U+257F Box Drawing (─│┌┐└┘├┤┬┴┼╔╗╚╝═║)
|
||||
// U+2580-U+259F Block Elements (▀▄█▌▐░▒▓, progress bars)
|
||||
// Deliberately excluded:
|
||||
// U+2190-U+21FF Arrows (→←↑↓⇒ — common rhetorical prose)
|
||||
// U+25A0-U+25FF Geometric Shapes (●○■□◆◇ — common bullets)
|
||||
// Triggering on those would wrap numbered lists / prose that merely uses
|
||||
// arrows in code blocks and break their markdown rendering.
|
||||
const BOX_PATTERN = /[─-╿▀-▟]/;
|
||||
|
||||
// Preserve existing fenced code blocks as-is (hide them behind placeholders)
|
||||
const fenceRe = /```[\s\S]*?```/g;
|
||||
const placeholders = [];
|
||||
const masked = text.replace(fenceRe, (m) => {
|
||||
placeholders.push(m);
|
||||
return `__CODEMAN_FENCE_${placeholders.length - 1}__`;
|
||||
});
|
||||
|
||||
// Split on blank-line paragraph boundaries; wrap any paragraph containing
|
||||
// box-drawing/arrow chars in its own fenced block.
|
||||
const processed = masked
|
||||
.split(/(\n{2,})/)
|
||||
.map((chunk) => {
|
||||
if (/^\n{2,}$/.test(chunk)) return chunk; // keep separators
|
||||
if (!chunk.trim()) return chunk;
|
||||
if (chunk.includes('__CODEMAN_FENCE_')) return chunk;
|
||||
if (BOX_PATTERN.test(chunk)) return '\n```\n' + chunk + '\n```\n';
|
||||
return chunk;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return processed.replace(/__CODEMAN_FENCE_(\d+)__/g, (_m, i) => placeholders[Number(i)]);
|
||||
}
|
||||
|
||||
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
|
||||
_renderMarkdown(text) {
|
||||
const src = text || '';
|
||||
if (typeof marked !== 'undefined' && marked.parse) {
|
||||
try {
|
||||
return this._sanitizeHtml(marked.parse(text, { breaks: true, gfm: true }));
|
||||
const prepared = this._preprocessAsciiArt(src);
|
||||
let html = this._sanitizeHtml(marked.parse(prepared, { breaks: true, gfm: true }));
|
||||
// Wrap tables in a horizontal-scroll container so they overflow gracefully
|
||||
// on mobile without collapsing into block-level cells.
|
||||
html = html.replace(/<table>/g, '<div class="rv-table-wrap"><table>')
|
||||
.replace(/<\/table>/g, '</table></div>');
|
||||
// Tag code blocks containing box-drawing glyphs as diagrams (same
|
||||
// narrow trigger as _preprocessAsciiArt — arrows/geometric shapes
|
||||
// don't count because they appear frequently in prose).
|
||||
// Default is wrap (readable on mobile); a toggle button lets the user
|
||||
// switch to horizontal-scroll mode when the original structure matters.
|
||||
// The button must live OUTSIDE the <pre> scroll container so it stays
|
||||
// pinned to the visual right edge when the user scrolls horizontally.
|
||||
const DIAGRAM_CHAR = /[─-╿▀-▟]/;
|
||||
const tmpl = document.createElement('template');
|
||||
tmpl.innerHTML = html;
|
||||
// Every fenced code block gets a positioned wrapper with an action
|
||||
// toolbar pinned to its top-right corner. The toolbar lives OUTSIDE the
|
||||
// <pre> scroll container so its buttons stay put during horizontal
|
||||
// scroll. All blocks get a one-click copy button; ASCII diagrams keep
|
||||
// the additional line-wrap toggle.
|
||||
tmpl.content.querySelectorAll('pre > code').forEach((code) => {
|
||||
const pre = code.parentElement;
|
||||
const isDiagram = DIAGRAM_CHAR.test(code.textContent || '');
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = isDiagram ? 'rv-code-wrap rv-diagram-wrap' : 'rv-code-wrap';
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'rv-code-actions';
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'rv-copy-btn';
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.setAttribute('aria-label', 'Copy code');
|
||||
copyBtn.setAttribute('title', 'Copy code');
|
||||
actions.appendChild(copyBtn);
|
||||
|
||||
if (isDiagram) {
|
||||
pre.classList.add('rv-diagram');
|
||||
const toggle = document.createElement('button');
|
||||
toggle.className = 'rv-wrap-toggle';
|
||||
toggle.type = 'button';
|
||||
toggle.setAttribute('aria-label', 'Toggle line wrapping');
|
||||
toggle.setAttribute('title', 'Toggle line wrapping');
|
||||
actions.appendChild(toggle);
|
||||
}
|
||||
|
||||
pre.parentNode.insertBefore(wrap, pre);
|
||||
wrap.appendChild(actions);
|
||||
wrap.appendChild(pre);
|
||||
});
|
||||
return tmpl.innerHTML;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
// Fallback: escape HTML and preserve whitespace
|
||||
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const escaped = src.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind click handlers inside the response viewer body. Uses event delegation
|
||||
* so a single listener serves every diagram-toggle button, including those
|
||||
* added when the conversation is reloaded. Idempotent via a dataset flag.
|
||||
*/
|
||||
_bindResponseViewerInteractions(body) {
|
||||
if (!body || body.dataset.rvBound === '1') return;
|
||||
body.dataset.rvBound = '1';
|
||||
body.addEventListener('click', async (ev) => {
|
||||
// One-click copy: lift the raw source from the sibling <pre><code>.
|
||||
const copyBtn = ev.target.closest('.rv-copy-btn');
|
||||
if (copyBtn) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const code = copyBtn.closest('.rv-code-wrap')?.querySelector('pre code');
|
||||
const ok = code ? await this._copyText(code.textContent || '') : false;
|
||||
copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
|
||||
copyBtn.classList.add(ok ? 'rv-copied' : 'rv-copy-failed');
|
||||
clearTimeout(copyBtn._resetTimer);
|
||||
copyBtn._resetTimer = setTimeout(() => {
|
||||
copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = ev.target.closest('.rv-wrap-toggle');
|
||||
if (!btn) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const wrap = btn.closest('.rv-diagram-wrap');
|
||||
const pre = wrap?.querySelector('pre.rv-diagram');
|
||||
if (!pre || !wrap) return;
|
||||
const nowrap = pre.classList.toggle('rv-nowrap');
|
||||
wrap.classList.toggle('rv-wrap-nowrap', nowrap);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to the clipboard. Prefers the async Clipboard API (secure
|
||||
* contexts); falls back to a hidden-textarea + execCommand path so copy
|
||||
* still works over plain HTTP. Returns true on success.
|
||||
*/
|
||||
async _copyText(text) {
|
||||
if (!text) return false;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch { /* secure-context write failed — try the legacy path */ }
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async toggleResponseViewer() {
|
||||
const viewer = document.getElementById('responseViewer');
|
||||
const backdrop = document.getElementById('responseViewerBackdrop');
|
||||
@@ -958,27 +1534,18 @@ class CodemanApp {
|
||||
const data = await res.json();
|
||||
let lastResponse = data.text || '';
|
||||
|
||||
// Source 2: Terminal buffer fallback (strip ANSI codes)
|
||||
// Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome
|
||||
if (!lastResponse) {
|
||||
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
|
||||
const termData = await termRes.json();
|
||||
if (termData.terminalBuffer) {
|
||||
lastResponse = termData.terminalBuffer
|
||||
.replace(/\x1b\[\?[0-9;]*[a-zA-Z]/g, '')
|
||||
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||||
.replace(/\x1b[()][A-Z0-9]/g, '')
|
||||
.replace(/\x1b[>=<]/g, '')
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||
.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
.replace(/[ \t]+$/gm, '')
|
||||
.replace(/\n{4,}/g, '\n\n\n')
|
||||
.trim();
|
||||
lastResponse = this._cleanTerminalBuffer(termData.terminalBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
const body = document.getElementById('responseViewerBody');
|
||||
body.innerHTML = this._renderMarkdown(lastResponse);
|
||||
this._bindResponseViewerInteractions(body);
|
||||
|
||||
// Reset state for fresh open
|
||||
const title = document.getElementById('responseViewerTitle');
|
||||
@@ -1015,11 +1582,12 @@ class CodemanApp {
|
||||
body.innerHTML = '';
|
||||
for (const msg of messages) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'rv-message';
|
||||
const isUser = msg.role === 'user';
|
||||
div.className = 'rv-message ' + (isUser ? 'rv-msg-user' : 'rv-msg-assistant');
|
||||
|
||||
const role = document.createElement('div');
|
||||
role.className = 'rv-role ' + (msg.role === 'user' ? 'rv-role-user' : 'rv-role-assistant');
|
||||
role.textContent = msg.role === 'user' ? 'You' : 'Claude';
|
||||
role.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant');
|
||||
role.textContent = isUser ? 'You' : 'Claude';
|
||||
div.appendChild(role);
|
||||
|
||||
const text = document.createElement('div');
|
||||
@@ -1029,6 +1597,7 @@ class CodemanApp {
|
||||
|
||||
body.appendChild(div);
|
||||
}
|
||||
this._bindResponseViewerInteractions(body);
|
||||
|
||||
if (title) title.textContent = `Conversation (${messages.length} messages)`;
|
||||
if (moreBtn) moreBtn.style.display = 'none';
|
||||
@@ -1565,8 +2134,10 @@ class CodemanApp {
|
||||
}
|
||||
});
|
||||
|
||||
// Restore tabs that were open before refresh but are no longer on the server
|
||||
this._restoreEndedTabs();
|
||||
// Server is source of truth for open sessions — don't resurrect stale tabs
|
||||
// from localStorage (would show phantom "ended" tabs when a session was closed
|
||||
// on another device).
|
||||
try { localStorage.removeItem('codeman-tab-meta'); } catch {}
|
||||
|
||||
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
|
||||
this.syncSessionOrder();
|
||||
@@ -1655,6 +2226,14 @@ class CodemanApp {
|
||||
// Reset activeSessionId so selectSession doesn't early-return.
|
||||
// Guard: skip if a newer handleInit has already started (race between loadState + SSE init).
|
||||
if (gen !== this._initGeneration) return;
|
||||
|
||||
// Solo (detached) window: always show exactly the target session, ignoring
|
||||
// the dashboard's "restore last active" logic.
|
||||
if (this.isSoloWindow) {
|
||||
this._applySoloMode();
|
||||
return;
|
||||
}
|
||||
|
||||
const previousActiveId = this.activeSessionId;
|
||||
this.activeSessionId = null;
|
||||
if (this.sessionOrder.length > 0) {
|
||||
@@ -1701,6 +2280,8 @@ class CodemanApp {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
renderSessionTabs() {
|
||||
// Don't re-render while user is typing in the inline rename input
|
||||
if (this._activeRename) return;
|
||||
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
|
||||
}
|
||||
|
||||
@@ -1757,6 +2338,17 @@ class CodemanApp {
|
||||
else if (wantIdle && !hasIdle) { tab.classList.add('tab-alert-idle'); tab.classList.remove('tab-alert-action'); }
|
||||
else if (!alertType && (hasAction || hasIdle)) { tab.classList.remove('tab-alert-action', 'tab-alert-idle'); }
|
||||
|
||||
// Inject tab-number badge if missing (added after initial render)
|
||||
if (!tab.querySelector('.tab-number')) {
|
||||
const idx = this.sessionOrder.indexOf(id);
|
||||
if (idx >= 0 && idx < 9) {
|
||||
const numSpan = document.createElement('span');
|
||||
numSpan.className = 'tab-number';
|
||||
numSpan.textContent = String(idx + 1);
|
||||
tab.insertBefore(numSpan, tab.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
// Update status indicator
|
||||
const statusEl = tab.querySelector('.tab-status');
|
||||
if (statusEl && !statusEl.classList.contains(status)) {
|
||||
@@ -1834,6 +2426,7 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
_fullRenderSessionTabs() {
|
||||
if (this._activeRename) return;
|
||||
const container = this.$('sessionTabs');
|
||||
|
||||
// Clean up any orphaned dropdowns before re-rendering
|
||||
@@ -1849,6 +2442,7 @@ class CodemanApp {
|
||||
// Reorder to put active tab first
|
||||
tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)];
|
||||
}
|
||||
let _tabIdx = 0;
|
||||
for (const id of tabOrder) {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) continue; // Skip if session was removed
|
||||
@@ -1873,28 +2467,28 @@ class CodemanApp {
|
||||
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
||||
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
||||
|
||||
const endedAttr = session._ended ? ' data-ended="1"' : '';
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}"${endedAttr} onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${this.detachedSessions.has(id) ? ' detached' : ''}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
|
||||
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||
</span>
|
||||
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
|
||||
</span>
|
||||
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
|
||||
${subagentBadge}
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
||||
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession('${escapeHtml(id)}')" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
||||
</div>`);
|
||||
_tabIdx++;
|
||||
}
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
|
||||
// Persist tab metadata for refresh recovery
|
||||
this._saveTabMetadata();
|
||||
|
||||
// Set up drag-and-drop handlers for tab reordering
|
||||
this.setupTabDragHandlers();
|
||||
|
||||
@@ -1994,33 +2588,6 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Save tab metadata to localStorage so ended sessions can be restored after refresh
|
||||
_saveTabMetadata() {
|
||||
try {
|
||||
const meta = {};
|
||||
for (const [id, s] of this.sessions) {
|
||||
if (s._ended) continue; // Don't persist ended stubs back
|
||||
meta[id] = { id, name: s.name || '', workingDir: s.workingDir || '', mode: s.mode || 'claude', color: s.color || 'default' };
|
||||
}
|
||||
localStorage.setItem('codeman-tab-meta', JSON.stringify(meta));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Restore tabs that were open before refresh but are no longer on the server
|
||||
_restoreEndedTabs() {
|
||||
try {
|
||||
const saved = localStorage.getItem('codeman-tab-meta');
|
||||
if (!saved) return;
|
||||
const meta = JSON.parse(saved);
|
||||
for (const [id, info] of Object.entries(meta)) {
|
||||
if (!this.sessions.has(id)) {
|
||||
// Add a stub session so the tab renders
|
||||
this.sessions.set(id, { id, name: info.name, workingDir: info.workingDir, mode: info.mode, color: info.color, status: 'ended', _ended: true });
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Set up drag-and-drop handlers on tab elements
|
||||
setupTabDragHandlers() {
|
||||
const container = this.$('sessionTabs');
|
||||
@@ -2106,6 +2673,24 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
moveActiveTabLeft() {
|
||||
if (!this.activeSessionId) return;
|
||||
const idx = this.sessionOrder.indexOf(this.activeSessionId);
|
||||
if (idx <= 0) return;
|
||||
[this.sessionOrder[idx - 1], this.sessionOrder[idx]] = [this.sessionOrder[idx], this.sessionOrder[idx - 1]];
|
||||
this.saveSessionOrder();
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
|
||||
moveActiveTabRight() {
|
||||
if (!this.activeSessionId) return;
|
||||
const idx = this.sessionOrder.indexOf(this.activeSessionId);
|
||||
if (idx === -1 || idx >= this.sessionOrder.length - 1) return;
|
||||
[this.sessionOrder[idx], this.sessionOrder[idx + 1]] = [this.sessionOrder[idx + 1], this.sessionOrder[idx]];
|
||||
this.saveSessionOrder();
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Lifecycle — select, close, navigate
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -2227,6 +2812,13 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
async selectSession(sessionId) {
|
||||
// If this session is popped out into its own window, raise that window
|
||||
// instead of showing it inline (focus-on-click for detached tabs).
|
||||
if (!this.isSoloWindow && this.detachedSessions.has(sessionId)) {
|
||||
// Raise the popup instead of showing inline. If we owned a now-closed
|
||||
// window, _raiseDetached re-docks and returns false so we fall through.
|
||||
if (this._raiseDetached(sessionId)) return;
|
||||
}
|
||||
if (this.activeSessionId === sessionId) return;
|
||||
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
|
||||
// programmatic focus() within the user-gesture call stack (e.g. tab click).
|
||||
@@ -2246,6 +2838,12 @@ class CodemanApp {
|
||||
this._cleanupPreviousSession(sessionId);
|
||||
this.activeSessionId = sessionId;
|
||||
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
|
||||
// Narrow SSE filter to the active session — server stops streaming
|
||||
// session:terminal events for other sessions to this client. Cuts
|
||||
// SSE traffic ~Nx for N concurrent sessions. Fire-and-forget; on the
|
||||
// rare race where server doesn't know our clientId yet, the next
|
||||
// selectSession or reconnect catches up.
|
||||
this._updateSseSubscription(sessionId);
|
||||
this.hideWelcome();
|
||||
// Clear idle hooks on view, but keep action hooks until user interacts
|
||||
this.clearPendingHooks(sessionId, 'idle_prompt');
|
||||
@@ -2276,16 +2874,9 @@ class CodemanApp {
|
||||
// Check if this is a restored session that needs to be attached
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
// Ended tabs (restored from localStorage, no longer on server) — show message, skip buffer load
|
||||
if (session?._ended) {
|
||||
this.terminal.clear();
|
||||
this.terminal.write('\r\n \x1b[2mSession ended. Close tab or click to reopen.\x1b[0m\r\n');
|
||||
return;
|
||||
}
|
||||
|
||||
// Track working directory for path normalization in Project Insights
|
||||
this.currentSessionWorkingDir = session?.workingDir || null;
|
||||
if (session && session.pid === null && !session._ended) {
|
||||
if (session && session.pid === null) {
|
||||
// Session has no PTY attached — either restored after server restart
|
||||
// or detached for some other reason. Re-attach regardless of status.
|
||||
try {
|
||||
@@ -2409,19 +3000,15 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
// Fire-and-forget resize + Ctrl+L to force Ink redraw.
|
||||
// Tailed buffers accumulate stale CUP-positioned Ink frames that overlap
|
||||
// in the viewport (e.g. duplicate "bypass permissions" bars). Ctrl+L
|
||||
// triggers a full Ink redraw which overwrites all stale frame content.
|
||||
// sendResize may be a no-op if dimensions match, so Ctrl+L is essential.
|
||||
this.sendResize(sessionId).then(() => {
|
||||
if (selectGen !== this._selectGeneration) return;
|
||||
fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: '\x0c' })
|
||||
}).catch(() => {});
|
||||
});
|
||||
// Fire-and-forget resize to nudge Ink via SIGWINCH on real size changes.
|
||||
// Previously we also sent Ctrl+L (\x0c) here to force a full Ink redraw,
|
||||
// but Claude Code 2.x treats Ctrl+L as a two-step "clear conversation"
|
||||
// command — if a page refresh or SSE reconnect ran selectSession twice
|
||||
// within Claude's confirmation window, the second \x0c silently wiped the
|
||||
// conversation. Stale Ink frames in the tailed buffer are a cosmetic
|
||||
// annoyance that disappear on the user's next keypress; data loss is not
|
||||
// acceptable. Do NOT re-introduce Ctrl+L here.
|
||||
this.sendResize(sessionId);
|
||||
|
||||
// Defer secondary panel updates so they don't block the main thread
|
||||
// after terminal content is already visible.
|
||||
@@ -2516,6 +3103,11 @@ class CodemanApp {
|
||||
|
||||
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler
|
||||
_cleanupSessionData(sessionId) {
|
||||
// If the deleted session is currently being renamed, abort the rename
|
||||
// so the inline <input> doesn't ghost as a stale tab on screen.
|
||||
if (this._activeRename?.sessionId === sessionId) {
|
||||
this._activeRename.cancel();
|
||||
}
|
||||
this.sessions.delete(sessionId);
|
||||
// Remove from tab order
|
||||
const orderIndex = this.sessionOrder.indexOf(sessionId);
|
||||
|
||||
@@ -43,7 +43,7 @@ function urlBase64ToUint8Array(base64String) {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Default terminal scrollback (can be changed via settings)
|
||||
const DEFAULT_SCROLLBACK = 20000;
|
||||
const DEFAULT_SCROLLBACK = 50000;
|
||||
|
||||
// Timing constants
|
||||
const STUCK_THRESHOLD_DEFAULT_MS = 600000; // 10 minutes - default for stuck detection
|
||||
@@ -54,7 +54,7 @@ const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notificati
|
||||
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
|
||||
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
||||
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
|
||||
const TERMINAL_TAIL_SIZE = 128 * 1024; // 128KB tail for initial load
|
||||
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
|
||||
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
||||
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
||||
|
||||
@@ -71,6 +71,52 @@ const WINDOW_MIN_WIDTH_PX = 200;
|
||||
const WINDOW_MIN_HEIGHT_PX = 200;
|
||||
const WINDOW_DEFAULT_WIDTH_PX = 300;
|
||||
|
||||
// WebGL renderer auto-fallback thresholds.
|
||||
// _installWebGLLongTaskGuard() observes longtask entries and disables WebGL
|
||||
// after LONGTASK_COUNT stalls of >= LONGTASK_MS within WINDOW_MS. GRACE_MS
|
||||
// suppresses the noisy initial-load stalls. STICKY_EXPIRY_MS is how long
|
||||
// localStorage's webgl-disabled marker survives before we retry WebGL on a
|
||||
// fresh load (driver/Chrome may have been updated).
|
||||
const WEBGL_FALLBACK = {
|
||||
LONGTASK_MS: 200,
|
||||
LONGTASK_COUNT: 3,
|
||||
WINDOW_MS: 30000,
|
||||
GRACE_MS: 5000,
|
||||
STICKY_EXPIRY_MS: 7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure rolling-window trip evaluator for the WebGL longtask guard.
|
||||
* Mutates `recent` in place (prunes entries older than `now - WINDOW_MS`)
|
||||
* and appends each new duration's startTime that meets the threshold.
|
||||
* Returns true when the count inside the window reaches `LONGTASK_COUNT`.
|
||||
*
|
||||
* Exposed on `window` for unit testing — the production guard in app.js
|
||||
* inlines this same logic in its PerformanceObserver callback. Splitting it
|
||||
* out keeps the threshold math testable without a real PerformanceObserver.
|
||||
*
|
||||
* @param {number[]} recent - mutable array of startTimes inside the window
|
||||
* @param {{startTime: number, duration: number}[]} entries - new longtask entries
|
||||
* @param {number} now - performance.now() at evaluation time
|
||||
* @param {typeof WEBGL_FALLBACK} [config=WEBGL_FALLBACK] - thresholds
|
||||
* @returns {boolean} true if the rolling window has reached the trip count
|
||||
*/
|
||||
function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK) {
|
||||
for (const entry of entries) {
|
||||
if (entry.duration >= config.LONGTASK_MS) recent.push(entry.startTime);
|
||||
}
|
||||
while (recent.length && now - recent[0] > config.WINDOW_MS) recent.shift();
|
||||
return recent.length >= config.LONGTASK_COUNT;
|
||||
}
|
||||
|
||||
// Expose for tests. `const` declarations at the top of a non-module script
|
||||
// are global lexical bindings but not `window` properties, so explicit
|
||||
// assignment is the test-visible API surface.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||
}
|
||||
|
||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||
// scheduler.postTask('background') defers non-critical work (connection lines, panel renders)
|
||||
// so the main thread stays free for terminal rendering at 60fps.
|
||||
@@ -292,6 +338,9 @@ const SSE_EVENTS = {
|
||||
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
|
||||
ORCHESTRATOR_ERROR: 'orchestrator:error',
|
||||
|
||||
// Clipboard
|
||||
CLIPBOARD_WRITE: 'clipboard:write',
|
||||
|
||||
// Cases
|
||||
CASE_CREATED: 'case:created',
|
||||
CASE_LINKED: 'case:linked',
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Image Input Mixin - Clipboard paste and drag-and-drop image support
|
||||
*
|
||||
* For paste: intercepts Ctrl+V at the xterm keyboard level, creates a temporary
|
||||
* hidden contenteditable div ("paste trap"), lets the browser's native paste fill
|
||||
* it, then checks for image data. This works on HTTP (no secure context needed).
|
||||
*
|
||||
* For drag-and-drop: listens on the terminal container for file drops.
|
||||
*
|
||||
* @dependency app.js (uses global `app` for sendInput, activeSessionId, showToast)
|
||||
* @dependency panels-ui.js (provides showToast)
|
||||
*/
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
|
||||
initImageInput() {
|
||||
// Drag-and-drop handlers on terminal container
|
||||
const container = document.getElementById('terminalContainer');
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
|
||||
container.classList.add('drag-active');
|
||||
}
|
||||
});
|
||||
|
||||
container.addEventListener('dragleave', (e) => {
|
||||
if (!container.contains(e.relatedTarget)) {
|
||||
container.classList.remove('drag-active');
|
||||
}
|
||||
});
|
||||
|
||||
container.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
container.classList.remove('drag-active');
|
||||
|
||||
if (!this.activeSessionId) return;
|
||||
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
||||
|
||||
const imageFiles = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
||||
if (imageFiles.length === 0) {
|
||||
this.showToast('Only image files are supported', 'error');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages(imageFiles);
|
||||
});
|
||||
},
|
||||
|
||||
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
||||
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
||||
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
||||
_handleImagePaste() {
|
||||
const self = this;
|
||||
|
||||
// Create a hidden contenteditable div to receive the paste
|
||||
const trap = document.createElement('div');
|
||||
trap.contentEditable = 'true';
|
||||
trap.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;overflow:hidden';
|
||||
document.body.appendChild(trap);
|
||||
trap.focus();
|
||||
|
||||
// Listen for the paste event on our trap
|
||||
trap.addEventListener('paste', function(e) {
|
||||
e.stopPropagation();
|
||||
|
||||
// Check for images in clipboard items
|
||||
var imageFiles = [];
|
||||
var items = e.clipboardData && e.clipboardData.items;
|
||||
if (items) {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
var blob = items[i].getAsFile();
|
||||
if (blob) imageFiles.push(blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the trap
|
||||
setTimeout(function() {
|
||||
if (trap.parentNode) trap.parentNode.removeChild(trap);
|
||||
// Refocus the terminal
|
||||
if (self.terminal) self.terminal.focus();
|
||||
}, 0);
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
self._uploadAndInsertImages(imageFiles);
|
||||
} else {
|
||||
// No image -- route text through xterm's paste() so bracketed-paste
|
||||
// markers (CSI 200~ ... CSI 201~) survive when the inner application
|
||||
// has enabled bracketed-paste mode (Claude Code does). Sending text
|
||||
// via raw sendInput() strips those markers and makes pasted input
|
||||
// indistinguishable from typed input, weakening the CLI's
|
||||
// prompt-injection defenses.
|
||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||
e.preventDefault();
|
||||
if (text && self.terminal) self.terminal.paste(text);
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger the browser's native paste via execCommand
|
||||
// (this fires the paste event on our focused trap element)
|
||||
document.execCommand('paste');
|
||||
},
|
||||
|
||||
async _uploadAndInsertImages(files) {
|
||||
const sessionId = this.activeSessionId;
|
||||
if (!sessionId) return;
|
||||
|
||||
this.showToast('Uploading ' + files.length + ' image' + (files.length > 1 ? 's' : '') + '...', 'info');
|
||||
|
||||
const paths = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Re-encode to a standard JPEG/PNG before upload. Galleries on some
|
||||
// phones (notably Android/MIUI) hand back a WebP/HEIF whose filename and
|
||||
// MIME claim "image/jpeg", which passes the server's extension allowlist
|
||||
// but fails its magic-byte check ("bytes do not match declared type").
|
||||
// Decoding through the browser and re-encoding guarantees the bytes
|
||||
// match the extension we send.
|
||||
const normalized = await this._normalizeImageForUpload(file);
|
||||
const path = await this._uploadPasteImage(sessionId, normalized);
|
||||
paths.push(path);
|
||||
} catch (err) {
|
||||
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
if (paths.length > 0) {
|
||||
const pathStr = paths.join(' ');
|
||||
await this.sendInput(pathStr);
|
||||
this.showToast(paths.length + ' image' + (paths.length > 1 ? 's' : '') + ' ready', 'success');
|
||||
}
|
||||
},
|
||||
|
||||
async _uploadPasteImage(sessionId, file) {
|
||||
const form = new FormData();
|
||||
form.append('image', file);
|
||||
|
||||
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'HTTP ' + resp.status);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
return data.path;
|
||||
},
|
||||
|
||||
// Decode an image File through the browser and re-encode it to a format the
|
||||
// server accepts, so the uploaded bytes always match their declared
|
||||
// extension. PNG is re-encoded as PNG (preserves transparency); everything
|
||||
// else (JPEG, WebP, HEIF, unknown) becomes JPEG. Animated GIFs are passed
|
||||
// through untouched since a canvas would flatten them to one frame. On any
|
||||
// decode/encode failure the original file is returned unchanged so the server
|
||||
// still gets a chance (and logs a precise diagnostic).
|
||||
async _normalizeImageForUpload(file) {
|
||||
if (file.type === 'image/gif') return file;
|
||||
|
||||
const toPng = file.type === 'image/png';
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const img = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error('decode failed'));
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
const width = img.naturalWidth;
|
||||
const height = img.naturalHeight;
|
||||
if (!width || !height) return file;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return file;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const mime = toPng ? 'image/png' : 'image/jpeg';
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92));
|
||||
if (!blob) return file;
|
||||
|
||||
const baseName = (file.name || 'image').replace(/\.[^.]+$/, '') || 'image';
|
||||
return new File([blob], baseName + (toPng ? '.png' : '.jpg'), { type: mime });
|
||||
} catch (err) {
|
||||
console.warn('Image re-encode failed, uploading original:', err);
|
||||
return file;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
@@ -2,6 +2,10 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<!-- Resolve all relative assets against the site root so the same shell can be
|
||||
served at /session/:id (detached single-session window) without 404ing
|
||||
on relative <script>/<link> URLs. Must precede the first resource tag. -->
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="description" content="Claude Code session manager with web interface">
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
@@ -55,7 +59,9 @@
|
||||
<div class="skeleton-toolbar"></div>
|
||||
</div>
|
||||
<!-- Skip link for keyboard users -->
|
||||
<a href="#terminalContainer" class="skip-link">Skip to terminal</a>
|
||||
<!-- onclick scrolls/focuses directly: with <base href="/"> a bare href="#..." would
|
||||
navigate to /#... (the dashboard) from a /session/:id solo window. -->
|
||||
<a href="#terminalContainer" class="skip-link" onclick="event.preventDefault(); var t=document.getElementById('terminalContainer'); if(t){t.scrollIntoView(); var f=t.querySelector('textarea,[tabindex]'); (f||t).focus&&(f||t).focus();}">Skip to terminal</a>
|
||||
<div class="app">
|
||||
<!-- Compact Header with Session Tabs -->
|
||||
<header class="header">
|
||||
@@ -67,7 +73,11 @@
|
||||
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs">
|
||||
</div>
|
||||
|
||||
<!-- Detached single-session window title (shown only in solo mode) -->
|
||||
<div class="solo-session-title" id="soloSessionTitle" style="display: none;" aria-live="polite"></div>
|
||||
|
||||
<div class="header-right">
|
||||
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">⊞</button>
|
||||
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
||||
<span class="tunnel-dot"></span>
|
||||
</button>
|
||||
@@ -97,7 +107,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-header btn-response-viewer-header" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications">
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
@@ -922,6 +933,16 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item settings-item-multiline" id="appSettingsGestureControlItem" title="Enable the camera hand-tracking gesture overlay (applied on reload). The instance must run with CODEMAN_GESTURE=1.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Gesture Control (beta)</span>
|
||||
<span class="settings-item-desc">Camera hand-tracking overlay (applied on reload)</span>
|
||||
</div>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsGestureControl">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Header Displays Section -->
|
||||
<div class="settings-section-header">Header Displays</div>
|
||||
@@ -960,6 +981,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the multi-monitor button in the header (opens Codeman spanned across all displays)">
|
||||
<span class="settings-item-label">Multi-monitor Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowMultiMonitorButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar Section -->
|
||||
<div class="settings-section-header">Tab Bar</div>
|
||||
@@ -1095,6 +1123,19 @@
|
||||
</label>
|
||||
<span class="form-hint">Use 1M token context window (model: opus[1m]) for all new sessions</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Thinking Effort</label>
|
||||
<select id="appSettingsThinkingEffort" class="form-select">
|
||||
<option value="">Default</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="xhigh">XHigh</option>
|
||||
<option value="max">Max</option>
|
||||
<option value="ultracode">Ultracode (multi-agent workflows)</option>
|
||||
</select>
|
||||
<span class="form-hint">Default effort for new Claude sessions — soft default, switchable anytime in-session via /effort (e.g. /effort ultracode)</span>
|
||||
</div>
|
||||
<!-- Nice Priority Section -->
|
||||
<div class="form-section-header">Nice Priority</div>
|
||||
<div class="form-row form-row-switch">
|
||||
@@ -1792,5 +1833,6 @@
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
<script defer src="subagent-windows.js"></script>
|
||||
<script defer src="image-input.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* Defines two exports:
|
||||
*
|
||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss.
|
||||
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, paste, and dismiss.
|
||||
* The paste button opens a dialog that handles both text paste and image attach
|
||||
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
|
||||
* Destructive actions (/clear) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
*
|
||||
@@ -49,7 +51,6 @@ const KeyboardAccessoryBar = {
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="init" title="/init">/init</button>
|
||||
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
|
||||
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
|
||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
@@ -92,12 +93,12 @@ const KeyboardAccessoryBar = {
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
|
||||
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
|
||||
<button class="accessory-btn" data-action="ctrl-o" title="Ctrl+O">⌃O</button>
|
||||
<button class="accessory-btn" data-action="opt-enter" title="Option+Enter (newline)">⌥Enter</button>
|
||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||
<button class="accessory-btn" data-action="init" title="/init">/init</button>
|
||||
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
|
||||
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
|
||||
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
@@ -125,9 +126,9 @@ 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']);
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
|
||||
if (refocusActions.has(action) ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
(action === 'clear' && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
app.terminal.focus();
|
||||
}
|
||||
@@ -184,16 +185,17 @@ const KeyboardAccessoryBar = {
|
||||
case 'ctrl-o':
|
||||
this.sendKey('\x0f');
|
||||
break;
|
||||
case 'effort-max':
|
||||
this.sendCommand('/effort max');
|
||||
break;
|
||||
case 'init':
|
||||
this.sendCommand('/init');
|
||||
break;
|
||||
case 'clear':
|
||||
case 'compact': {
|
||||
case 'clear': {
|
||||
// Require double-tap: first tap turns amber, second tap within 2s sends
|
||||
const cmd = action === 'clear' ? '/clear' : '/compact';
|
||||
if (this._confirmAction === action && this._confirmTimer) {
|
||||
this.clearConfirm();
|
||||
this.sendCommand(cmd);
|
||||
this.sendCommand('/clear');
|
||||
} else {
|
||||
this.setConfirm(action, btn);
|
||||
}
|
||||
@@ -260,8 +262,17 @@ const KeyboardAccessoryBar = {
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
/** Read clipboard and send contents as input */
|
||||
/** Show a paste overlay with a textarea for iOS compatibility */
|
||||
/** Show a paste overlay for iOS compatibility.
|
||||
* Handles three input paths from one dialog:
|
||||
* - Text: long-press the textarea → Paste → Send (unchanged).
|
||||
* - Image (picker): the "Image" button opens a native file picker
|
||||
* (accept=image/* → camera / photo library / files), the most reliable
|
||||
* way to attach a photo on mobile.
|
||||
* - Image (paste): if the browser exposes image blobs on the textarea's
|
||||
* paste event, we intercept them and upload directly. Support is spotty
|
||||
* on mobile, so it is a best-effort enhancement layered on the picker.
|
||||
* All image paths reuse app._uploadAndInsertImages() (image-input.js), which
|
||||
* uploads to /api/sessions/:id/paste-image and inserts the saved path. */
|
||||
pasteFromClipboard() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||
|
||||
@@ -270,23 +281,61 @@ const KeyboardAccessoryBar = {
|
||||
overlay.className = 'paste-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="paste-dialog">
|
||||
<textarea class="paste-textarea" placeholder="Long-press here and tap Paste"></textarea>
|
||||
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach an image"></textarea>
|
||||
<div class="paste-actions">
|
||||
<button class="paste-image">🖼 Image</button>
|
||||
<button class="paste-cancel">Cancel</button>
|
||||
<button class="paste-send">Send</button>
|
||||
</div>
|
||||
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textarea = overlay.querySelector('.paste-textarea');
|
||||
const send = () => {
|
||||
const fileInput = overlay.querySelector('.paste-file-input');
|
||||
|
||||
const close = () => overlay.remove();
|
||||
|
||||
const sendText = () => {
|
||||
const text = textarea.value;
|
||||
overlay.remove();
|
||||
close();
|
||||
if (text) app.sendInput(text);
|
||||
};
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('.paste-send').addEventListener('click', send);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
|
||||
// Filter to images, close the dialog, and hand off to the shared
|
||||
// upload+insert pipeline. Returns true if any image was handled.
|
||||
const handleImages = (files) => {
|
||||
const images = Array.from(files || []).filter((f) => f.type.startsWith('image/'));
|
||||
if (images.length === 0) return false;
|
||||
close();
|
||||
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Image picker (camera / photo library) — the reliable mobile path.
|
||||
overlay.querySelector('.paste-image').addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => handleImages(fileInput.files));
|
||||
|
||||
// Best-effort: capture images pasted straight into the textarea.
|
||||
textarea.addEventListener('paste', (e) => {
|
||||
const items = e.clipboardData && e.clipboardData.items;
|
||||
if (!items) return;
|
||||
const imageFiles = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob) imageFiles.push(blob);
|
||||
}
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
handleImages(imageFiles);
|
||||
}
|
||||
});
|
||||
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', close);
|
||||
overlay.querySelector('.paste-send').addEventListener('click', sendText);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
textarea.focus();
|
||||
|
||||
@@ -893,6 +893,7 @@ html.mobile-init .file-browser-panel {
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
z-index: 51;
|
||||
transition: transform 0.15s ease-out;
|
||||
@@ -1042,7 +1043,7 @@ html.mobile-init .file-browser-panel {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.paste-cancel, .paste-new, .paste-send {
|
||||
.paste-cancel, .paste-new, .paste-send, .paste-image {
|
||||
padding: 8px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
@@ -1050,6 +1051,14 @@ html.mobile-init .file-browser-panel {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Image attach button — left-aligned, accent outline */
|
||||
.paste-image {
|
||||
margin-right: auto;
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--accent-color, #7aa2f7);
|
||||
border: 1px solid var(--accent-color, #7aa2f7);
|
||||
}
|
||||
|
||||
.paste-cancel {
|
||||
background: var(--bg-tertiary, #333);
|
||||
color: var(--text-secondary, #aaa);
|
||||
@@ -1195,11 +1204,6 @@ html.mobile-init .file-browser-panel {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Response viewer — show eye icon in header on mobile */
|
||||
.btn-response-viewer-header {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
|
||||
.response-viewer {
|
||||
padding-bottom: var(--safe-area-bottom, 0px);
|
||||
}
|
||||
@@ -2097,6 +2101,7 @@ html.mobile-init .file-browser-panel {
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* The NotificationManager class implements five notification layers:
|
||||
* 1. In-app notification drawer (slide-out panel with grouped notifications)
|
||||
* 2. Tab title flash (alternating "(*) Codeman" when tab is hidden)
|
||||
* 2. Tab title flash (alternating "⚠️ (N) codeman:<host>" / "codeman:<host>" when tab is hidden; uses this.originalTitle so it tracks any per-host title)
|
||||
* 3. Browser Notification API (desktop push with auto-close after 8s)
|
||||
* 4. Web Push via service worker (OS-level notifications when tab is closed)
|
||||
* 5. Audio alerts (Web Audio API beep, user-opt-in)
|
||||
@@ -291,11 +291,11 @@ class NotificationManager {
|
||||
this.titleFlashInterval = setInterval(() => {
|
||||
this.titleFlashState = !this.titleFlashState;
|
||||
document.title = this.titleFlashState
|
||||
? `\u26A0\uFE0F (${this.unreadCount}) Codeman`
|
||||
? `\u26A0\uFE0F (${this.unreadCount}) ${this.originalTitle}`
|
||||
: this.originalTitle;
|
||||
}, TITLE_FLASH_INTERVAL_MS);
|
||||
// Set immediately
|
||||
document.title = `\u26A0\uFE0F (${this.unreadCount}) Codeman`;
|
||||
document.title = `\u26A0\uFE0F (${this.unreadCount}) ${this.originalTitle}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,7 +330,7 @@ class NotificationManager {
|
||||
if (now - this.lastBrowserNotifTime < BROWSER_NOTIF_RATE_LIMIT_MS) return;
|
||||
this.lastBrowserNotifTime = now;
|
||||
|
||||
const notif = new Notification(`Codeman: ${title}`, {
|
||||
const notif = new Notification(`${this.originalTitle}: ${title}`, {
|
||||
body,
|
||||
tag, // Groups same-tag notifications
|
||||
icon: '/favicon.ico',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user