Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
64b8ea30b2 | ||
|
|
5743af3339 | ||
|
|
2011bd8d89 | ||
|
|
b76724690d | ||
|
|
f277f9664c | ||
|
|
cd49171bbc | ||
|
|
0f57342b10 | ||
|
|
e1f0ac993a | ||
|
|
a84ef52992 | ||
|
|
692c894760 | ||
|
|
ad0acb6d58 |
@@ -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
|
||||
|
||||
|
||||
@@ -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,181 @@
|
||||
# aicodeman
|
||||
|
||||
## 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
|
||||
|
||||
- feat: support "Default (CLI default)" option for model selection. Adds a new empty-value option to the model dropdown that defers to the CLI's own default model instead of forcing a specific model. Ensures empty defaultModel values are treated as undefined when passed to session creation and Ralph loop start, preventing empty strings from being sent as model flags.
|
||||
|
||||
## 0.5.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix: default new sessions to opus[1m] (1M context window) instead of plain opus (200k context)
|
||||
|
||||
## 0.5.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add 1M Opus context quick setting — per-case and global toggle that writes `model: "opus[1m]"` to `.claude/settings.local.json` when creating new sessions. Fix mobile layout: banners (respawn, timer, orchestrator) between header and main content now visible by switching from margin-top on `.main` to padding-top on `.app`. Add tablet-optimized respawn banner styles and mobile phone banner refinements.
|
||||
|
||||
## 0.5.4
|
||||
|
||||
### 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.4 (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.6.7 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -73,21 +77,26 @@ 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()` — tab title renders as `codeman:<name>`) |
|
||||
| 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
|
||||
- **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 package) AND inline inside `src/web/public/app.js` (runtime copy used by the web UI). Any change to overlay behavior MUST be applied to both, or dev and prod diverge. Always test on mobile after touching it.
|
||||
|
||||
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
||||
|
||||
@@ -109,11 +118,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` (~2.9K 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` (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 +145,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 +159,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 +176,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.
|
||||
~128 handlers across 15 route files in `src/web/routes/`: system (36), sessions (27), 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 +202,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()` in `test/routes/`. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles).
|
||||
|
||||
## Debugging
|
||||
|
||||
@@ -220,27 +227,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.
|
||||
|
||||
### 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,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,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.3.11",
|
||||
"version": "0.6.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "0.3.11",
|
||||
"version": "0.6.7",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.5.4",
|
||||
"version": "0.6.7",
|
||||
"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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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.
|
||||
@@ -116,6 +152,34 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the `model` field in .claude/settings.local.json for the given case path.
|
||||
* Pass a non-empty string to set, or empty/null to remove.
|
||||
*/
|
||||
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
|
||||
const claudeDir = join(casePath, '.claude');
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
let existing: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
|
||||
if (model) {
|
||||
existing.model = model;
|
||||
} else {
|
||||
delete existing.model;
|
||||
}
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes hooks config to .claude/settings.local.json in the given case path.
|
||||
* Merges with existing file content, only touching the `hooks` key.
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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).
|
||||
@@ -2078,6 +2021,18 @@ export class RespawnController extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for active child processes (bash tools, test suites, builds, etc.)
|
||||
// These may produce no terminal output, so restart timers to retry periodically.
|
||||
const activeProcesses = this.session.getActiveChildProcesses();
|
||||
if (activeProcesses.length > 0) {
|
||||
const names = activeProcesses.map((p) => p.command).join(', ');
|
||||
this.log(`Skipping AI check - ${activeProcesses.length} active child process(es): ${names}`);
|
||||
this.logAction('detection', `Skipped AI check: child processes running (${names})`);
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
// If AI check is disabled or errored out, fall back to direct idle confirmation
|
||||
if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') {
|
||||
this.log(`AI check unavailable (${this.aiChecker.status}), confirming idle directly via: ${reason}`);
|
||||
@@ -2682,6 +2637,18 @@ export class RespawnController extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Safety check: if child processes are running (bash tools, test suites, builds, etc.)
|
||||
const activeProcesses = this.session.getActiveChildProcesses();
|
||||
if (activeProcesses.length > 0) {
|
||||
const names = activeProcesses.map((p) => p.command).join(', ');
|
||||
this.log(`Idle confirmation rejected - ${activeProcesses.length} active child process(es): ${names}`);
|
||||
this.logAction('detection', `Rejected: child processes running (${names})`);
|
||||
this.setState('watching');
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(`Idle confirmed via: ${reason}`);
|
||||
const status = this.getDetectionStatus();
|
||||
this.log(
|
||||
|
||||
@@ -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,6 +29,7 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { execSync, execFileSync } from 'node:child_process';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as pty from 'node-pty';
|
||||
import {
|
||||
@@ -65,6 +66,7 @@ import {
|
||||
MAX_MESSAGES,
|
||||
MAX_LINE_BUFFER_SIZE,
|
||||
} from './config/buffer-limits.js';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import {
|
||||
buildInteractiveArgs,
|
||||
buildPromptArgs,
|
||||
@@ -152,63 +154,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.
|
||||
@@ -272,6 +217,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
|
||||
@@ -327,6 +273,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';
|
||||
|
||||
@@ -386,6 +336,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();
|
||||
@@ -433,6 +385,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 = {
|
||||
@@ -527,6 +484,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;
|
||||
}
|
||||
@@ -539,6 +510,49 @@ export class Session extends EventEmitter {
|
||||
return this._isWorking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the session's process tree has active child processes beyond Claude itself.
|
||||
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
|
||||
*
|
||||
* The tmux pane PID is typically "claude" directly (bash exec'd into it). When Claude
|
||||
* runs a bash tool, it spawns child processes: claude → bash → npm/node/python/etc.
|
||||
* We check direct children of the pane PID, filtering out "claude" itself (for the rare
|
||||
* case where bash wraps claude and didn't exec).
|
||||
*
|
||||
* Returns an array of {pid, command} for each child process, or empty array if none.
|
||||
* Returns empty array if no mux session or on error (fail-open to avoid blocking respawn).
|
||||
*/
|
||||
getActiveChildProcesses(): { pid: number; command: string }[] {
|
||||
if (!this._muxSession) return [];
|
||||
|
||||
try {
|
||||
const panePid = this._muxSession.pid;
|
||||
|
||||
// Single call: get direct children with their command names
|
||||
const output = execSync(`ps -o pid=,comm= --ppid ${panePid} 2>/dev/null`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (!output) return [];
|
||||
|
||||
const activeProcesses: { pid: number; command: string }[] = [];
|
||||
for (const line of output.split('\n')) {
|
||||
const match = line.trim().match(/^(\d+)\s+(.+)/);
|
||||
if (!match) continue;
|
||||
const pid = parseInt(match[1], 10);
|
||||
const command = match[2].trim();
|
||||
// Skip the claude process itself (pane_pid may be bash wrapping claude)
|
||||
if (command === 'claude') continue;
|
||||
activeProcesses.push({ pid, command });
|
||||
}
|
||||
|
||||
return activeProcesses;
|
||||
} catch {
|
||||
// ps returns exit code 1 when no matches — normal (no children)
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
get lastPromptTime(): number {
|
||||
return this._lastPromptTime;
|
||||
}
|
||||
@@ -795,9 +809,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(),
|
||||
@@ -912,11 +946,28 @@ 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)
|
||||
let ptyCols = 120;
|
||||
let ptyRows = 40;
|
||||
try {
|
||||
const sizeStr = execFileSync(
|
||||
'tmux',
|
||||
['display', '-t', this._muxSession!.muxName, '-p', '#{window_width} #{window_height}'],
|
||||
{ timeout: 2000, encoding: 'utf8' }
|
||||
).trim();
|
||||
const [w, h] = sizeStr.split(' ').map(Number);
|
||||
if (w > 0 && h > 0) {
|
||||
ptyCols = w;
|
||||
ptyRows = h;
|
||||
}
|
||||
} catch {
|
||||
/* fall back to 120x40 */
|
||||
}
|
||||
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(),
|
||||
});
|
||||
@@ -963,6 +1014,7 @@ export class Session extends EventEmitter {
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
@@ -975,6 +1027,7 @@ export class Session extends EventEmitter {
|
||||
allowedTools: this._allowedTools,
|
||||
openCodeConfig: this._openCodeConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
},
|
||||
spawnErrLabel: 'mux attachment',
|
||||
});
|
||||
@@ -1050,7 +1103,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);
|
||||
@@ -1073,6 +1127,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
|
||||
@@ -1285,6 +1349,7 @@ export class Session extends EventEmitter {
|
||||
workingDir: this.workingDir,
|
||||
mode: 'shell',
|
||||
niceConfig: this._niceConfig,
|
||||
envOverrides: this._envOverrides,
|
||||
},
|
||||
createSessionOptions: {
|
||||
sessionId: this.id,
|
||||
@@ -1292,6 +1357,7 @@ export class Session extends EventEmitter {
|
||||
mode: 'shell',
|
||||
name: this._name,
|
||||
niceConfig: this._niceConfig,
|
||||
envOverrides: this._envOverrides,
|
||||
},
|
||||
spawnErrLabel: 'shell mux attachment',
|
||||
});
|
||||
@@ -1427,7 +1493,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);
|
||||
@@ -1573,11 +1640,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.
|
||||
|
||||
@@ -98,6 +98,44 @@ 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+)$/;
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
|
||||
/**
|
||||
* 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]/;
|
||||
|
||||
@@ -185,8 +223,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.
|
||||
@@ -361,6 +399,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 +419,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(`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.
|
||||
@@ -420,6 +491,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
} = options;
|
||||
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
||||
|
||||
@@ -484,7 +556,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(`tmux new-session -ds "${muxName}" -c "${workingDir}"`, {
|
||||
cwd: workingDir,
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
@@ -507,6 +579,10 @@ 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)}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
@@ -533,6 +609,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
.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(`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
|
||||
@@ -647,6 +730,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
allowedTools,
|
||||
openCodeConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
} = options;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
@@ -678,6 +762,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this._configureOpenCode(muxName, openCodeConfig);
|
||||
}
|
||||
|
||||
// Re-apply user env overrides before respawn so the new shell inherits them.
|
||||
this.applyEnvOverrides(muxName, envOverrides);
|
||||
|
||||
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
@@ -902,23 +989,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
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>();
|
||||
let activeSessions = new Map<string, number>();
|
||||
try {
|
||||
const output = execSync("tmux list-panes -a -F '#{session_name}\t#{pane_pid}' 2>/dev/null || true", {
|
||||
const output = execSync(`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);
|
||||
}
|
||||
}
|
||||
activeSessions = parsePaneList(output);
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to list tmux panes:', err);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,7 +22,7 @@ interface TimedEntry<V> {
|
||||
/**
|
||||
* Configuration options for StaleExpirationMap.
|
||||
*/
|
||||
export interface StaleExpirationMapOptions<K, V> {
|
||||
interface StaleExpirationMapOptions<K, V> {
|
||||
/** Time-to-live in milliseconds before entries expire */
|
||||
ttlMs: number;
|
||||
/** How often to run cleanup (default: ttlMs / 2) */
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
export const AUTH_COOKIE_NAME = 'codeman_session';
|
||||
|
||||
/** State returned from registerAuthMiddleware for cleanup in server stop() */
|
||||
export interface AuthState {
|
||||
interface AuthState {
|
||||
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
|
||||
authFailures: StaleExpirationMap<string, number> | null;
|
||||
qrAuthFailures: StaleExpirationMap<string, number> | null;
|
||||
|
||||
@@ -11,5 +11,5 @@ export type { EventPort } from './event-port.js';
|
||||
export type { RespawnPort } from './respawn-port.js';
|
||||
export type { ConfigPort } from './config-port.js';
|
||||
export type { InfraPort, ScheduledRun } from './infra-port.js';
|
||||
export type { AuthPort, AuthSessionRecord } from './auth-port.js';
|
||||
export type { AuthPort } from './auth-port.js';
|
||||
export type { OrchestratorPort } from './orchestrator-port.js';
|
||||
|
||||
@@ -249,8 +249,28 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.ORCHESTRATOR_TASK_FAILED, '_onOrchestratorTaskFailed'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_COMPLETED, '_onOrchestratorCompleted'],
|
||||
[SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'],
|
||||
|
||||
// Clipboard
|
||||
[SSE_EVENTS.CLIPBOARD_WRITE, '_onClipboardWrite'],
|
||||
];
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Name Prefix Parser
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Parses w<N>-<caseName> or s<N>-<caseName> prefix from session names.
|
||||
// Returns { prefix, suffix } or null if name does not match the pattern.
|
||||
function parseSessionPrefix(name) {
|
||||
if (!name) return null;
|
||||
const m = name.match(/^(w\d+-[a-zA-Z0-9_-]+|s\d+-[a-zA-Z0-9_-]+)/);
|
||||
if (!m) return null;
|
||||
const prefix = m[1];
|
||||
const rest = name.slice(prefix.length);
|
||||
if (rest === "") return { prefix, suffix: "" };
|
||||
if (rest.startsWith(": ")) return { prefix, suffix: rest.slice(2) };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CodemanApp Class — constructor and global state
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -523,6 +543,9 @@ class CodemanApp {
|
||||
SwipeHandler.init();
|
||||
VoiceInput.init();
|
||||
KeyboardAccessoryBar.init();
|
||||
// Apply keyboard bar mode from settings
|
||||
const _kbSettings = this.loadAppSettingsFromStorage();
|
||||
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
@@ -612,15 +635,15 @@ class CodemanApp {
|
||||
// shift? (require Shift), action }.
|
||||
const SHORTCUTS = [
|
||||
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
|
||||
{ key: 'Enter', ctrl: true, action: () => this.quickStart() },
|
||||
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
|
||||
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
|
||||
{ key: 'k', ctrl: true, action: () => this.killAllSessions() },
|
||||
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
|
||||
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
|
||||
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
|
||||
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
|
||||
{ key: 'V', ctrl: true, shift: true, action: () => VoiceInput.toggle() },
|
||||
{ key: '{', ctrl: true, shift: true, action: () => this.moveActiveTabLeft() },
|
||||
{ key: '}', ctrl: true, shift: true, action: () => this.moveActiveTabRight() },
|
||||
];
|
||||
|
||||
// Use capture to handle before terminal
|
||||
@@ -634,6 +657,16 @@ class CodemanApp {
|
||||
this.closeHelp();
|
||||
}
|
||||
|
||||
// Alt+1-9: switch to Codeman session by index
|
||||
if (e.altKey && !e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '9') {
|
||||
const idx = parseInt(e.key) - 1;
|
||||
if (idx < this.sessionOrder.length) {
|
||||
e.preventDefault();
|
||||
this.selectSession(this.sessionOrder[idx]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Match against shortcut table
|
||||
for (const s of SHORTCUTS) {
|
||||
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
|
||||
@@ -866,6 +899,291 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Response Viewer — native-scroll panel for reading full Claude responses
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Strip dangerous elements and attributes from HTML (XSS prevention) */
|
||||
_sanitizeHtml(html) {
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = html;
|
||||
const frag = tpl.content;
|
||||
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
|
||||
el.remove();
|
||||
}
|
||||
for (const el of frag.querySelectorAll('*')) {
|
||||
for (const attr of [...el.attributes]) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (name.startsWith('on')) {
|
||||
el.removeAttribute(attr.name);
|
||||
} else if (['href', 'src', 'action', 'xlink:href', 'formaction'].includes(name)) {
|
||||
const val = attr.value.replace(/\s/g, '').toLowerCase();
|
||||
if (val.startsWith('javascript:') || val.startsWith('vbscript:') || val.startsWith('data:text/html')) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(frag);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI escape sequences and Claude CLI chrome (status bar, hints,
|
||||
* spinner, progress bar) from a terminal buffer so the response viewer can
|
||||
* show just the conversational text when the JSONL transcript is missing.
|
||||
*/
|
||||
_cleanTerminalBuffer(buf) {
|
||||
const stripped = buf
|
||||
// CSI sequences — params (0x30-0x3F includes digits, ?, ;, <, =, >),
|
||||
// intermediates (0x20-0x2F), final byte (0x40-0x7E). Catches \x1b[>c,
|
||||
// \x1b[>q, \x1b[?25l etc. that the previous regex missed.
|
||||
.replace(/\x1b\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]/g, '')
|
||||
// OSC sequences (window titles etc.) terminated by BEL or ST
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||||
// DCS / APC / PM / SOS sequences
|
||||
.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '')
|
||||
// SS2/SS3 + charset selects + single-char escapes
|
||||
.replace(/\x1b[NO()][A-Z0-9]?/g, '')
|
||||
.replace(/\x1b[>=<78cDEHM]/g, '')
|
||||
// Stray control chars (except \t \n)
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||
.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
// Drop Claude CLI chrome lines that aren't part of the response.
|
||||
const CHROME_PATTERNS = [
|
||||
/^\s*❯\s*/, // shell prompt
|
||||
/^\s*[⏵⏺⏸⏹]+\s*/, // status glyphs
|
||||
/^\s*✻\s*(Crunching|Crunched|Thinking)/i, // spinner lines
|
||||
/bypass permissions/i,
|
||||
/\bshift\+tab to cycle\b/i,
|
||||
/^\s*focus\s*$/,
|
||||
/^\s*new task\?/i,
|
||||
/\/clear to save/i,
|
||||
/^\s*─{5,}\s*$/, // horizontal dividers
|
||||
/\[(Opus|Sonnet|Haiku|GPT|Claude)[\s\S]*(tokens?|\$|¥|%|↑|↓)/i, // status bar
|
||||
/^\s*\[\d+[km]?\/\d+[km]?\]/i, // token counter
|
||||
/[█░▓▒]{3,}/, // progress bar
|
||||
/^\s*\(.*\s*(tokens?|context).*\)\s*$/i,
|
||||
];
|
||||
|
||||
const lines = stripped.split('\n');
|
||||
const kept = lines.filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return true; // keep blanks so paragraphs survive
|
||||
return !CHROME_PATTERNS.some((re) => re.test(line));
|
||||
});
|
||||
|
||||
return kept
|
||||
.join('\n')
|
||||
.replace(/[ \t]+$/gm, '')
|
||||
.replace(/\n{4,}/g, '\n\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap ASCII/box diagrams in fenced code blocks so marked.js preserves whitespace.
|
||||
* Claude often emits box-drawing diagrams without triple-backticks; without this
|
||||
* step, HTML collapses the whitespace and the diagram becomes unreadable prose.
|
||||
*/
|
||||
_preprocessAsciiArt(text) {
|
||||
// Only trigger on characters that rarely appear in prose:
|
||||
// U+2500-U+257F Box Drawing (─│┌┐└┘├┤┬┴┼╔╗╚╝═║)
|
||||
// U+2580-U+259F Block Elements (▀▄█▌▐░▒▓, progress bars)
|
||||
// Deliberately excluded:
|
||||
// U+2190-U+21FF Arrows (→←↑↓⇒ — common rhetorical prose)
|
||||
// U+25A0-U+25FF Geometric Shapes (●○■□◆◇ — common bullets)
|
||||
// Triggering on those would wrap numbered lists / prose that merely uses
|
||||
// arrows in code blocks and break their markdown rendering.
|
||||
const BOX_PATTERN = /[─-╿▀-▟]/;
|
||||
|
||||
// Preserve existing fenced code blocks as-is (hide them behind placeholders)
|
||||
const fenceRe = /```[\s\S]*?```/g;
|
||||
const placeholders = [];
|
||||
const masked = text.replace(fenceRe, (m) => {
|
||||
placeholders.push(m);
|
||||
return ` | ||||