Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
002cf81b1e | ||
|
|
9b4aab2502 | ||
|
|
829c797726 | ||
|
|
85da3bb898 | ||
|
|
29b2653801 | ||
|
|
7b8b175133 | ||
|
|
c3027b21e1 | ||
|
|
0b231edd43 | ||
|
|
ea1c2ee4ec | ||
|
|
b4a808adcf | ||
|
|
f3cbe9bca6 | ||
|
|
a11bcb0029 | ||
|
|
47fd9a922f | ||
|
|
14f7d8298d | ||
|
|
d32f4debb2 | ||
|
|
3cb7b510f8 | ||
|
|
6a12a72c9c | ||
|
|
f1a126efeb | ||
|
|
12fd780af8 | ||
|
|
fd74a42933 | ||
|
|
7101e64800 | ||
|
|
1b10d9b733 | ||
|
|
5078f5251d | ||
|
|
196af8fba7 | ||
|
|
a9b22b86a4 | ||
|
|
28cace5858 | ||
|
|
0a594b61bd | ||
|
|
89d787a949 | ||
|
|
bd9797b68c | ||
|
|
0e6cd94312 | ||
|
|
24a6f1cac8 | ||
|
|
8e679a280b | ||
|
|
c642689bbd | ||
|
|
28a6247c27 | ||
|
|
0ceb455c4b | ||
|
|
e51117dfa9 | ||
|
|
13d41cf7c7 | ||
|
|
2c7557d002 | ||
|
|
53b473708f | ||
|
|
2cba393ae5 | ||
|
|
2011bd8d89 | ||
|
|
b76724690d | ||
|
|
f277f9664c |
@@ -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
|
||||
|
||||
@@ -48,7 +48,12 @@ Thumbs.db
|
||||
# Generated output
|
||||
out/
|
||||
screenshots-echo-diag/
|
||||
tools/remotion/out/
|
||||
scripts/remotion/out/
|
||||
|
||||
# Artifacts that should not be tracked
|
||||
test-results/
|
||||
tmp/
|
||||
public
|
||||
|
||||
# Claude Code plan tracking
|
||||
plan.json
|
||||
|
||||
@@ -6,4 +6,4 @@ src/web/public/app.js
|
||||
src/web/public/styles.css
|
||||
src/web/public/mobile.css
|
||||
src/web/public/index.html
|
||||
tools/
|
||||
scripts/remotion/
|
||||
|
||||
@@ -1,5 +1,250 @@
|
||||
# aicodeman
|
||||
|
||||
## 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
|
||||
|
||||
- Fix "Case path not found" error in Quick Start when `~/codeman-cases/` does not exist (issue #64). Two bugs in `session-ui.js`:
|
||||
- `runClaude()` auto-create read `createCaseData.case`, but `POST /api/cases` returns `{ success, data: { case } }` — corrected to `createCaseData.data.case`.
|
||||
- `runShell()` had no auto-create logic and would immediately throw on a missing case directory — now mirrors `runClaude()`'s create-on-demand flow.
|
||||
|
||||
## 0.5.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix quick-start to resolve linked cases before codeman-cases fallback. `/api/quick-start` was always resolving `caseName` against `CASES_DIR`, ignoring entries in `~/.codeman/linked-cases.json`. Sessions started via quick-start now correctly honour linked external project directories, consistent with regular case routes.
|
||||
|
||||
## 0.5.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Community contributions and security hardening:
|
||||
- Mobile response viewer: native-scroll panel for reading full Claude responses with markdown rendering via marked.js (PR #62)
|
||||
- PWA support: service worker caching, web app manifest, and Android home screen install (PR #59)
|
||||
- Named Cloudflare tunnel support (PR #58)
|
||||
- Markdown rendering for response viewer with HTML sanitization (XSS prevention) — strips dangerous elements, event handlers, and javascript: URIs
|
||||
- Service worker switched from stale-while-revalidate to network-first caching so deploys take effect immediately
|
||||
- Content-Disposition filename sanitization to prevent header injection in file downloads
|
||||
- Expose session.muxName public getter, replace unsafe `as any` cast in session-routes
|
||||
- Static import for execFile in session-routes
|
||||
- Keyboard shortcut updates: Alt+1-9 tab switching, Shift+Enter newline
|
||||
- Repo restructure for cleaner GitHub landing page
|
||||
- Mobile logo, expandable history, session resume fixes
|
||||
|
||||
## 0.5.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix: allow bracket characters in model validation regex so models like opus[1m] (1M context window) are accepted instead of silently dropped. Quote the model flag value in tmux spawn commands to prevent bash glob expansion of bracket patterns.
|
||||
|
||||
docs: update macOS launchd instructions to use `launchctl bootstrap` instead of deprecated `load`. Clean up README install and service sections.
|
||||
|
||||
## 0.5.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Mobile keyboard accessory bar: add configurable "Extended Keyboard Bar" setting (Settings > Display > Input) that toggles between simple mode (up/down arrows, /init, /clear, /compact, paste, dismiss) and extended mode (adds left/right arrows, Tab, Shift+Tab, Ctrl+O, Alt+Enter, Esc). Default is simple mode. Setting is device-specific (not synced to server).
|
||||
|
||||
Restyle dismiss button: muted steel-blue tone, fills remaining bar space via flex, larger tap target. Arrow buttons now blue.
|
||||
|
||||
Fix paste overlay visibility on mobile: dialog repositioned to top of screen (15vh from top) so the virtual keyboard doesn't cover it. Textarea enlarged for better usability.
|
||||
|
||||
(Also includes all v0.5.8 changes: case reorder/delete, XSS sanitization, auto-attach PTY on restart, mobile keyboard buttons, macOS installer fixes, terminal flicker fix, state store collision fix.)
|
||||
|
||||
## 0.5.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Case management: add Manage tab with reorder (up/down arrows) and delete for cases; linked cases are unlinked (folder preserved), CASES_DIR cases are permanently deleted. New endpoints: DELETE /api/cases/:name, PUT /api/cases/order. SSE events: case:deleted, case:order-changed.
|
||||
|
||||
Security: sanitize case names from filesystem with /^[a-zA-Z0-9_-]+$/ regex before returning from GET /api/cases to prevent XSS via maliciously-named directories reaching frontend inline onclick handlers.
|
||||
|
||||
Auto-attach PTY: server now calls startInteractive() for recovered tmux sessions during startup so all sessions resume capturing output immediately after deploy, instead of waiting for client selection. Frontend auto-attach condition relaxed from (pid===null && status==='idle') to (pid===null && !\_ended).
|
||||
|
||||
Mobile keyboard accessory: add Shift+Tab, Tab, Esc, Alt+Enter, Left/Right arrow, and Ctrl+O buttons.
|
||||
|
||||
Terminal: fix flicker regression by moving viewport clear inside dimension guard.
|
||||
|
||||
State store: fix temp file collisions on concurrent writes.
|
||||
|
||||
macOS: fix installer failures when piped via curl | bash, add HTML cache support, launchd service template, and trust dialog handling.
|
||||
|
||||
Housekeeping: remove accidentally committed dist/state-store.js build artifact.
|
||||
|
||||
## 0.5.7
|
||||
|
||||
### 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
|
||||
@@ -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.7 (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.7.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -73,21 +77,27 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| 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) |
|
||||
| Continuous typecheck | `tsc --noEmit --watch` |
|
||||
| Test coverage | `npm run test:coverage` |
|
||||
| Dead-code sweep | `npm run knip` (config in `knip.json`) |
|
||||
| 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` on push to master/main and on PRs (Node 22). Tests excluded (they spawn tmux).
|
||||
|
||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`). ESLint flat config (`eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `tools/**`, `remotion/**`.
|
||||
**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/**`.
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
- **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
|
||||
- **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`.
|
||||
|
||||
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
||||
|
||||
@@ -99,7 +109,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,11 +119,11 @@ 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.6K 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.1K 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`.
|
||||
|
||||
@@ -136,7 +146,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**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 `agent-teams/`.
|
||||
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
|
||||
|
||||
**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`.
|
||||
|
||||
@@ -150,7 +160,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**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+Enter (quick start), Ctrl+W (kill), Ctrl+Tab (next), Ctrl+K (kill all), 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
|
||||
|
||||
@@ -167,11 +177,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 (36), 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 +203,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 `mobile-test/` (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 +228,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: `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.
|
||||
|
||||
@@ -30,24 +30,6 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | b
|
||||
|
||||
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it.
|
||||
|
||||
**Install from a fork or specific branch:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/<user>/Codeman/<branch>/install.sh | \
|
||||
CODEMAN_REPO_URL=https://github.com/<user>/Codeman.git \
|
||||
CODEMAN_BRANCH=<branch> bash
|
||||
```
|
||||
|
||||
The installer supports these environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CODEMAN_REPO_URL` | upstream Codeman | Custom git repository URL |
|
||||
| `CODEMAN_BRANCH` | `master` | Git branch to install |
|
||||
| `CODEMAN_INSTALL_DIR` | `~/.codeman/app` | Custom install directory |
|
||||
| `CODEMAN_SKIP_SYSTEMD` | `0` | Skip systemd service setup prompt |
|
||||
| `CODEMAN_NODE_VERSION` | `22` | Node.js major version to install |
|
||||
| `CODEMAN_NONINTERACTIVE` | `0` | Skip all prompts (for CI/automation) |
|
||||
|
||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai) (or both). After install:
|
||||
|
||||
```bash
|
||||
@@ -60,12 +42,53 @@ codeman web
|
||||
|
||||
**Linux (systemd):**
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user && printf '[Unit]\nDescription=Codeman Web Server\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=%s %s/dist/index.js web\nRestart=always\nRestartSec=10\n\n[Install]\nWantedBy=default.target\n' "$(which node)" "$HOME/.codeman/app" > ~/.config/systemd/user/codeman-web.service && systemctl --user daemon-reload && systemctl --user enable --now codeman-web && loginctl enable-linger $USER
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cat > ~/.config/systemd/user/codeman-web.service << EOF
|
||||
[Unit]
|
||||
Description=Codeman Web Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=$(which node) $HOME/.codeman/app/dist/index.js web
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now codeman-web
|
||||
loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
**macOS (launchd):**
|
||||
```bash
|
||||
mkdir -p ~/Library/LaunchAgents && printf '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>com.codeman.web</string><key>ProgramArguments</key><array><string>%s</string><string>%s/dist/index.js</string><string>web</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>StandardOutPath</key><string>/tmp/codeman.log</string><key>StandardErrorPath</key><string>/tmp/codeman.log</string></dict></plist>\n' "$(which node)" "$HOME/.codeman/app" > ~/Library/LaunchAgents/com.codeman.web.plist && launchctl load ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
mkdir -p ~/Library/LaunchAgents
|
||||
cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.codeman.web</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$(which node)</string>
|
||||
<string>$HOME/.codeman/app/dist/index.js</string>
|
||||
<string>web</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/codeman.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/codeman.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
|
||||
```
|
||||
</details>
|
||||
|
||||
@@ -203,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 |
|
||||
@@ -378,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 |
|
||||
|
||||
@@ -423,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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -22,8 +22,7 @@ export default tseslint.config(
|
||||
'src/web/public/vendor/**',
|
||||
'src/web/public/app.js',
|
||||
'scripts/**/*.mjs',
|
||||
'tools/**',
|
||||
'remotion/**',
|
||||
'scripts/remotion/**',
|
||||
],
|
||||
}
|
||||
);
|
||||
@@ -1,7 +1,11 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
root,
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
@@ -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
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# Environment variables:
|
||||
# CODEMAN_NONINTERACTIVE=1 - Skip all prompts (for CI/automation)
|
||||
# CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app)
|
||||
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd service setup prompt
|
||||
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd/launchd service setup prompt
|
||||
# CODEMAN_NODE_VERSION - Node.js major version to install (default: 22)
|
||||
# CODEMAN_REPO_URL - Custom git repository URL (default: upstream Codeman)
|
||||
# CODEMAN_BRANCH - Git branch to install (default: master)
|
||||
@@ -353,8 +353,15 @@ ensure_sudo() {
|
||||
die "sudo is required but not installed. Please install packages manually or run as root."
|
||||
fi
|
||||
# Validate sudo access
|
||||
if ! sudo -v 2>/dev/null; then
|
||||
die "Failed to obtain sudo privileges."
|
||||
# When piped (curl | bash), stdin is the pipe — redirect from /dev/tty so sudo can prompt
|
||||
if [[ -e /dev/tty ]]; then
|
||||
if ! sudo -v 2>/dev/null < /dev/tty; then
|
||||
die "Failed to obtain sudo privileges."
|
||||
fi
|
||||
else
|
||||
if ! sudo -v 2>/dev/null; then
|
||||
die "Failed to obtain sudo privileges. Try running the script directly instead of piping."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -372,7 +379,12 @@ ensure_homebrew() {
|
||||
fi
|
||||
|
||||
info "Installing Homebrew first..."
|
||||
/bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
# When piped (curl | bash), stdin is the pipe — Homebrew needs TTY for sudo password prompt
|
||||
if [[ -e /dev/tty ]]; then
|
||||
/bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" < /dev/tty
|
||||
else
|
||||
NONINTERACTIVE=1 /bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
fi
|
||||
|
||||
# Add Homebrew to PATH for Apple Silicon
|
||||
if [[ -f /opt/homebrew/bin/brew ]]; then
|
||||
@@ -787,9 +799,84 @@ setup_sc_alias() {
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Systemd Service Setup (Linux only)
|
||||
# Service Setup (Linux systemd / macOS launchd)
|
||||
# ============================================================================
|
||||
|
||||
setup_launchd_service() {
|
||||
local plist_label="com.codeman.web"
|
||||
local agent_dir="$HOME/Library/LaunchAgents"
|
||||
local agent_plist="$agent_dir/$plist_label.plist"
|
||||
local daemon_plist="/Library/LaunchDaemons/$plist_label.plist"
|
||||
|
||||
info "Setting up macOS LaunchAgent..."
|
||||
|
||||
# Remove any existing LaunchDaemon (system-level) to prevent duplicates.
|
||||
# We standardize on LaunchAgent (user-level) — it doesn't require sudo,
|
||||
# inherits the user's environment, and is the correct choice for user apps.
|
||||
if [[ -f "$daemon_plist" ]]; then
|
||||
warn "Found system-level LaunchDaemon at $daemon_plist — removing to prevent duplicate"
|
||||
sudo launchctl unload "$daemon_plist" 2>/dev/null || true
|
||||
sudo rm -f "$daemon_plist"
|
||||
success "Removed duplicate LaunchDaemon"
|
||||
fi
|
||||
|
||||
# Unload existing agent before overwriting
|
||||
if [[ -f "$agent_plist" ]]; then
|
||||
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
mkdir -p "$agent_dir"
|
||||
|
||||
# Build PATH: ensure /opt/homebrew/bin (Apple Silicon) and ~/.local/bin are included
|
||||
local svc_path="/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
|
||||
# Find node binary path
|
||||
local node_path
|
||||
node_path=$(command -v node)
|
||||
|
||||
cat > "$agent_plist" << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$plist_label</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$node_path</string>
|
||||
<string>$INSTALL_DIR/dist/index.js</string>
|
||||
<string>web</string>
|
||||
</array>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>$svc_path</string>
|
||||
<key>HOME</key>
|
||||
<string>$HOME</string>
|
||||
<key>LANG</key>
|
||||
<string>en_US.UTF-8</string>
|
||||
</dict>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>$HOME</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/codeman.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/codeman.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
launchctl load "$agent_plist" 2>/dev/null || true
|
||||
|
||||
success "LaunchAgent installed and started"
|
||||
}
|
||||
|
||||
setup_systemd_service() {
|
||||
local service_dir="$HOME/.config/systemd/user"
|
||||
local service_file="$service_dir/codeman-web.service"
|
||||
@@ -1139,17 +1226,25 @@ main() {
|
||||
echo ""
|
||||
|
||||
local launch_choice=""
|
||||
local has_systemd=false
|
||||
local has_service=false
|
||||
local service_type=""
|
||||
|
||||
if [[ "$os" == "linux" ]] && [[ "$SKIP_SYSTEMD" != "1" ]] && command -v systemctl &>/dev/null; then
|
||||
has_systemd=true
|
||||
has_service=true
|
||||
service_type="systemd"
|
||||
elif [[ "$os" == "macos" ]] && [[ "$SKIP_SYSTEMD" != "1" ]]; then
|
||||
has_service=true
|
||||
service_type="launchd"
|
||||
fi
|
||||
|
||||
if [[ "$has_systemd" == "true" ]]; then
|
||||
if [[ "$has_service" == "true" ]]; then
|
||||
local service_label="systemd service"
|
||||
[[ "$service_type" == "launchd" ]] && service_label="LaunchAgent"
|
||||
|
||||
echo -e " ${BOLD}How would you like to run Codeman?${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}1)${NC} Run now in this terminal"
|
||||
echo -e " ${CYAN}2)${NC} Install as systemd service (auto-start on boot)"
|
||||
echo -e " ${CYAN}2)${NC} Install as $service_label (auto-start on boot)"
|
||||
echo -e " ${CYAN}3)${NC} Don't start — I'll run it later"
|
||||
echo ""
|
||||
|
||||
@@ -1166,7 +1261,7 @@ main() {
|
||||
done
|
||||
fi
|
||||
else
|
||||
# macOS or no systemd — only offer run now or skip
|
||||
# No service manager available — only offer run now or skip
|
||||
echo -e " ${BOLD}Would you like to start Codeman now?${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}1)${NC} Run now in this terminal"
|
||||
@@ -1192,12 +1287,16 @@ main() {
|
||||
|
||||
echo ""
|
||||
|
||||
# Handle systemd setup
|
||||
# Handle service setup
|
||||
if [[ "$launch_choice" == "2" ]]; then
|
||||
setup_systemd_service
|
||||
if [[ "$service_type" == "launchd" ]]; then
|
||||
setup_launchd_service
|
||||
else
|
||||
setup_systemd_service
|
||||
fi
|
||||
|
||||
# Offer tunnel service if cloudflared is available
|
||||
if check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
|
||||
# Offer tunnel service if cloudflared is available (Linux only — systemd tunnel service)
|
||||
if [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
|
||||
echo ""
|
||||
if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then
|
||||
setup_tunnel_service
|
||||
@@ -1212,10 +1311,16 @@ main() {
|
||||
echo ""
|
||||
echo -e " ${BOLD}Manage the service:${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYAN}systemctl --user stop codeman-web${NC} # Stop"
|
||||
echo -e " ${CYAN}systemctl --user restart codeman-web${NC} # Restart"
|
||||
echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status"
|
||||
echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs"
|
||||
if [[ "$service_type" == "launchd" ]]; then
|
||||
echo -e " ${CYAN}launchctl unload ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Stop"
|
||||
echo -e " ${CYAN}launchctl load ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Start"
|
||||
echo -e " ${CYAN}tail -f /tmp/codeman.log${NC} # View logs"
|
||||
else
|
||||
echo -e " ${CYAN}systemctl --user stop codeman-web${NC} # Stop"
|
||||
echo -e " ${CYAN}systemctl --user restart codeman-web${NC} # Restart"
|
||||
echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status"
|
||||
echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
@@ -1289,11 +1394,17 @@ update() {
|
||||
success "Updated to $(node -e "console.log(require('./package.json').version)")"
|
||||
echo ""
|
||||
|
||||
# Auto-restart systemd service if it's running, otherwise tell the user
|
||||
if systemctl --user is-active codeman-web.service &>/dev/null; then
|
||||
# Auto-restart service if running, otherwise tell the user
|
||||
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||
if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then
|
||||
info "Restarting codeman-web service..."
|
||||
systemctl --user restart codeman-web.service
|
||||
success "codeman-web service restarted"
|
||||
elif [[ -f "$agent_plist" ]]; then
|
||||
info "Restarting LaunchAgent..."
|
||||
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||
launchctl load "$agent_plist" 2>/dev/null || true
|
||||
success "LaunchAgent restarted"
|
||||
else
|
||||
echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
|
||||
echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}"
|
||||
@@ -1306,9 +1417,9 @@ uninstall() {
|
||||
info "Uninstalling Codeman..."
|
||||
echo ""
|
||||
|
||||
# Stop and remove systemd services
|
||||
# Stop and remove systemd services (Linux)
|
||||
for svc in codeman-web codeman-tunnel; do
|
||||
if systemctl --user is-active "${svc}.service" &>/dev/null; then
|
||||
if systemctl --user is-active "${svc}.service" &>/dev/null 2>&1; then
|
||||
info "Stopping ${svc} service..."
|
||||
systemctl --user stop "${svc}.service"
|
||||
fi
|
||||
@@ -1324,6 +1435,20 @@ uninstall() {
|
||||
done
|
||||
systemctl --user daemon-reload 2>/dev/null || true
|
||||
|
||||
# Stop and remove launchd services (macOS)
|
||||
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
|
||||
local daemon_plist="/Library/LaunchDaemons/com.codeman.web.plist"
|
||||
if [[ -f "$agent_plist" ]]; then
|
||||
launchctl unload "$agent_plist" 2>/dev/null || true
|
||||
rm -f "$agent_plist"
|
||||
success "Removed LaunchAgent"
|
||||
fi
|
||||
if [[ -f "$daemon_plist" ]]; then
|
||||
sudo launchctl unload "$daemon_plist" 2>/dev/null || true
|
||||
sudo rm -f "$daemon_plist"
|
||||
success "Removed LaunchDaemon"
|
||||
fi
|
||||
|
||||
# Remove symlinks
|
||||
local symlink_dir="$HOME/.local/bin"
|
||||
if [[ -L "$symlink_dir/codeman" ]]; then
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.5.7",
|
||||
"version": "0.7.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",
|
||||
@@ -15,17 +15,19 @@
|
||||
"dev": "tsx src/index.ts web",
|
||||
"web": "node dist/index.js web",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test": "vitest run --config config/vitest.config.ts",
|
||||
"test:watch": "vitest --config config/vitest.config.ts",
|
||||
"test:coverage": "vitest run --config config/vitest.config.ts --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||
"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'",
|
||||
"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,6 +52,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/compress": "^8.3.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/static": "^8.0.0",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -93,6 +93,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 = {};
|
||||
|
||||
@@ -428,7 +428,7 @@ const SUBAGENT_ACTIVITY = {
|
||||
'agent-002': [
|
||||
{ type: 'tool', tool: 'Glob', input: { pattern: 'test/**/*.test.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/vitest.config.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/config/vitest.config.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
{ type: 'message', role: 'assistant', text: 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...', timestamp: new Date().toISOString(), agentId: 'agent-002' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* Usage: node scripts/capture-video-screenshots.mjs
|
||||
* Port: 3198 (static file server)
|
||||
* Output: remotion/public/ (6 PNGs)
|
||||
* Output: scripts/scripts/remotion/public/ (6 PNGs)
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'url';
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PROJECT_ROOT = join(__dirname, '..');
|
||||
const PUBLIC_DIR = join(PROJECT_ROOT, 'src', 'web', 'public');
|
||||
const OUTPUT_DIR = join(PROJECT_ROOT, 'remotion', 'public');
|
||||
const OUTPUT_DIR = join(PROJECT_ROOT, 'scripts', 'remotion', 'public');
|
||||
const PORT = 3198;
|
||||
|
||||
const DESKTOP_VIEWPORT = { width: 1920, height: 1080 };
|
||||
@@ -516,7 +516,7 @@ async function captureDesktopWelcome(browser) {
|
||||
path: join(OUTPUT_DIR, 'desktop-welcome.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/desktop-welcome.png');
|
||||
console.log(' Saved: scripts/remotion/public/desktop-welcome.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -545,7 +545,7 @@ async function captureDesktopClaude(browser) {
|
||||
path: join(OUTPUT_DIR, 'desktop-claude.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/desktop-claude.png');
|
||||
console.log(' Saved: scripts/remotion/public/desktop-claude.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -575,7 +575,7 @@ async function captureDesktopBothClaude(browser) {
|
||||
path: join(OUTPUT_DIR, 'desktop-both-claude.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/desktop-both-claude.png');
|
||||
console.log(' Saved: scripts/remotion/public/desktop-both-claude.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -605,7 +605,7 @@ async function captureDesktopBothOpencode(browser) {
|
||||
path: join(OUTPUT_DIR, 'desktop-both-opencode.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/desktop-both-opencode.png');
|
||||
console.log(' Saved: scripts/remotion/public/desktop-both-opencode.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -634,7 +634,7 @@ async function captureMobileClaude(browser) {
|
||||
path: join(OUTPUT_DIR, 'mobile-claude.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/mobile-claude.png');
|
||||
console.log(' Saved: scripts/remotion/public/mobile-claude.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -663,7 +663,7 @@ async function captureMobileOpencode(browser) {
|
||||
path: join(OUTPUT_DIR, 'mobile-opencode.png'),
|
||||
fullPage: false,
|
||||
});
|
||||
console.log(' Saved: remotion/public/mobile-opencode.png');
|
||||
console.log(' Saved: scripts/remotion/public/mobile-opencode.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
@@ -706,12 +706,12 @@ async function main() {
|
||||
console.log('All 6 screenshots captured!');
|
||||
console.log('='.repeat(60));
|
||||
console.log('\nOutput files:');
|
||||
console.log(' remotion/public/desktop-welcome.png');
|
||||
console.log(' remotion/public/desktop-claude.png');
|
||||
console.log(' remotion/public/desktop-both-claude.png');
|
||||
console.log(' remotion/public/desktop-both-opencode.png');
|
||||
console.log(' remotion/public/mobile-claude.png');
|
||||
console.log(' remotion/public/mobile-opencode.png');
|
||||
console.log(' scripts/remotion/public/desktop-welcome.png');
|
||||
console.log(' scripts/remotion/public/desktop-claude.png');
|
||||
console.log(' scripts/remotion/public/desktop-both-claude.png');
|
||||
console.log(' scripts/remotion/public/desktop-both-opencode.png');
|
||||
console.log(' scripts/remotion/public/mobile-claude.png');
|
||||
console.log(' scripts/remotion/public/mobile-opencode.png');
|
||||
} catch (err) {
|
||||
console.error('\nFatal error:', err.message);
|
||||
console.error(err.stack);
|
||||
|
||||
@@ -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,19 @@
|
||||
[Unit]
|
||||
Description=Codeman Cloudflare Named Tunnel
|
||||
After=network-online.target codeman-web.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/cloudflared tunnel --config %h/.cloudflared/codeman.yml run codeman
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
KillMode=process
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=codeman-tunnel-named
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 390 KiB After Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,45 +1,209 @@
|
||||
#!/usr/bin/env bash
|
||||
# Quick Cloudflare Tunnel for Codeman
|
||||
# Usage: ./scripts/tunnel.sh [start|stop|status|url]
|
||||
# Cloudflare Tunnel manager for Codeman
|
||||
# Usage: ./scripts/tunnel.sh [quick|named] [start|stop|status|url]
|
||||
#
|
||||
# Modes:
|
||||
# quick — Quick tunnel with random trycloudflare.com URL (default)
|
||||
# named — Named tunnel on a fixed hostname (requires setup, see below)
|
||||
#
|
||||
# Environment variables:
|
||||
# CLOUDFLARED_TUNNEL_NAME — tunnel name (default: codeman)
|
||||
# CLOUDFLARED_TUNNEL_ID — tunnel UUID (from: cloudflared tunnel list)
|
||||
# CODEMAN_TUNNEL_HOSTNAME — public hostname (e.g. codeman.example.com)
|
||||
#
|
||||
# First-time named tunnel setup:
|
||||
# cloudflared tunnel login
|
||||
# cloudflared tunnel create <tunnel-name>
|
||||
# cloudflared tunnel route dns <tunnel-name> <hostname>
|
||||
# ./scripts/tunnel.sh named setup # writes ~/.cloudflared/<tunnel-name>.yml
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE="codeman-tunnel"
|
||||
QUICK_SERVICE="codeman-tunnel"
|
||||
NAMED_SERVICE="codeman-tunnel-named"
|
||||
TUNNEL_NAME="${CLOUDFLARED_TUNNEL_NAME:-codeman}"
|
||||
TUNNEL_HOSTNAME="${CODEMAN_TUNNEL_HOSTNAME:-codeman.example.com}"
|
||||
CODEMAN_PORT="3000"
|
||||
LOG_FILE="$HOME/.codeman/tunnel.log"
|
||||
|
||||
case "${1:-start}" in
|
||||
start)
|
||||
if ! systemctl --user is-active "$SERVICE" &>/dev/null; then
|
||||
# Install service if not already
|
||||
if ! systemctl --user cat "$SERVICE" &>/dev/null 2>&1; then
|
||||
cp "$(dirname "$0")/codeman-tunnel.service" "$HOME/.config/systemd/user/"
|
||||
systemctl --user daemon-reload
|
||||
fi
|
||||
systemctl --user start "$SERVICE"
|
||||
echo "Tunnel starting... waiting for URL"
|
||||
sleep 6
|
||||
fi
|
||||
# Extract the tunnel URL from journal
|
||||
URL=$(grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1)
|
||||
if [ -n "$URL" ]; then
|
||||
echo "$URL"
|
||||
else
|
||||
echo "URL not ready yet, try: $0 url"
|
||||
fi
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_require_cloudflared() {
|
||||
if ! command -v cloudflared &>/dev/null; then
|
||||
echo "Error: cloudflared not found. Install with: yay -S cloudflared" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
_cloudflared_bin() {
|
||||
command -v cloudflared
|
||||
}
|
||||
|
||||
_install_service() {
|
||||
local svc_file="$1"
|
||||
local svc_name="$2"
|
||||
if ! systemctl --user cat "$svc_name" &>/dev/null 2>&1; then
|
||||
cp "$(dirname "$0")/$svc_file" "$HOME/.config/systemd/user/"
|
||||
systemctl --user daemon-reload
|
||||
echo "Service $svc_name installed."
|
||||
fi
|
||||
}
|
||||
|
||||
_install_named_service() {
|
||||
if ! systemctl --user cat "$NAMED_SERVICE" &>/dev/null 2>&1; then
|
||||
# Generate service file with the configured tunnel name
|
||||
sed "s/codeman\.yml/$TUNNEL_NAME.yml/g; s/run codeman/run $TUNNEL_NAME/g" \
|
||||
"$(dirname "$0")/codeman-tunnel-named.service" \
|
||||
> "$HOME/.config/systemd/user/codeman-tunnel-named.service"
|
||||
systemctl --user daemon-reload
|
||||
echo "Service $NAMED_SERVICE installed (tunnel: $TUNNEL_NAME)."
|
||||
fi
|
||||
}
|
||||
|
||||
# ── named tunnel setup ───────────────────────────────────────────────────────
|
||||
|
||||
_named_setup() {
|
||||
_require_cloudflared
|
||||
|
||||
local creds_dir="$HOME/.cloudflared"
|
||||
local config_file="$creds_dir/$TUNNEL_NAME.yml"
|
||||
# Replace with your tunnel ID (from: cloudflared tunnel list)
|
||||
local tunnel_id="${CLOUDFLARED_TUNNEL_ID:-YOUR_TUNNEL_ID_HERE}"
|
||||
local creds_file="$creds_dir/$tunnel_id.json"
|
||||
|
||||
if [ ! -f "$creds_file" ]; then
|
||||
echo "Credentials not found: $creds_file"
|
||||
echo "Run: cloudflared tunnel create $TUNNEL_NAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$config_file" <<EOF
|
||||
tunnel: $tunnel_id
|
||||
credentials-file: $creds_file
|
||||
|
||||
ingress:
|
||||
- hostname: $TUNNEL_HOSTNAME
|
||||
service: http://localhost:$CODEMAN_PORT
|
||||
- service: http_status:404
|
||||
EOF
|
||||
|
||||
echo "Config written to $config_file"
|
||||
echo "Tunnel ID: $tunnel_id"
|
||||
echo "Hostname: $TUNNEL_HOSTNAME"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Add Cloudflare Access policy for $TUNNEL_HOSTNAME (Zero Trust dashboard)"
|
||||
echo " 2. ./scripts/tunnel.sh named start"
|
||||
}
|
||||
|
||||
# ── quick mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
_quick_start() {
|
||||
if ! systemctl --user is-active "$QUICK_SERVICE" &>/dev/null; then
|
||||
_install_service "codeman-tunnel.service" "$QUICK_SERVICE"
|
||||
systemctl --user start "$QUICK_SERVICE"
|
||||
echo "Quick tunnel starting... waiting for URL"
|
||||
sleep 6
|
||||
fi
|
||||
local url
|
||||
url=$(grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" 2>/dev/null | tail -1)
|
||||
if [ -n "$url" ]; then
|
||||
echo "$url"
|
||||
else
|
||||
echo "URL not ready yet, try: $0 quick url"
|
||||
fi
|
||||
}
|
||||
|
||||
_quick_stop() {
|
||||
systemctl --user stop "$QUICK_SERVICE"
|
||||
echo "Quick tunnel stopped"
|
||||
}
|
||||
|
||||
_quick_status() {
|
||||
systemctl --user status "$QUICK_SERVICE" --no-pager 2>&1 | head -10
|
||||
echo ""
|
||||
echo "URL:"
|
||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" 2>/dev/null | tail -1
|
||||
}
|
||||
|
||||
_quick_url() {
|
||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" 2>/dev/null | tail -1
|
||||
}
|
||||
|
||||
# ── named mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
_named_start() {
|
||||
_require_cloudflared
|
||||
if [ ! -f "$HOME/.cloudflared/$TUNNEL_NAME.yml" ]; then
|
||||
echo "Config not found. Run: $0 named setup"
|
||||
exit 1
|
||||
fi
|
||||
if ! systemctl --user is-active "$NAMED_SERVICE" &>/dev/null; then
|
||||
_install_named_service
|
||||
systemctl --user start "$NAMED_SERVICE"
|
||||
echo "Named tunnel starting..."
|
||||
sleep 3
|
||||
fi
|
||||
echo "https://$TUNNEL_HOSTNAME"
|
||||
}
|
||||
|
||||
_named_stop() {
|
||||
systemctl --user stop "$NAMED_SERVICE"
|
||||
echo "Named tunnel stopped"
|
||||
}
|
||||
|
||||
_named_status() {
|
||||
systemctl --user status "$NAMED_SERVICE" --no-pager 2>&1 | head -10
|
||||
echo ""
|
||||
echo "URL: https://$TUNNEL_HOSTNAME"
|
||||
}
|
||||
|
||||
_named_enable() {
|
||||
_install_named_service
|
||||
systemctl --user enable "$NAMED_SERVICE"
|
||||
echo "Named tunnel enabled at boot."
|
||||
}
|
||||
|
||||
_named_disable() {
|
||||
systemctl --user disable "$NAMED_SERVICE"
|
||||
echo "Named tunnel disabled."
|
||||
}
|
||||
|
||||
# ── dispatch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
MODE="${1:-quick}"
|
||||
CMD="${2:-start}"
|
||||
|
||||
case "$MODE" in
|
||||
quick)
|
||||
case "$CMD" in
|
||||
start) _quick_start ;;
|
||||
stop) _quick_stop ;;
|
||||
status) _quick_status ;;
|
||||
url) _quick_url ;;
|
||||
*) echo "Usage: $0 quick [start|stop|status|url]"; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
stop)
|
||||
systemctl --user stop "$SERVICE"
|
||||
echo "Tunnel stopped"
|
||||
;;
|
||||
status)
|
||||
systemctl --user status "$SERVICE" --no-pager 2>&1 | head -10
|
||||
echo ""
|
||||
echo "URL:"
|
||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1
|
||||
;;
|
||||
url)
|
||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1
|
||||
named)
|
||||
case "$CMD" in
|
||||
start) _named_start ;;
|
||||
stop) _named_stop ;;
|
||||
status) _named_status ;;
|
||||
url) echo "https://$TUNNEL_HOSTNAME" ;;
|
||||
setup) _named_setup ;;
|
||||
enable) _named_enable ;;
|
||||
disable) _named_disable ;;
|
||||
*) echo "Usage: $0 named [start|stop|status|url|setup|enable|disable]"; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
# backward compat: no mode prefix → quick tunnel
|
||||
start) _quick_start ;;
|
||||
stop) _quick_stop ;;
|
||||
status) _quick_status ;;
|
||||
url) _quick_url ;;
|
||||
*)
|
||||
echo "Usage: $0 [start|stop|status|url]"
|
||||
echo "Usage: $0 [quick|named] [start|stop|status|url]"
|
||||
echo " $0 named setup # first-time named tunnel configuration"
|
||||
echo " $0 named enable # start at boot"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export type AiIdleCheckConfig = AiCheckerConfigBase;
|
||||
type AiIdleCheckConfig = AiCheckerConfigBase;
|
||||
|
||||
export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR';
|
||||
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -485,16 +485,18 @@ program
|
||||
.description('Start the web interface')
|
||||
.option('-p, --port <port>', 'Port to listen on', '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')
|
||||
.action(async (options) => {
|
||||
const { startWebServer } = await import('./web/server.js');
|
||||
const port = parseInt(options.port, 10);
|
||||
const https = !!options.https;
|
||||
const titleHostname = options.titleHostname;
|
||||
const protocol = https ? 'https' : 'http';
|
||||
|
||||
console.log(chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`));
|
||||
|
||||
try {
|
||||
const server = await startWebServer(port, https);
|
||||
const server = await startWebServer(port, https, false, titleHostname);
|
||||
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://localhost:${port}`));
|
||||
if (https) {
|
||||
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -63,6 +63,8 @@ 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_EFFORT_LEVEL). Ephemeral — not written to disk. */
|
||||
envOverrides?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Options for respawning a dead pane. */
|
||||
@@ -77,6 +79,8 @@ 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>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,6 +98,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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -15,7 +15,7 @@ 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'];
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 {
|
||||
@@ -121,6 +121,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 +192,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.
|
||||
@@ -274,6 +255,7 @@ export class Session extends EventEmitter {
|
||||
private _lastPromptTime: number = 0;
|
||||
private activityTimeout: NodeJS.Timeout | null = null;
|
||||
private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection
|
||||
private _trustDialogAccepted: boolean = false; // Prevents repeated trust dialog auto-accept
|
||||
private _taskTracker: TaskTracker;
|
||||
|
||||
// Token tracking for auto-clear
|
||||
@@ -329,6 +311,10 @@ export class Session extends EventEmitter {
|
||||
private _openCodeConfig: OpenCodeConfig | undefined;
|
||||
private _resumeSessionId: string | undefined;
|
||||
|
||||
// Ephemeral env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL). Exported by tmux at spawn,
|
||||
// preserved across respawns via persisted state. Not written to .claude/settings.local.json.
|
||||
private _envOverrides: Record<string, string> | undefined;
|
||||
|
||||
// Session color for visual differentiation
|
||||
private _color: import('./types.js').SessionColor = 'default';
|
||||
|
||||
@@ -388,6 +374,8 @@ 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>;
|
||||
}
|
||||
) {
|
||||
super();
|
||||
@@ -435,6 +423,11 @@ export class Session extends EventEmitter {
|
||||
this._openCodeConfig = config.openCodeConfig;
|
||||
}
|
||||
|
||||
// Apply env overrides (exported at spawn, not persisted to disk)
|
||||
if (config.envOverrides && Object.keys(config.envOverrides).length > 0) {
|
||||
this._envOverrides = { ...config.envOverrides };
|
||||
}
|
||||
|
||||
// Initialize task tracker and forward events (store handlers for cleanup)
|
||||
this._taskTracker = new TaskTracker();
|
||||
this._taskTrackerHandlers = {
|
||||
@@ -529,6 +522,20 @@ 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;
|
||||
}
|
||||
|
||||
get totalCost(): number {
|
||||
return this._totalCost;
|
||||
}
|
||||
@@ -840,9 +847,29 @@ export class Session extends EventEmitter {
|
||||
cliLatestVersion: this._cliLatestVersion || undefined,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
// 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(),
|
||||
@@ -957,11 +984,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(),
|
||||
});
|
||||
@@ -1008,6 +1037,7 @@ export class Session extends EventEmitter {
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
@@ -1020,6 +1050,7 @@ export class Session extends EventEmitter {
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
},
|
||||
spawnErrLabel: 'mux attachment',
|
||||
});
|
||||
@@ -1095,7 +1126,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:', spawnErr);
|
||||
@@ -1118,6 +1150,16 @@ export class Session extends EventEmitter {
|
||||
|
||||
this._handleTerminalOutput(data);
|
||||
|
||||
// === Auto-accept workspace trust dialog ===
|
||||
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
|
||||
// Codeman sessions always use --dangerously-skip-permissions, so auto-accept.
|
||||
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
|
||||
this._trustDialogAccepted = true;
|
||||
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
|
||||
// Send Enter to accept the default selection ("Yes, I trust this folder")
|
||||
this.writeViaMux('\r');
|
||||
}
|
||||
|
||||
// === Idle/working detection runs on every chunk (latency-sensitive) ===
|
||||
// Detect if Claude is working or at prompt
|
||||
// The prompt line contains "❯" when waiting for input
|
||||
@@ -1330,6 +1372,7 @@ export class Session extends EventEmitter {
|
||||
workingDir: this.workingDir,
|
||||
mode: 'shell',
|
||||
niceConfig: this._niceConfig,
|
||||
envOverrides: this._envOverrides,
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
@@ -1337,6 +1380,7 @@ export class Session extends EventEmitter {
|
||||
mode: 'shell',
|
||||
name: this._name,
|
||||
niceConfig: this._niceConfig,
|
||||
envOverrides: this._envOverrides,
|
||||
},
|
||||
spawnErrLabel: 'shell mux attachment',
|
||||
});
|
||||
@@ -1472,7 +1516,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);
|
||||
@@ -1618,11 +1663,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -309,7 +309,7 @@ export class StateStore {
|
||||
|
||||
this.ensureDir();
|
||||
|
||||
const tempPath = this.filePath + '.tmp';
|
||||
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
||||
const backupPath = this.filePath + '.bak';
|
||||
|
||||
// Step 1: Serialize state (validates it's JSON-safe)
|
||||
@@ -373,7 +373,7 @@ export class StateStore {
|
||||
|
||||
this.ensureDir();
|
||||
|
||||
const tempPath = this.filePath + '.tmp';
|
||||
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
||||
const backupPath = this.filePath + '.bak';
|
||||
|
||||
const json = this.serializeState();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -71,6 +71,9 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
||||
/** Default stats collection interval (2 seconds) */
|
||||
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||||
|
||||
/** 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),
|
||||
@@ -98,6 +101,63 @@ 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. */
|
||||
const DEFAULT_CODEMAN_TMUX_SOCKET = 'codeman';
|
||||
|
||||
/** 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 +183,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.
|
||||
@@ -185,8 +270,8 @@ function buildSpawnCommand(options: {
|
||||
}): 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 safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
|
||||
const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
|
||||
// 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.
|
||||
@@ -210,7 +295,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 +303,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 +319,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 +350,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 +383,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 +399,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 +417,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 +488,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 +508,35 @@ 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 {
|
||||
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 +560,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 +581,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
} = options;
|
||||
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
||||
|
||||
@@ -473,7 +635,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,7 +646,7 @@ 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`, {
|
||||
execSync(`${this.tmux()} new-session -ds "${muxName}" -c "${workingDir}"`, {
|
||||
cwd: workingDir,
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
@@ -493,7 +655,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,8 +669,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this._configureOpenCode(muxName, openCodeConfig);
|
||||
}
|
||||
|
||||
// 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)
|
||||
execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
||||
execSync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
@@ -522,23 +688,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 +768,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 +794,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 +820,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
} = options;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
@@ -670,7 +844,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
});
|
||||
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,7 +852,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this._configureOpenCode(muxName, openCodeConfig);
|
||||
}
|
||||
|
||||
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
||||
// Re-apply user env overrides before respawn so the new shell inherits them.
|
||||
this.applyEnvOverrides(muxName, envOverrides);
|
||||
|
||||
await execAsync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
// Wait for the respawned process to start
|
||||
@@ -696,7 +873,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 +1013,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 +1078,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 +1107,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 +1132,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})`);
|
||||
}
|
||||
@@ -1268,21 +1440,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,
|
||||
});
|
||||
}
|
||||
@@ -1309,7 +1481,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`tmux set-option -t "${muxName}" mouse on`, {
|
||||
execSync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
@@ -1333,7 +1505,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`tmux set-option -t "${muxName}" mouse off`, {
|
||||
execSync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
@@ -1373,7 +1545,7 @@ 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}'`,
|
||||
`${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
|
||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||
).trim();
|
||||
|
||||
@@ -1412,27 +1584,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 +1636,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 +1668,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 +1696,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 +1712,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.
|
||||
|
||||
@@ -150,11 +150,3 @@ export interface SessionState {
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -7,17 +7,14 @@
|
||||
*/
|
||||
|
||||
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 { 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 +24,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
|
||||
|
||||