mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-26 10:59:33 +01:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -22,6 +22,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Check package-lock.json version sync
|
||||||
|
run: npm run check:lockfile
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -48,7 +48,12 @@ Thumbs.db
|
|||||||
# Generated output
|
# Generated output
|
||||||
out/
|
out/
|
||||||
screenshots-echo-diag/
|
screenshots-echo-diag/
|
||||||
tools/remotion/out/
|
scripts/remotion/out/
|
||||||
|
|
||||||
|
# Artifacts that should not be tracked
|
||||||
|
test-results/
|
||||||
|
tmp/
|
||||||
|
public
|
||||||
|
|
||||||
# Claude Code plan tracking
|
# Claude Code plan tracking
|
||||||
plan.json
|
plan.json
|
||||||
|
|||||||
+1
-1
@@ -6,4 +6,4 @@ src/web/public/app.js
|
|||||||
src/web/public/styles.css
|
src/web/public/styles.css
|
||||||
src/web/public/mobile.css
|
src/web/public/mobile.css
|
||||||
src/web/public/index.html
|
src/web/public/index.html
|
||||||
tools/
|
scripts/remotion/
|
||||||
@@ -1,5 +1,63 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 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
|
## 0.5.9
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
| Type check | `tsc --noEmit` |
|
| Type check | `tsc --noEmit` |
|
||||||
| Lint | `npm run lint` (fix: `npm run lint:fix`) |
|
| Lint | `npm run lint` (fix: `npm run lint:fix`) |
|
||||||
| Format | `npm run format` (check: `npm run format:check`) |
|
| 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`) |
|
||||||
|
| 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` |
|
| Production | `npm run build && systemctl --user restart codeman-web` |
|
||||||
|
|
||||||
## CRITICAL: Session Safety
|
## CRITICAL: Session Safety
|
||||||
@@ -48,11 +49,13 @@ When user says "COM":
|
|||||||
CHANGESET
|
CHANGESET
|
||||||
```
|
```
|
||||||
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
|
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`
|
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`
|
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||||
|
|
||||||
**Version**: 0.5.9 (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.1 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -78,16 +81,16 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
| Production start | `npm run start` |
|
| Production start | `npm run start` |
|
||||||
| Production logs | `journalctl --user -u codeman-web -f` |
|
| 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
|
## Common Gotchas
|
||||||
|
|
||||||
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink
|
- **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
|
- **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
|
- **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)
|
||||||
|
|
||||||
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
||||||
|
|
||||||
@@ -109,11 +112,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` | |
|
| **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` | |
|
| **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` | |
|
| **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` | |
|
| **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.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` | |
|
| **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` → 14 domain files | See `@fileoverview` in index.ts |
|
| **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`.
|
**Local package**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; copy embedded in `app.js`.
|
||||||
|
|
||||||
@@ -136,7 +139,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`.
|
**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`.
|
**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`.
|
||||||
|
|
||||||
@@ -150,7 +153,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).
|
**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
|
### Security
|
||||||
|
|
||||||
@@ -167,11 +170,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
### SSE Event Registry
|
### 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
|
### 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
|
## Adding Features
|
||||||
|
|
||||||
@@ -193,22 +196,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:
|
**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
|
```bash
|
||||||
npx vitest run test/<specific-file>.test.ts # Single file (SAFE)
|
npm test -- test/<specific-file>.test.ts # Single file (SAFE, uses config/vitest.config.ts)
|
||||||
npx vitest run -t "pattern" # By name (SAFE)
|
npm test -- -t "pattern" # By name (SAFE)
|
||||||
# npx vitest run # DANGEROUS — DON'T DO THIS
|
# 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.
|
**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.
|
**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.
|
**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).
|
**Respawn tests**: Use `MockSession` from `test/respawn-test-utils.ts`. **Route tests**: `app.inject()` in `test/routes/`. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles).
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
Mobile screenshots in `~/.codeman/screenshots/`. API: `GET /api/screenshots`, `POST /api/screenshots`.
|
|
||||||
|
|
||||||
## Debugging
|
## Debugging
|
||||||
|
|
||||||
@@ -220,27 +221,14 @@ curl localhost:3000/api/subagents | jq # Background agents
|
|||||||
cat ~/.codeman/state.json | jq # Persisted state
|
cat ~/.codeman/state.json | jq # Persisted state
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Mobile screenshots: `~/.codeman/screenshots/`, accessed via `GET/POST /api/screenshots`.
|
||||||
|
|
||||||
## Performance & Limits
|
## 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`.
|
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: `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.
|
||||||
|
|
||||||
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.
|
|
||||||
@@ -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.
|
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:
|
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
|
```bash
|
||||||
@@ -60,12 +42,53 @@ codeman web
|
|||||||
|
|
||||||
**Linux (systemd):**
|
**Linux (systemd):**
|
||||||
```bash
|
```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):**
|
**macOS (launchd):**
|
||||||
```bash
|
```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>
|
</details>
|
||||||
|
|
||||||
@@ -378,9 +401,12 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|
|||||||
| `Ctrl+Enter` | Quick-start session |
|
| `Ctrl+Enter` | Quick-start session |
|
||||||
| `Ctrl+W` | Close session |
|
| `Ctrl+W` | Close session |
|
||||||
| `Ctrl+Tab` | Next 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+K` | Kill all sessions |
|
||||||
| `Ctrl+L` | Clear terminal |
|
| `Ctrl+L` | Clear terminal |
|
||||||
| `Ctrl+Shift+R` | Restore terminal size |
|
| `Ctrl+Shift+R` | Restore terminal size |
|
||||||
|
| `Ctrl+Shift+V` | Toggle voice input |
|
||||||
| `Ctrl/Cmd +/-` | Font size |
|
| `Ctrl/Cmd +/-` | Font size |
|
||||||
| `Escape` | Close panels |
|
| `Escape` | Close panels |
|
||||||
|
|
||||||
@@ -423,6 +449,7 @@ Single-digit selection (1-9), color-coded status, token counts, auto-refresh. De
|
|||||||
| `GET` | `/api/events` | SSE stream |
|
| `GET` | `/api/events` | SSE stream |
|
||||||
| `GET` | `/api/status` | Full app state |
|
| `GET` | `/api/status` | Full app state |
|
||||||
| `POST` | `/api/hook-event` | Hook callbacks |
|
| `POST` | `/api/hook-event` | Hook callbacks |
|
||||||
|
| `POST` | `/api/clipboard` | Push text to all connected browsers (`{text}`) |
|
||||||
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
|
| `GET` | `/api/sessions/:id/run-summary` | Timeline + stats |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ export default tseslint.config(
|
|||||||
'src/web/public/vendor/**',
|
'src/web/public/vendor/**',
|
||||||
'src/web/public/app.js',
|
'src/web/public/app.js',
|
||||||
'scripts/**/*.mjs',
|
'scripts/**/*.mjs',
|
||||||
'tools/**',
|
'scripts/remotion/**',
|
||||||
'remotion/**',
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { resolve } from 'node:path';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dirname, '..');
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
|
root,
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['test/**/*.test.ts'],
|
include: ['test/**/*.test.ts'],
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||||
|
"entry": [
|
||||||
|
"scripts/*.mjs",
|
||||||
|
"scripts/*.js",
|
||||||
|
"scripts/watch-subagents.ts",
|
||||||
|
"scripts/remotion/Root.tsx",
|
||||||
|
"scripts/remotion/index.ts",
|
||||||
|
"test/**/*.test.ts",
|
||||||
|
"test/mobile/vitest.config.ts",
|
||||||
|
"test/**/*.mjs"
|
||||||
|
],
|
||||||
|
"project": ["src/**/*.{ts,tsx}", "scripts/**/*.{ts,tsx,mjs,js}", "test/**/*.{ts,mjs}"],
|
||||||
|
"ignoreExportsUsedInFile": true,
|
||||||
|
"ignoreDependencies": ["@remotion/cli", "@remotion/transitions", "esbuild", "agent-browser"]
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "0.3.11",
|
"version": "0.6.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "0.3.11",
|
"version": "0.6.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+9
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "0.5.9",
|
"version": "0.6.1",
|
||||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -15,17 +15,19 @@
|
|||||||
"dev": "tsx src/index.ts web",
|
"dev": "tsx src/index.ts web",
|
||||||
"web": "node dist/index.js web",
|
"web": "node dist/index.js web",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"test": "vitest run",
|
"test": "vitest run --config config/vitest.config.ts",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest --config config/vitest.config.ts",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --config config/vitest.config.ts --coverage",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||||
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
|
||||||
"format": "prettier --write 'src/**/*.ts'",
|
"format": "prettier --write 'src/**/*.ts'",
|
||||||
"format:check": "prettier --check 'src/**/*.ts'",
|
"format:check": "prettier --check 'src/**/*.ts'",
|
||||||
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
|
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
|
||||||
"changeset": "changeset",
|
"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"
|
"release": "changeset publish"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
@@ -428,7 +428,7 @@ const SUBAGENT_ACTIVITY = {
|
|||||||
'agent-002': [
|
'agent-002': [
|
||||||
{ type: 'tool', tool: 'Glob', input: { pattern: 'test/**/*.test.ts' }, timestamp: new Date().toISOString(), agentId: '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/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' },
|
{ 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
|
* Usage: node scripts/capture-video-screenshots.mjs
|
||||||
* Port: 3198 (static file server)
|
* Port: 3198 (static file server)
|
||||||
* Output: remotion/public/ (6 PNGs)
|
* Output: scripts/scripts/remotion/public/ (6 PNGs)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'url';
|
|||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
const PROJECT_ROOT = join(__dirname, '..');
|
const PROJECT_ROOT = join(__dirname, '..');
|
||||||
const PUBLIC_DIR = join(PROJECT_ROOT, 'src', 'web', 'public');
|
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 PORT = 3198;
|
||||||
|
|
||||||
const DESKTOP_VIEWPORT = { width: 1920, height: 1080 };
|
const DESKTOP_VIEWPORT = { width: 1920, height: 1080 };
|
||||||
@@ -516,7 +516,7 @@ async function captureDesktopWelcome(browser) {
|
|||||||
path: join(OUTPUT_DIR, 'desktop-welcome.png'),
|
path: join(OUTPUT_DIR, 'desktop-welcome.png'),
|
||||||
fullPage: false,
|
fullPage: false,
|
||||||
});
|
});
|
||||||
console.log(' Saved: remotion/public/desktop-welcome.png');
|
console.log(' Saved: scripts/remotion/public/desktop-welcome.png');
|
||||||
} finally {
|
} finally {
|
||||||
await context.close();
|
await context.close();
|
||||||
}
|
}
|
||||||
@@ -545,7 +545,7 @@ async function captureDesktopClaude(browser) {
|
|||||||
path: join(OUTPUT_DIR, 'desktop-claude.png'),
|
path: join(OUTPUT_DIR, 'desktop-claude.png'),
|
||||||
fullPage: false,
|
fullPage: false,
|
||||||
});
|
});
|
||||||
console.log(' Saved: remotion/public/desktop-claude.png');
|
console.log(' Saved: scripts/remotion/public/desktop-claude.png');
|
||||||
} finally {
|
} finally {
|
||||||
await context.close();
|
await context.close();
|
||||||
}
|
}
|
||||||
@@ -575,7 +575,7 @@ async function captureDesktopBothClaude(browser) {
|
|||||||
path: join(OUTPUT_DIR, 'desktop-both-claude.png'),
|
path: join(OUTPUT_DIR, 'desktop-both-claude.png'),
|
||||||
fullPage: false,
|
fullPage: false,
|
||||||
});
|
});
|
||||||
console.log(' Saved: remotion/public/desktop-both-claude.png');
|
console.log(' Saved: scripts/remotion/public/desktop-both-claude.png');
|
||||||
} finally {
|
} finally {
|
||||||
await context.close();
|
await context.close();
|
||||||
}
|
}
|
||||||
@@ -605,7 +605,7 @@ async function captureDesktopBothOpencode(browser) {
|
|||||||
path: join(OUTPUT_DIR, 'desktop-both-opencode.png'),
|
path: join(OUTPUT_DIR, 'desktop-both-opencode.png'),
|
||||||
fullPage: false,
|
fullPage: false,
|
||||||
});
|
});
|
||||||
console.log(' Saved: remotion/public/desktop-both-opencode.png');
|
console.log(' Saved: scripts/remotion/public/desktop-both-opencode.png');
|
||||||
} finally {
|
} finally {
|
||||||
await context.close();
|
await context.close();
|
||||||
}
|
}
|
||||||
@@ -634,7 +634,7 @@ async function captureMobileClaude(browser) {
|
|||||||
path: join(OUTPUT_DIR, 'mobile-claude.png'),
|
path: join(OUTPUT_DIR, 'mobile-claude.png'),
|
||||||
fullPage: false,
|
fullPage: false,
|
||||||
});
|
});
|
||||||
console.log(' Saved: remotion/public/mobile-claude.png');
|
console.log(' Saved: scripts/remotion/public/mobile-claude.png');
|
||||||
} finally {
|
} finally {
|
||||||
await context.close();
|
await context.close();
|
||||||
}
|
}
|
||||||
@@ -663,7 +663,7 @@ async function captureMobileOpencode(browser) {
|
|||||||
path: join(OUTPUT_DIR, 'mobile-opencode.png'),
|
path: join(OUTPUT_DIR, 'mobile-opencode.png'),
|
||||||
fullPage: false,
|
fullPage: false,
|
||||||
});
|
});
|
||||||
console.log(' Saved: remotion/public/mobile-opencode.png');
|
console.log(' Saved: scripts/remotion/public/mobile-opencode.png');
|
||||||
} finally {
|
} finally {
|
||||||
await context.close();
|
await context.close();
|
||||||
}
|
}
|
||||||
@@ -706,12 +706,12 @@ async function main() {
|
|||||||
console.log('All 6 screenshots captured!');
|
console.log('All 6 screenshots captured!');
|
||||||
console.log('='.repeat(60));
|
console.log('='.repeat(60));
|
||||||
console.log('\nOutput files:');
|
console.log('\nOutput files:');
|
||||||
console.log(' remotion/public/desktop-welcome.png');
|
console.log(' scripts/remotion/public/desktop-welcome.png');
|
||||||
console.log(' remotion/public/desktop-claude.png');
|
console.log(' scripts/remotion/public/desktop-claude.png');
|
||||||
console.log(' remotion/public/desktop-both-claude.png');
|
console.log(' scripts/remotion/public/desktop-both-claude.png');
|
||||||
console.log(' remotion/public/desktop-both-opencode.png');
|
console.log(' scripts/remotion/public/desktop-both-opencode.png');
|
||||||
console.log(' remotion/public/mobile-claude.png');
|
console.log(' scripts/remotion/public/mobile-claude.png');
|
||||||
console.log(' remotion/public/mobile-opencode.png');
|
console.log(' scripts/remotion/public/mobile-opencode.png');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('\nFatal error:', err.message);
|
console.error('\nFatal error:', err.message);
|
||||||
console.error(err.stack);
|
console.error(err.stack);
|
||||||
|
|||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Fails if package-lock.json's version fields don't match package.json.
|
||||||
|
// Changesets bumps package.json but NOT the lockfile — this catches that drift
|
||||||
|
// (the top-level `version` in lockfiles is metadata, so `npm ci` won't flag it).
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const pkg = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8'));
|
||||||
|
const lock = JSON.parse(readFileSync(resolve(repoRoot, 'package-lock.json'), 'utf8'));
|
||||||
|
|
||||||
|
const expected = pkg.version;
|
||||||
|
const rootVersion = lock.version;
|
||||||
|
const selfVersion = lock.packages?.['']?.version;
|
||||||
|
|
||||||
|
const mismatches = [];
|
||||||
|
if (rootVersion !== expected) mismatches.push(` package-lock.json#.version = ${rootVersion} (expected ${expected})`);
|
||||||
|
if (selfVersion !== expected) mismatches.push(` package-lock.json#.packages[""].version = ${selfVersion} (expected ${expected})`);
|
||||||
|
|
||||||
|
if (mismatches.length > 0) {
|
||||||
|
console.error(`\nLockfile version drift detected (package.json is ${expected}):`);
|
||||||
|
console.error(mismatches.join('\n'));
|
||||||
|
console.error('\nFix: run `npm install --package-lock-only` and commit the updated package-lock.json.\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Lockfile in sync with package.json (${expected}).`);
|
||||||
@@ -0,0 +1,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
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+199
-35
@@ -1,45 +1,209 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Quick Cloudflare Tunnel for Codeman
|
# Cloudflare Tunnel manager for Codeman
|
||||||
# Usage: ./scripts/tunnel.sh [start|stop|status|url]
|
# 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
|
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
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
start)
|
|
||||||
if ! systemctl --user is-active "$SERVICE" &>/dev/null; then
|
_require_cloudflared() {
|
||||||
# Install service if not already
|
if ! command -v cloudflared &>/dev/null; then
|
||||||
if ! systemctl --user cat "$SERVICE" &>/dev/null 2>&1; then
|
echo "Error: cloudflared not found. Install with: yay -S cloudflared" >&2
|
||||||
cp "$(dirname "$0")/codeman-tunnel.service" "$HOME/.config/systemd/user/"
|
exit 1
|
||||||
systemctl --user daemon-reload
|
fi
|
||||||
fi
|
}
|
||||||
systemctl --user start "$SERVICE"
|
|
||||||
echo "Tunnel starting... waiting for URL"
|
_cloudflared_bin() {
|
||||||
sleep 6
|
command -v cloudflared
|
||||||
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)
|
_install_service() {
|
||||||
if [ -n "$URL" ]; then
|
local svc_file="$1"
|
||||||
echo "$URL"
|
local svc_name="$2"
|
||||||
else
|
if ! systemctl --user cat "$svc_name" &>/dev/null 2>&1; then
|
||||||
echo "URL not ready yet, try: $0 url"
|
cp "$(dirname "$0")/$svc_file" "$HOME/.config/systemd/user/"
|
||||||
fi
|
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)
|
named)
|
||||||
systemctl --user stop "$SERVICE"
|
case "$CMD" in
|
||||||
echo "Tunnel stopped"
|
start) _named_start ;;
|
||||||
;;
|
stop) _named_stop ;;
|
||||||
status)
|
status) _named_status ;;
|
||||||
systemctl --user status "$SERVICE" --no-pager 2>&1 | head -10
|
url) echo "https://$TUNNEL_HOSTNAME" ;;
|
||||||
echo ""
|
setup) _named_setup ;;
|
||||||
echo "URL:"
|
enable) _named_enable ;;
|
||||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1
|
disable) _named_disable ;;
|
||||||
;;
|
*) echo "Usage: $0 named [start|stop|status|url|setup|enable|disable]"; exit 1 ;;
|
||||||
url)
|
esac
|
||||||
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1
|
|
||||||
;;
|
;;
|
||||||
|
# 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
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
export type AiIdleCheckConfig = AiCheckerConfigBase;
|
type AiIdleCheckConfig = AiCheckerConfigBase;
|
||||||
|
|
||||||
export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR';
|
export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR';
|
||||||
|
|
||||||
|
|||||||
@@ -40,13 +40,13 @@ import {
|
|||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
export type AiPlanCheckConfig = AiCheckerConfigBase;
|
type AiPlanCheckConfig = AiCheckerConfigBase;
|
||||||
|
|
||||||
export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR';
|
export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR';
|
||||||
|
|
||||||
export type AiPlanCheckResult = AiCheckerResultBase<AiPlanCheckVerdict>;
|
export type AiPlanCheckResult = AiCheckerResultBase<AiPlanCheckVerdict>;
|
||||||
|
|
||||||
export type AiPlanCheckState = AiCheckerStateBase<AiPlanCheckVerdict>;
|
type AiPlanCheckState = AiCheckerStateBase<AiPlanCheckVerdict>;
|
||||||
|
|
||||||
// ========== Constants ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ const LOG_FILE_MENTION_PATTERN = /([/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log
|
|||||||
/**
|
/**
|
||||||
* Events emitted by BashToolParser.
|
* Events emitted by BashToolParser.
|
||||||
*/
|
*/
|
||||||
export interface BashToolParserEvents {
|
interface BashToolParserEvents {
|
||||||
/** New Bash tool with file paths started */
|
/** New Bash tool with file paths started */
|
||||||
toolStart: [tool: ActiveBashTool];
|
toolStart: [tool: ActiveBashTool];
|
||||||
/** Bash tool completed */
|
/** Bash tool completed */
|
||||||
@@ -111,7 +111,7 @@ export interface BashToolParserEvents {
|
|||||||
/**
|
/**
|
||||||
* Configuration options for BashToolParser.
|
* Configuration options for BashToolParser.
|
||||||
*/
|
*/
|
||||||
export interface BashToolParserConfig {
|
interface BashToolParserConfig {
|
||||||
/** Session ID this parser belongs to */
|
/** Session ID this parser belongs to */
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
/** Whether the parser is enabled (default: true) */
|
/** Whether the parser is enabled (default: true) */
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const STREAM_INACTIVITY_TIMEOUT_MS = INACTIVITY_TIMEOUT_MS;
|
|||||||
/**
|
/**
|
||||||
* Represents an active file stream.
|
* Represents an active file stream.
|
||||||
*/
|
*/
|
||||||
export interface FileStream {
|
interface FileStream {
|
||||||
/** Unique stream identifier */
|
/** Unique stream identifier */
|
||||||
id: string;
|
id: string;
|
||||||
/** Session this stream belongs to */
|
/** Session this stream belongs to */
|
||||||
@@ -74,7 +74,7 @@ export interface FileStream {
|
|||||||
/**
|
/**
|
||||||
* Options for creating a file stream.
|
* Options for creating a file stream.
|
||||||
*/
|
*/
|
||||||
export interface CreateStreamOptions {
|
interface CreateStreamOptions {
|
||||||
/** Session ID requesting the stream */
|
/** Session ID requesting the stream */
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
/** Path to the file to stream */
|
/** Path to the file to stream */
|
||||||
@@ -94,7 +94,7 @@ export interface CreateStreamOptions {
|
|||||||
/**
|
/**
|
||||||
* Result of creating a stream.
|
* Result of creating a stream.
|
||||||
*/
|
*/
|
||||||
export interface CreateStreamResult {
|
interface CreateStreamResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
streamId?: string;
|
streamId?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
@@ -17,11 +17,6 @@ import { KeyedDebouncer } from './utils/index.js';
|
|||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
export interface ImageWatcherEvents {
|
|
||||||
'image:detected': (event: ImageDetectedEvent) => void;
|
|
||||||
'image:error': (error: Error, sessionId?: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Constants ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
/** Supported image file extensions (lowercase) */
|
/** Supported image file extensions (lowercase) */
|
||||||
|
|||||||
@@ -60,21 +60,6 @@ const POST_PHASE_DELAY_MS = 1000;
|
|||||||
// Events
|
// 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
|
// OrchestratorLoop
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export interface DetailedPlanResult {
|
|||||||
|
|
||||||
export type ProgressCallback = (phase: string, detail: string) => void;
|
export type ProgressCallback = (phase: string, detail: string) => void;
|
||||||
|
|
||||||
export interface PlanSubagentEvent {
|
interface PlanSubagentEvent {
|
||||||
type: 'started' | 'progress' | 'completed' | 'failed';
|
type: 'started' | 'progress' | 'completed' | 'failed';
|
||||||
agentId: string;
|
agentId: string;
|
||||||
agentType: 'research' | 'planner';
|
agentType: 'research' | 'planner';
|
||||||
@@ -80,7 +80,7 @@ export interface PlanSubagentEvent {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SubagentCallback = (event: PlanSubagentEvent) => void;
|
type SubagentCallback = (event: PlanSubagentEvent) => void;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// JSON Repair Helper
|
// JSON Repair Helper
|
||||||
|
|||||||
@@ -7,10 +7,4 @@
|
|||||||
|
|
||||||
export { RESEARCH_AGENT_PROMPT } from './research-agent.js';
|
export { RESEARCH_AGENT_PROMPT } from './research-agent.js';
|
||||||
export { PLANNER_PROMPT } from './planner.js';
|
export { PLANNER_PROMPT } from './planner.js';
|
||||||
export {
|
export { PHASE_EXECUTION_PROMPT, TEAM_LEAD_PROMPT, REPLAN_PROMPT, SINGLE_TASK_PROMPT } from './orchestrator.js';
|
||||||
PHASE_EXECUTION_PROMPT,
|
|
||||||
TEAM_LEAD_PROMPT,
|
|
||||||
VERIFICATION_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.
|
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>`;
|
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.
|
* Replan prompt — gives failure context and asks for recovery.
|
||||||
*
|
*
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ const YAML_LINE_PATTERN = /^([a-zA-Z_-]+):\s*"?([^"\n]+)"?\s*$/gm;
|
|||||||
/**
|
/**
|
||||||
* Ralph Loop configuration from .claude/ralph-loop.local.md
|
* Ralph Loop configuration from .claude/ralph-loop.local.md
|
||||||
*/
|
*/
|
||||||
export interface RalphLoopConfig {
|
interface RalphLoopConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
iteration: number;
|
iteration: number;
|
||||||
maxIterations: number | null;
|
maxIterations: number | null;
|
||||||
|
|||||||
+1
-9
@@ -34,19 +34,11 @@ import { RalphLoopStatus, getErrorMessage } from './types.js';
|
|||||||
/**
|
/**
|
||||||
* Events emitted by RalphLoop
|
* 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
|
* Configuration options for RalphLoop
|
||||||
*/
|
*/
|
||||||
export interface RalphLoopOptions {
|
interface RalphLoopOptions {
|
||||||
/** How often to check for new tasks (default from config) */
|
/** How often to check for new tasks (default from config) */
|
||||||
pollIntervalMs?: number;
|
pollIntervalMs?: number;
|
||||||
/** Minimum time to run before stopping (null = no minimum) */
|
/** Minimum time to run before stopping (null = no minimum) */
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
* Key exports:
|
* Key exports:
|
||||||
* - `RalphTracker` class — main tracker, extends EventEmitter
|
* - `RalphTracker` class — main tracker, extends EventEmitter
|
||||||
* - `RalphTrackerEvents` interface — typed event map
|
* - `RalphTrackerEvents` interface — typed event map
|
||||||
* - Re-exports: `EnhancedPlanTask`, `CheckpointReview` from ralph-plan-tracker
|
|
||||||
*
|
*
|
||||||
* Key methods: `processData(data)` — feed terminal output, `getState()`,
|
* Key methods: `processData(data)` — feed terminal output, `getState()`,
|
||||||
* `getTodos()`, `getCompletionHistory()`, `getPlanTasks()`, `reset()`
|
* `getTodos()`, `getCompletionHistory()`, `getPlanTasks()`, `reset()`
|
||||||
@@ -66,9 +65,6 @@ import { RalphStallDetector } from './ralph-stall-detector.js';
|
|||||||
import { RalphStatusParser } from './ralph-status-parser.js';
|
import { RalphStatusParser } from './ralph-status-parser.js';
|
||||||
import { STALE_DATA_MAX_AGE_MS, INACTIVITY_TIMEOUT_MS } from './config/server-timing.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 ==========
|
// ========== Configuration Constants ==========
|
||||||
// Note: MAX_TODOS_PER_SESSION and MAX_LINE_BUFFER_SIZE are imported from config modules
|
// 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 circuitBreakerUpdate - Fired when circuit breaker state changes
|
||||||
* @event exitGateMet - Fired when dual-condition exit gate is met
|
* @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
|
* RalphTracker - Parses terminal output to detect Ralph Wiggum loops and todos
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
* - `RespawnController` class — state machine, extends EventEmitter
|
* - `RespawnController` class — state machine, extends EventEmitter
|
||||||
* - `RespawnConfig` interface — all configuration options
|
* - `RespawnConfig` interface — all configuration options
|
||||||
* - `RespawnState` type — union of all state machine states
|
* - `RespawnState` type — union of all state machine states
|
||||||
* - `DetectionStatus`, `ActiveTimerInfo`, `RespawnEvents` — status/event types
|
* - `DetectionStatus`, `ActiveTimerInfo` — status types
|
||||||
*
|
*
|
||||||
* Key methods: `start()`, `stop()`, `getStatus()`, `getConfig()`,
|
* Key methods: `start()`, `stop()`, `getStatus()`, `getConfig()`,
|
||||||
* `getDetectionStatus()`, `getActiveTimers()`, `getAggregateMetrics()`,
|
* `getDetectionStatus()`, `getActiveTimers()`, `getAggregateMetrics()`,
|
||||||
@@ -46,8 +46,8 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Session } from './session.js';
|
import { Session } from './session.js';
|
||||||
import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js';
|
import { AiIdleChecker, type AiCheckState } from './ai-idle-checker.js';
|
||||||
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
|
import { AiPlanChecker } from './ai-plan-checker.js';
|
||||||
import type { TeamWatcher } from './team-watcher.js';
|
import type { TeamWatcher } from './team-watcher.js';
|
||||||
import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.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';
|
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.
|
* Each layer provides a confidence signal that Claude has finished working.
|
||||||
*/
|
*/
|
||||||
/** Active timer info for UI display */
|
/** Active timer info for UI display */
|
||||||
export interface ActiveTimerInfo {
|
interface ActiveTimerInfo {
|
||||||
name: string;
|
name: string;
|
||||||
remainingMs: number;
|
remainingMs: number;
|
||||||
totalMs: number;
|
totalMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DetectionStatus {
|
interface DetectionStatus {
|
||||||
/** Layer 0: Stop hook received (highest priority - definitive signal) */
|
/** Layer 0: Stop hook received (highest priority - definitive signal) */
|
||||||
stopHookReceived: boolean;
|
stopHookReceived: boolean;
|
||||||
/** Timestamp when Stop hook was received */
|
/** Timestamp when Stop hook was received */
|
||||||
@@ -488,70 +488,13 @@ export interface RespawnConfig {
|
|||||||
* @event error - Fired on errors
|
* @event error - Fired on errors
|
||||||
* @event log - Fired for debug logging
|
* @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 */
|
/** Action log entry for detailed UI feedback */
|
||||||
export interface ActionLogEntry {
|
interface ActionLogEntry {
|
||||||
type: string;
|
type: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
timestamp: number;
|
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.
|
* 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).
|
* Rounds up so that e.g. 1200 ms shows as 2 s (never under-reports remaining time).
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ const DEFAULT_AUTO_COMPACT_THRESHOLD = 110_000;
|
|||||||
/**
|
/**
|
||||||
* Callbacks required by SessionAutoOps to interact with the parent Session.
|
* Callbacks required by SessionAutoOps to interact with the parent Session.
|
||||||
*/
|
*/
|
||||||
export interface AutoOpsCallbacks {
|
interface AutoOpsCallbacks {
|
||||||
/** Send a command via the terminal multiplexer */
|
/** Send a command via the terminal multiplexer */
|
||||||
writeCommand: (command: string) => Promise<boolean>;
|
writeCommand: (command: string) => Promise<boolean>;
|
||||||
/** Check if Claude is currently working */
|
/** Check if Claude is currently working */
|
||||||
@@ -109,12 +109,6 @@ export interface AutoOpsCallbacks {
|
|||||||
/**
|
/**
|
||||||
* Events emitted by SessionAutoOps.
|
* 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.
|
* 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.
|
* Build Claude CLI permission flags based on the configured mode.
|
||||||
* Returns an array of args to pass to the CLI.
|
* 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) {
|
switch (claudeMode) {
|
||||||
case 'dangerously-skip-permissions':
|
case 'dangerously-skip-permissions':
|
||||||
return ['--dangerously-skip-permissions'];
|
return ['--dangerously-skip-permissions'];
|
||||||
|
|||||||
@@ -30,18 +30,6 @@ import { SessionState } from './types.js';
|
|||||||
/**
|
/**
|
||||||
* Events emitted by SessionManager
|
* 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.
|
* Manages multiple Claude sessions with lifecycle coordination.
|
||||||
|
|||||||
+5
-57
@@ -154,63 +154,6 @@ export interface ClaudeMessage {
|
|||||||
* Event signatures emitted by the Session class.
|
* Event signatures emitted by the Session class.
|
||||||
* Subscribe using `session.on('eventName', handler)`.
|
* 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.
|
* Core session class that wraps a PTY process running Claude CLI or a shell.
|
||||||
@@ -530,6 +473,11 @@ export class Session extends EventEmitter {
|
|||||||
return this._claudeSessionId;
|
return this._claudeSessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The tmux session name, if the session is running inside a mux */
|
||||||
|
get muxName(): string | null {
|
||||||
|
return this._muxSession?.muxName ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
get totalCost(): number {
|
get totalCost(): number {
|
||||||
return this._totalCost;
|
return this._totalCost;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,17 +134,6 @@ export interface SubagentToolResult {
|
|||||||
isError: boolean; // Whether result is an error
|
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 ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude/projects');
|
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude/projects');
|
||||||
|
|||||||
@@ -17,14 +17,6 @@ import { getStore } from './state-store.js';
|
|||||||
/**
|
/**
|
||||||
* Events emitted by TaskQueue
|
* 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.
|
* Priority queue for managing tasks with dependency support.
|
||||||
|
|||||||
@@ -153,16 +153,6 @@ export interface BackgroundTask {
|
|||||||
* @event taskCompleted - Task finished successfully
|
* @event taskCompleted - Task finished successfully
|
||||||
* @event taskFailed - Task finished with error
|
* @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.
|
* TaskTracker - Detects and tracks background tasks in Claude Code sessions.
|
||||||
|
|||||||
+2
-2
@@ -185,8 +185,8 @@ function buildSpawnCommand(options: {
|
|||||||
}): string {
|
}): string {
|
||||||
if (options.mode === 'claude') {
|
if (options.mode === 'claude') {
|
||||||
// Validate model to prevent command injection
|
// Validate model to prevent command injection
|
||||||
const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
|
const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
|
||||||
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
|
const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
|
||||||
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
|
// 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.),
|
// 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.
|
// fall back to a new session with --session-id so the pane doesn't die.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { createInterface } from 'node:readline';
|
|||||||
/**
|
/**
|
||||||
* Parsed transcript entry from the JSONL file
|
* Parsed transcript entry from the JSONL file
|
||||||
*/
|
*/
|
||||||
export interface TranscriptEntry {
|
interface TranscriptEntry {
|
||||||
type: 'user' | 'assistant' | 'system' | 'result';
|
type: 'user' | 'assistant' | 'system' | 'result';
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
message?: {
|
message?: {
|
||||||
@@ -35,7 +35,7 @@ export interface TranscriptEntry {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscriptContentBlock {
|
interface TranscriptContentBlock {
|
||||||
type: 'text' | 'tool_use' | 'tool_result';
|
type: 'text' | 'tool_use' | 'tool_result';
|
||||||
text?: string;
|
text?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -68,15 +68,6 @@ export interface TranscriptState {
|
|||||||
lastUpdateAt: string | null;
|
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 ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
/** How often to check for new content when file watching fails */
|
/** 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 ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
export interface TunnelStatus {
|
interface TunnelStatus {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,18 +268,3 @@ export function createInitialOrchestratorStats(): OrchestratorStats {
|
|||||||
replanCount: 0,
|
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
|
* 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.
|
* Persisted respawn configuration for mux sessions.
|
||||||
|
|||||||
@@ -150,11 +150,3 @@ export interface SessionState {
|
|||||||
/**
|
/**
|
||||||
* Output captured from a session
|
* 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.
|
* Options for setTimeout/setInterval with automatic cleanup.
|
||||||
*/
|
*/
|
||||||
export interface TimerOptions {
|
interface TimerOptions {
|
||||||
/** Human-readable description for debugging */
|
/** Human-readable description for debugging */
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-6
@@ -7,17 +7,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { BufferAccumulator } from './buffer-accumulator.js';
|
export { BufferAccumulator } from './buffer-accumulator.js';
|
||||||
export { LRUMap, type LRUMapOptions } from './lru-map.js';
|
export { CleanupManager } from './cleanup-manager.js';
|
||||||
export { CleanupManager, type TimerOptions } from './cleanup-manager.js';
|
|
||||||
export { Debouncer, KeyedDebouncer } from './debouncer.js';
|
export { Debouncer, KeyedDebouncer } from './debouncer.js';
|
||||||
export { StaleExpirationMap, type StaleExpirationMapOptions } from './stale-expiration-map.js';
|
export { StaleExpirationMap } from './stale-expiration-map.js';
|
||||||
export {
|
export {
|
||||||
ANSI_ESCAPE_PATTERN_FULL,
|
ANSI_ESCAPE_PATTERN_FULL,
|
||||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||||
TOKEN_PATTERN,
|
TOKEN_PATTERN,
|
||||||
SPINNER_PATTERN,
|
SPINNER_PATTERN,
|
||||||
createAnsiPatternFull,
|
|
||||||
createAnsiPatternSimple,
|
|
||||||
stripAnsi,
|
stripAnsi,
|
||||||
SAFE_PATH_PATTERN,
|
SAFE_PATH_PATTERN,
|
||||||
execPattern,
|
execPattern,
|
||||||
@@ -27,4 +24,4 @@ export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-si
|
|||||||
export { assertNever } from './type-safety.js';
|
export { assertNever } from './type-safety.js';
|
||||||
export { wrapWithNice } from './nice-wrapper.js';
|
export { wrapWithNice } from './nice-wrapper.js';
|
||||||
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.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.
|
* Configuration options for LRUMap.
|
||||||
*/
|
*/
|
||||||
export interface LRUMapOptions<K, V> {
|
interface LRUMapOptions<K, V> {
|
||||||
/** Maximum number of entries before eviction */
|
/** Maximum number of entries before eviction */
|
||||||
maxSize: number;
|
maxSize: number;
|
||||||
/** Optional callback when an entry is evicted */
|
/** 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/;
|
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.
|
* Strips ANSI escape codes from text using the comprehensive pattern.
|
||||||
* @param text - Text containing ANSI escape codes
|
* @param text - Text containing ANSI escape codes
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ interface TimedEntry<V> {
|
|||||||
/**
|
/**
|
||||||
* Configuration options for StaleExpirationMap.
|
* Configuration options for StaleExpirationMap.
|
||||||
*/
|
*/
|
||||||
export interface StaleExpirationMapOptions<K, V> {
|
interface StaleExpirationMapOptions<K, V> {
|
||||||
/** Time-to-live in milliseconds before entries expire */
|
/** Time-to-live in milliseconds before entries expire */
|
||||||
ttlMs: number;
|
ttlMs: number;
|
||||||
/** How often to run cleanup (default: ttlMs / 2) */
|
/** How often to run cleanup (default: ttlMs / 2) */
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
export const AUTH_COOKIE_NAME = 'codeman_session';
|
export const AUTH_COOKIE_NAME = 'codeman_session';
|
||||||
|
|
||||||
/** State returned from registerAuthMiddleware for cleanup in server stop() */
|
/** State returned from registerAuthMiddleware for cleanup in server stop() */
|
||||||
export interface AuthState {
|
interface AuthState {
|
||||||
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
|
authSessions: StaleExpirationMap<string, AuthSessionRecord> | null;
|
||||||
authFailures: StaleExpirationMap<string, number> | null;
|
authFailures: StaleExpirationMap<string, number> | null;
|
||||||
qrAuthFailures: 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 { RespawnPort } from './respawn-port.js';
|
||||||
export type { ConfigPort } from './config-port.js';
|
export type { ConfigPort } from './config-port.js';
|
||||||
export type { InfraPort, ScheduledRun } from './infra-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';
|
export type { OrchestratorPort } from './orchestrator-port.js';
|
||||||
+218
-4
@@ -249,8 +249,28 @@ const _SSE_HANDLER_MAP = [
|
|||||||
[SSE_EVENTS.ORCHESTRATOR_TASK_FAILED, '_onOrchestratorTaskFailed'],
|
[SSE_EVENTS.ORCHESTRATOR_TASK_FAILED, '_onOrchestratorTaskFailed'],
|
||||||
[SSE_EVENTS.ORCHESTRATOR_COMPLETED, '_onOrchestratorCompleted'],
|
[SSE_EVENTS.ORCHESTRATOR_COMPLETED, '_onOrchestratorCompleted'],
|
||||||
[SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'],
|
[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
|
// CodemanApp Class — constructor and global state
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -615,15 +635,15 @@ class CodemanApp {
|
|||||||
// shift? (require Shift), action }.
|
// shift? (require Shift), action }.
|
||||||
const SHORTCUTS = [
|
const SHORTCUTS = [
|
||||||
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
|
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
|
||||||
{ key: 'Enter', ctrl: true, action: () => this.quickStart() },
|
|
||||||
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
|
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
|
||||||
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
|
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
|
||||||
{ key: 'k', ctrl: true, action: () => this.killAllSessions() },
|
|
||||||
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
|
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
|
||||||
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
|
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
|
||||||
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
|
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
|
||||||
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
|
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
|
||||||
{ key: 'V', ctrl: true, shift: true, action: () => VoiceInput.toggle() },
|
{ 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
|
// Use capture to handle before terminal
|
||||||
@@ -637,6 +657,16 @@ class CodemanApp {
|
|||||||
this.closeHelp();
|
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
|
// Match against shortcut table
|
||||||
for (const s of SHORTCUTS) {
|
for (const s of SHORTCUTS) {
|
||||||
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
|
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
|
||||||
@@ -869,6 +899,153 @@ 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;
|
||||||
|
// Remove dangerous elements
|
||||||
|
for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) {
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
// Strip dangerous attributes from all elements
|
||||||
|
for (const el of frag.querySelectorAll('*')) {
|
||||||
|
for (const attr of [...el.attributes]) {
|
||||||
|
const name = attr.name.toLowerCase();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Serialize back via a container
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.appendChild(frag);
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
|
||||||
|
_renderMarkdown(text) {
|
||||||
|
if (typeof marked !== 'undefined' && marked.parse) {
|
||||||
|
try {
|
||||||
|
return this._sanitizeHtml(marked.parse(text, { breaks: true, gfm: true }));
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
// Fallback: escape HTML and preserve whitespace
|
||||||
|
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleResponseViewer() {
|
||||||
|
const viewer = document.getElementById('responseViewer');
|
||||||
|
const backdrop = document.getElementById('responseViewerBackdrop');
|
||||||
|
if (!viewer) return;
|
||||||
|
|
||||||
|
const isOpen = viewer.classList.contains('visible');
|
||||||
|
if (isOpen) {
|
||||||
|
viewer.classList.remove('visible');
|
||||||
|
backdrop.classList.remove('visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.activeSessionId) return;
|
||||||
|
try {
|
||||||
|
// Source 1: Transcript JSONL (best quality — clean structured text from Claude)
|
||||||
|
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response`);
|
||||||
|
const data = await res.json();
|
||||||
|
let lastResponse = data.text || '';
|
||||||
|
|
||||||
|
// Source 2: Terminal buffer fallback (strip ANSI codes)
|
||||||
|
if (!lastResponse) {
|
||||||
|
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
|
||||||
|
const termData = await termRes.json();
|
||||||
|
if (termData.terminalBuffer) {
|
||||||
|
lastResponse = termData.terminalBuffer
|
||||||
|
.replace(/\x1b\[\?[0-9;]*[a-zA-Z]/g, '')
|
||||||
|
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
||||||
|
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||||||
|
.replace(/\x1b[()][A-Z0-9]/g, '')
|
||||||
|
.replace(/\x1b[>=<]/g, '')
|
||||||
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||||
|
.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||||
|
.replace(/[ \t]+$/gm, '')
|
||||||
|
.replace(/\n{4,}/g, '\n\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = document.getElementById('responseViewerBody');
|
||||||
|
body.innerHTML = this._renderMarkdown(lastResponse);
|
||||||
|
|
||||||
|
// Reset state for fresh open
|
||||||
|
const title = document.getElementById('responseViewerTitle');
|
||||||
|
const moreBtn = document.getElementById('responseViewerMore');
|
||||||
|
if (title) title.textContent = 'Last Response';
|
||||||
|
if (moreBtn) { moreBtn.style.display = ''; moreBtn.textContent = 'More'; }
|
||||||
|
|
||||||
|
viewer.classList.add('visible');
|
||||||
|
backdrop.classList.add('visible');
|
||||||
|
body.scrollTop = 0;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load response:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadFullContext() {
|
||||||
|
if (!this.activeSessionId) return;
|
||||||
|
const moreBtn = document.getElementById('responseViewerMore');
|
||||||
|
if (moreBtn) moreBtn.textContent = '...';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response?context=full`);
|
||||||
|
const data = await res.json();
|
||||||
|
const messages = data.messages || [];
|
||||||
|
const body = document.getElementById('responseViewerBody');
|
||||||
|
const title = document.getElementById('responseViewerTitle');
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
body.textContent = 'No conversation history available';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render conversation thread
|
||||||
|
body.innerHTML = '';
|
||||||
|
for (const msg of messages) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'rv-message';
|
||||||
|
|
||||||
|
const role = document.createElement('div');
|
||||||
|
role.className = 'rv-role ' + (msg.role === 'user' ? 'rv-role-user' : 'rv-role-assistant');
|
||||||
|
role.textContent = msg.role === 'user' ? 'You' : 'Claude';
|
||||||
|
div.appendChild(role);
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'rv-text';
|
||||||
|
text.innerHTML = this._renderMarkdown(msg.text);
|
||||||
|
div.appendChild(text);
|
||||||
|
|
||||||
|
body.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title) title.textContent = `Conversation (${messages.length} messages)`;
|
||||||
|
if (moreBtn) moreBtn.style.display = 'none';
|
||||||
|
// Scroll to bottom (latest message)
|
||||||
|
body.scrollTop = body.scrollHeight;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load context:', err);
|
||||||
|
} finally {
|
||||||
|
if (moreBtn) moreBtn.textContent = 'More';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async _onSessionNeedsRefresh() {
|
async _onSessionNeedsRefresh() {
|
||||||
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
// Server sends this after SSE backpressure clears — terminal data was dropped,
|
||||||
// so reload the buffer to recover from any display corruption.
|
// so reload the buffer to recover from any display corruption.
|
||||||
@@ -1585,6 +1762,17 @@ class CodemanApp {
|
|||||||
else if (wantIdle && !hasIdle) { tab.classList.add('tab-alert-idle'); tab.classList.remove('tab-alert-action'); }
|
else if (wantIdle && !hasIdle) { tab.classList.add('tab-alert-idle'); tab.classList.remove('tab-alert-action'); }
|
||||||
else if (!alertType && (hasAction || hasIdle)) { tab.classList.remove('tab-alert-action', 'tab-alert-idle'); }
|
else if (!alertType && (hasAction || hasIdle)) { tab.classList.remove('tab-alert-action', 'tab-alert-idle'); }
|
||||||
|
|
||||||
|
// Inject tab-number badge if missing (added after initial render)
|
||||||
|
if (!tab.querySelector('.tab-number')) {
|
||||||
|
const idx = this.sessionOrder.indexOf(id);
|
||||||
|
if (idx >= 0 && idx < 9) {
|
||||||
|
const numSpan = document.createElement('span');
|
||||||
|
numSpan.className = 'tab-number';
|
||||||
|
numSpan.textContent = String(idx + 1);
|
||||||
|
tab.insertBefore(numSpan, tab.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update status indicator
|
// Update status indicator
|
||||||
const statusEl = tab.querySelector('.tab-status');
|
const statusEl = tab.querySelector('.tab-status');
|
||||||
if (statusEl && !statusEl.classList.contains(status)) {
|
if (statusEl && !statusEl.classList.contains(status)) {
|
||||||
@@ -1594,7 +1782,12 @@ class CodemanApp {
|
|||||||
// Update name if changed
|
// Update name if changed
|
||||||
const nameEl = tab.querySelector('.tab-name');
|
const nameEl = tab.querySelector('.tab-name');
|
||||||
if (nameEl && nameEl.textContent !== name) {
|
if (nameEl && nameEl.textContent !== name) {
|
||||||
nameEl.textContent = name;
|
const _p = parseSessionPrefix(name);
|
||||||
|
if (_p && _p.suffix) {
|
||||||
|
nameEl.innerHTML = '<span class="tab-prefix">' + escapeHtml(_p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(_p.suffix) + '</span>';
|
||||||
|
} else {
|
||||||
|
nameEl.textContent = name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update task badge
|
// Update task badge
|
||||||
@@ -1672,6 +1865,7 @@ class CodemanApp {
|
|||||||
// Reorder to put active tab first
|
// Reorder to put active tab first
|
||||||
tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)];
|
tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)];
|
||||||
}
|
}
|
||||||
|
let _tabIdx = 0;
|
||||||
for (const id of tabOrder) {
|
for (const id of tabOrder) {
|
||||||
const session = this.sessions.get(id);
|
const session = this.sessions.get(id);
|
||||||
if (!session) continue; // Skip if session was removed
|
if (!session) continue; // Skip if session was removed
|
||||||
@@ -1698,11 +1892,12 @@ class CodemanApp {
|
|||||||
|
|
||||||
const endedAttr = session._ended ? ' data-ended="1"' : '';
|
const endedAttr = session._ended ? ' data-ended="1"' : '';
|
||||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}"${endedAttr} onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}"${endedAttr} onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||||
|
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||||
<span class="tab-info">
|
<span class="tab-info">
|
||||||
<span class="tab-name-row">
|
<span class="tab-name-row">
|
||||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
|
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
|
||||||
<span class="tab-name" data-session-id="${id}">${escapeHtml(name)}</span>
|
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
|
||||||
</span>
|
</span>
|
||||||
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
|
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
|
||||||
</span>
|
</span>
|
||||||
@@ -1711,6 +1906,7 @@ class CodemanApp {
|
|||||||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
||||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
||||||
</div>`);
|
</div>`);
|
||||||
|
_tabIdx++;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = parts.join('');
|
container.innerHTML = parts.join('');
|
||||||
@@ -1929,6 +2125,24 @@ class CodemanApp {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
moveActiveTabLeft() {
|
||||||
|
if (!this.activeSessionId) return;
|
||||||
|
const idx = this.sessionOrder.indexOf(this.activeSessionId);
|
||||||
|
if (idx <= 0) return;
|
||||||
|
[this.sessionOrder[idx - 1], this.sessionOrder[idx]] = [this.sessionOrder[idx], this.sessionOrder[idx - 1]];
|
||||||
|
this.saveSessionOrder();
|
||||||
|
this._fullRenderSessionTabs();
|
||||||
|
}
|
||||||
|
|
||||||
|
moveActiveTabRight() {
|
||||||
|
if (!this.activeSessionId) return;
|
||||||
|
const idx = this.sessionOrder.indexOf(this.activeSessionId);
|
||||||
|
if (idx === -1 || idx >= this.sessionOrder.length - 1) return;
|
||||||
|
[this.sessionOrder[idx], this.sessionOrder[idx + 1]] = [this.sessionOrder[idx + 1], this.sessionOrder[idx]];
|
||||||
|
this.saveSessionOrder();
|
||||||
|
this._fullRenderSessionTabs();
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Session Lifecycle — select, close, navigate
|
// Session Lifecycle — select, close, navigate
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -292,6 +292,9 @@ const SSE_EVENTS = {
|
|||||||
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
|
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
|
||||||
ORCHESTRATOR_ERROR: 'orchestrator:error',
|
ORCHESTRATOR_ERROR: 'orchestrator:error',
|
||||||
|
|
||||||
|
// Clipboard
|
||||||
|
CLIPBOARD_WRITE: 'clipboard:write',
|
||||||
|
|
||||||
// Cases
|
// Cases
|
||||||
CASE_CREATED: 'case:created',
|
CASE_CREATED: 'case:created',
|
||||||
CASE_LINKED: 'case:linked',
|
CASE_LINKED: 'case:linked',
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -6,6 +6,9 @@
|
|||||||
<meta name="description" content="Claude Code session manager with web interface">
|
<meta name="description" content="Claude Code session manager with web interface">
|
||||||
<meta name="theme-color" content="#0a0a0a">
|
<meta name="theme-color" content="#0a0a0a">
|
||||||
<meta name="google" content="notranslate">
|
<meta name="google" content="notranslate">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="apple-touch-icon" href="icon-192.png">
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
<title>Codeman</title>
|
<title>Codeman</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%2360a5fa'/%3E%3Cstop offset='100%25' stop-color='%233b82f6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='6' fill='%230a0a0a'/%3E%3Cpath d='M18 4L8 18h6l-2 10 10-14h-6z' fill='url(%23g)'/%3E%3C/svg%3E">
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%2360a5fa'/%3E%3Cstop offset='100%25' stop-color='%233b82f6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='6' fill='%230a0a0a'/%3E%3Cpath d='M18 4L8 18h6l-2 10 10-14h-6z' fill='url(%23g)'/%3E%3C/svg%3E">
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
|
<!-- WebGL addon lazy-loaded by app.js on desktop only (skipped on mobile, saving 244KB) -->
|
||||||
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
||||||
<script defer src="vendor/xterm-zerolag-input.js"></script>
|
<script defer src="vendor/xterm-zerolag-input.js"></script>
|
||||||
|
<script defer src="vendor/marked.min.js"></script>
|
||||||
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
|
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
|
||||||
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
|
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
|
||||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||||
@@ -92,6 +96,7 @@
|
|||||||
<span class="stat-value" id="statMem">--</span>
|
<span class="stat-value" id="statMem">--</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn-icon-header btn-response-viewer-header" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications">
|
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||||
@@ -331,6 +336,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Response Viewer (mobile: native-scroll overlay for reading full Claude responses) -->
|
||||||
|
<div class="response-viewer" id="responseViewer">
|
||||||
|
<div class="response-viewer-header">
|
||||||
|
<span id="responseViewerTitle">Last Response</span>
|
||||||
|
<div class="response-viewer-actions">
|
||||||
|
<button class="response-viewer-more" id="responseViewerMore" onclick="app.loadFullContext()">More</button>
|
||||||
|
<button class="response-viewer-close" onclick="app.toggleResponseViewer()">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="response-viewer-body" id="responseViewerBody"></div>
|
||||||
|
</div>
|
||||||
|
<div class="response-viewer-backdrop" id="responseViewerBackdrop" onclick="app.toggleResponseViewer()"></div>
|
||||||
|
|
||||||
<!-- Bottom Toolbar -->
|
<!-- Bottom Toolbar -->
|
||||||
<footer class="toolbar">
|
<footer class="toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
@@ -459,10 +477,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="shortcuts-grid">
|
<div class="shortcuts-grid">
|
||||||
<div><kbd>Ctrl</kbd>+<kbd>Enter</kbd></div><div>Run Claude</div>
|
|
||||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
||||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||||
<div><kbd>Ctrl</kbd>+<kbd>K</kbd></div><div>Kill All Sessions + Tmux</div>
|
<div><kbd>Alt</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
|
||||||
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
|
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
|
||||||
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
|
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
|
||||||
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
|
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
|
||||||
@@ -659,7 +676,10 @@
|
|||||||
<div class="form-section-header">Appearance</div>
|
<div class="form-section-header">Appearance</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Session Name</label>
|
<label>Session Name</label>
|
||||||
<input type="text" id="modalSessionName" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()">
|
<div style="display: flex; align-items: center; gap: 4px;">
|
||||||
|
<span id="modalSessionPrefix" style="color: var(--text-muted); white-space: nowrap; display: none;"></span>
|
||||||
|
<input type="text" id="modalSessionName" maxlength="128" placeholder="Auto (directory name)" onblur="app.saveSessionName()" style="flex: 1;">
|
||||||
|
</div>
|
||||||
<span class="form-hint">Custom name shown in the tab (right-click tab to rename inline)</span>
|
<span class="form-hint">Custom name shown in the tab (right-click tab to rename inline)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "Codeman",
|
"name": "Codeman",
|
||||||
"short_name": "Codeman",
|
"short_name": "Codeman",
|
||||||
|
"description": "Claude Code session manager",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
"background_color": "#0a0a0a",
|
"background_color": "#0a0a0a",
|
||||||
"theme_color": "#0a0a0a"
|
"theme_color": "#0a0a0a",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -301,9 +301,15 @@ html.mobile-init .file-browser-panel {
|
|||||||
Phone Breakpoint (<430px)
|
Phone Breakpoint (<430px)
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
@media (max-width: 430px) {
|
@media (max-width: 430px) {
|
||||||
/* Hide header brand on phones */
|
/* Compact header brand on phones — acts as home button */
|
||||||
.header-brand {
|
.header-brand {
|
||||||
display: none;
|
padding-right: 0.25rem;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-brand .logo {
|
||||||
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Font controls - compact on phones, visibility controlled by JS */
|
/* Font controls - compact on phones, visibility controlled by JS */
|
||||||
@@ -1189,6 +1195,20 @@ html.mobile-init .file-browser-panel {
|
|||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Response viewer — show eye icon in header on mobile */
|
||||||
|
.btn-response-viewer-header {
|
||||||
|
display: inline-flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-viewer {
|
||||||
|
padding-bottom: var(--safe-area-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-viewer-body {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Compact welcome overlay for mobile */
|
/* Compact welcome overlay for mobile */
|
||||||
.welcome-content {
|
.welcome-content {
|
||||||
max-width: calc(100vw - 1.5rem);
|
max-width: calc(100vw - 1.5rem);
|
||||||
|
|||||||
@@ -2318,12 +2318,17 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
||||||
|
|
||||||
|
const downloadBtn = !isDir
|
||||||
|
? `<a class="file-tree-download" href="/api/sessions/${this.activeSessionId}/file-raw?path=${encodeURIComponent(node.path)}&download=true" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||||
|
: '';
|
||||||
|
|
||||||
html.push(`
|
html.push(`
|
||||||
<div class="file-tree-item${hiddenClass}" data-path="${escapeHtml(node.path)}" data-type="${node.type}" data-depth="${depth}">
|
<div class="file-tree-item${hiddenClass}" data-path="${escapeHtml(node.path)}" data-type="${node.type}" data-depth="${depth}">
|
||||||
${expandIcon}
|
${expandIcon}
|
||||||
<span class="file-tree-icon">${icon}</span>
|
<span class="file-tree-icon">${icon}</span>
|
||||||
<span class="${nameClass}">${escapeHtml(node.name)}</span>
|
<span class="${nameClass}">${escapeHtml(node.name)}</span>
|
||||||
${sizeStr}
|
${sizeStr}
|
||||||
|
${downloadBtn}
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -3241,4 +3246,68 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ─── Clipboard ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async _onClipboardWrite(data) {
|
||||||
|
const text = data?.text;
|
||||||
|
if (typeof text !== 'string') return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
this.showToast(`Copied to clipboard (${text.length} chars)`, 'success');
|
||||||
|
} catch {
|
||||||
|
this._showClipboardFallback(text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_showClipboardFallback(text) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:10000;display:flex;align-items:center;justify-content:center';
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.style.cssText = 'background:#1e1e2e;border:1px solid #444;border-radius:8px;padding:16px;max-width:600px;width:90%;max-height:60vh;display:flex;flex-direction:column;gap:12px';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.style.cssText = 'display:flex;justify-content:space-between;align-items:center';
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.style.cssText = 'color:#cdd6f4;font-weight:600';
|
||||||
|
title.textContent = 'Clipboard (browser blocked auto-copy)';
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.style.cssText = 'background:none;border:none;color:#cdd6f4;font-size:18px;cursor:pointer';
|
||||||
|
closeBtn.textContent = '\u00d7';
|
||||||
|
header.appendChild(title);
|
||||||
|
header.appendChild(closeBtn);
|
||||||
|
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.readOnly = true;
|
||||||
|
textarea.style.cssText = 'background:#181825;color:#cdd6f4;border:1px solid #555;border-radius:4px;padding:8px;font-family:monospace;font-size:13px;resize:none;height:200px;width:100%';
|
||||||
|
textarea.value = text;
|
||||||
|
|
||||||
|
const copyBtn = document.createElement('button');
|
||||||
|
copyBtn.style.cssText = 'background:#89b4fa;color:#1e1e2e;border:none;border-radius:4px;padding:8px 16px;cursor:pointer;font-weight:600';
|
||||||
|
copyBtn.textContent = 'Copy to Clipboard';
|
||||||
|
|
||||||
|
modal.appendChild(header);
|
||||||
|
modal.appendChild(textarea);
|
||||||
|
modal.appendChild(copyBtn);
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
copyBtn.onclick = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
this.showToast('Copied to clipboard', 'success');
|
||||||
|
overlay.remove();
|
||||||
|
} catch {
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
this.showToast('Copied (fallback)', 'success');
|
||||||
|
overlay.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => overlay.remove();
|
||||||
|
closeBtn.onclick = close;
|
||||||
|
overlay.onclick = (e) => { if (e.target === overlay) close(); };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -287,8 +287,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
});
|
});
|
||||||
const createCaseData = await createCaseRes.json();
|
const createCaseData = await createCaseRes.json();
|
||||||
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||||||
// Use the newly created case data (API returns { success, case: { name, path } })
|
// API returns { success, data: { case: { name, path } } }
|
||||||
caseData = createCaseData.case;
|
caseData = createCaseData.data.case;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workingDir = caseData.path;
|
const workingDir = caseData.path;
|
||||||
@@ -298,7 +298,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Find the highest existing w-number for THIS case to avoid duplicates
|
// Find the highest existing w-number for THIS case to avoid duplicates
|
||||||
let startNumber = 1;
|
let startNumber = 1;
|
||||||
for (const [, session] of this.sessions) {
|
for (const [, session] of this.sessions) {
|
||||||
const match = session.name && session.name.match(/^w(\d+)-(.+)$/);
|
const match = session.name && session.name.match(/^w(\d+)-([a-zA-Z0-9_-]+)/);
|
||||||
if (match && match[2] === caseName) {
|
if (match && match[2] === caseName) {
|
||||||
const num = parseInt(match[1]);
|
const num = parseInt(match[1]);
|
||||||
if (num >= startNumber) {
|
if (num >= startNumber) {
|
||||||
@@ -425,14 +425,28 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
try {
|
try {
|
||||||
// Get the case path
|
// Get the case path
|
||||||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||||||
const caseData = await caseRes.json();
|
let caseData = await caseRes.json();
|
||||||
|
|
||||||
|
// Create the case if it doesn't exist
|
||||||
|
if (!caseData.path) {
|
||||||
|
const createCaseRes = await fetch('/api/cases', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: caseName, description: '' })
|
||||||
|
});
|
||||||
|
const createCaseData = await createCaseRes.json();
|
||||||
|
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||||||
|
// API returns { success, data: { case: { name, path } } }
|
||||||
|
caseData = createCaseData.data.case;
|
||||||
|
}
|
||||||
|
|
||||||
const workingDir = caseData.path;
|
const workingDir = caseData.path;
|
||||||
if (!workingDir) throw new Error('Case path not found');
|
if (!workingDir) throw new Error('Case path not found');
|
||||||
|
|
||||||
// Find the highest existing s-number for THIS case to avoid duplicates
|
// Find the highest existing s-number for THIS case to avoid duplicates
|
||||||
let startNumber = 1;
|
let startNumber = 1;
|
||||||
for (const [, session] of this.sessions) {
|
for (const [, session] of this.sessions) {
|
||||||
const match = session.name && session.name.match(/^s(\d+)-(.+)$/);
|
const match = session.name && session.name.match(/^s(\d+)-([a-zA-Z0-9_-]+)/);
|
||||||
if (match && match[2] === caseName) {
|
if (match && match[2] === caseName) {
|
||||||
const num = parseInt(match[1]);
|
const num = parseInt(match[1]);
|
||||||
if (num >= startNumber) {
|
if (num >= startNumber) {
|
||||||
@@ -596,8 +610,20 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
|
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
|
||||||
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
|
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
|
||||||
|
|
||||||
// Populate session name input
|
// Populate session name input with prefix/suffix split
|
||||||
document.getElementById('modalSessionName').value = session.name || '';
|
const _modalParsed = parseSessionPrefix(session.name);
|
||||||
|
const _prefixEl = document.getElementById('modalSessionPrefix');
|
||||||
|
if (_modalParsed) {
|
||||||
|
_prefixEl.textContent = _modalParsed.prefix + ': ';
|
||||||
|
_prefixEl.style.display = '';
|
||||||
|
document.getElementById('modalSessionName').value = _modalParsed.suffix;
|
||||||
|
document.getElementById('modalSessionName').placeholder = 'Add description...';
|
||||||
|
} else {
|
||||||
|
_prefixEl.style.display = 'none';
|
||||||
|
_prefixEl.textContent = '';
|
||||||
|
document.getElementById('modalSessionName').value = session.name || '';
|
||||||
|
document.getElementById('modalSessionName').placeholder = 'Auto (directory name)';
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize color picker with current session color
|
// Initialize color picker with current session color
|
||||||
const currentColor = session.color || 'default';
|
const currentColor = session.color || 'default';
|
||||||
@@ -644,7 +670,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
async saveSessionName() {
|
async saveSessionName() {
|
||||||
if (!this.editingSessionId) return;
|
if (!this.editingSessionId) return;
|
||||||
const name = document.getElementById('modalSessionName').value.trim();
|
const session = this.sessions.get(this.editingSessionId);
|
||||||
|
const parsed = session ? parseSessionPrefix(session.name) : null;
|
||||||
|
const inputVal = document.getElementById('modalSessionName').value.trim();
|
||||||
|
let name;
|
||||||
|
if (parsed) {
|
||||||
|
name = parsed.prefix + (inputVal ? ': ' + inputVal : '');
|
||||||
|
} else {
|
||||||
|
name = inputVal;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name });
|
await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -864,29 +898,46 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!tabName) return;
|
if (!tabName) return;
|
||||||
|
|
||||||
const currentName = this.getSessionName(session);
|
const currentName = this.getSessionName(session);
|
||||||
|
const parsed = parseSessionPrefix(session.name);
|
||||||
|
const originalContent = tabName.textContent;
|
||||||
|
tabName.textContent = '';
|
||||||
|
tabName.innerHTML = '';
|
||||||
|
|
||||||
|
// If prefix detected, show it as non-editable label
|
||||||
|
if (parsed) {
|
||||||
|
const prefixLabel = document.createElement('span');
|
||||||
|
prefixLabel.textContent = parsed.prefix + ': ';
|
||||||
|
prefixLabel.style.cssText = 'color: var(--text-muted); font-size: 0.75rem; white-space: nowrap;';
|
||||||
|
tabName.appendChild(prefixLabel);
|
||||||
|
}
|
||||||
|
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
input.value = session.name || '';
|
input.value = parsed ? parsed.suffix : (session.name || '');
|
||||||
input.placeholder = currentName;
|
input.placeholder = parsed ? 'Add description...' : currentName;
|
||||||
input.className = 'tab-rename-input';
|
input.className = 'tab-rename-input';
|
||||||
input.style.cssText = 'width: 80px; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;';
|
input.style.cssText = 'width: 80px; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;';
|
||||||
|
|
||||||
const originalContent = tabName.textContent;
|
|
||||||
tabName.textContent = '';
|
|
||||||
tabName.appendChild(input);
|
tabName.appendChild(input);
|
||||||
input.focus();
|
input.focus();
|
||||||
input.select();
|
input.select();
|
||||||
|
|
||||||
const finishRename = async () => {
|
const finishRename = async () => {
|
||||||
const newName = input.value.trim();
|
const suffix = input.value.trim();
|
||||||
tabName.textContent = newName || originalContent;
|
let fullName;
|
||||||
|
if (parsed) {
|
||||||
|
fullName = parsed.prefix + (suffix ? ': ' + suffix : '');
|
||||||
|
} else {
|
||||||
|
fullName = suffix;
|
||||||
|
}
|
||||||
|
tabName.textContent = fullName || originalContent;
|
||||||
|
|
||||||
if (newName && newName !== session.name) {
|
if (fullName !== session.name) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/sessions/${sessionId}/name`, {
|
await fetch(`/api/sessions/${sessionId}/name`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: newName })
|
body: JSON.stringify({ name: fullName })
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
tabName.textContent = originalContent;
|
tabName.textContent = originalContent;
|
||||||
|
|||||||
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user