Compare commits

..
Author SHA1 Message Date
arkonandClaude Opus 4.7 002cf81b1e chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 04:02:02 +02:00
arkonandClaude Opus 4.6 7b8b175133 chore: version packages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:01:32 +02:00
Ark0N c3027b21e1 Merge pull request #63 from Typhon0/fix/quick-start-linked-cases
Good catch — quick-start was ignoring linked-cases.json. Thanks @Typhon0!
2026-04-11 20:58:57 +02:00
Loïc Sculier 0b231edd43 Fix quick-start to resolve linked cases before codeman-cases fallback
/api/quick-start was always resolving caseName against CASES_DIR,
ignoring any entries in ~/.codeman/linked-cases.json. This caused
sessions to start in ~/codeman-cases/<name> even when the case was
linked to an external project directory.

Fix: read linked-cases.json first and prefer that path, falling back
to validatePathWithinBase only when no link is found.
2026-04-11 17:45:41 +02:00
arkon ea1c2ee4ec chore: version packages 2026-04-11 07:21:18 +02:00
arkonandClaude Opus 4.6 b4a808adcf fix: security hardening and cleanup from community PR cherry-picks
- Add HTML sanitizer for markdown rendering (XSS prevention)
- Switch service worker to network-first caching (deploys take effect immediately)
- Sanitize Content-Disposition filenames (header injection prevention)
- Expose session.muxName getter, replace unsafe `as any` cast
- Static import for execFile, update CLAUDE.md keyboard shortcuts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 07:20:09 +02:00
arkonandClaude Opus 4.6 f3cbe9bca6 feat: cherry-pick keyboard UX and file download from community PRs
Cherry-picked from PR #60 (keyboard UX) and PR #61 (file download):

- Alt+1-9 session switching
- Disable Ctrl+K (too easy to trigger accidentally)
- Session rename with prefix preservation (w1-case: description)
- Shift+Enter / Ctrl+Enter multiline input via tmux send-keys -H
- Android virtual keyboard fix for non-composition input
- File download button in browser file explorer (?download=true)

Dropped from PR #60: stale package-lock.json, upload popup (missing upload.html)
Dropped from PR #61: standalone /api/download endpoint (arbitrary fs access)
Fixed from PR #60: execFileSync replaced with async execFile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 06:59:50 +02:00
Ark0N a11bcb0029 Merge pull request #59 from aakhter/feat/pwa-support
feat: add PWA support for Android/iOS home screen install
2026-04-11 06:54:06 +02:00
Ark0N 47fd9a922f Merge pull request #58 from ToRvaLDz/master
feat: add named Cloudflare tunnel support
2026-04-11 06:53:56 +02:00
Ark0N 14f7d8298d Merge pull request #62 from TeigenZhang/feat/mobile-response-viewer
feat: mobile response viewer with markdown rendering
2026-04-11 06:53:47 +02:00
Teigen d32f4debb2 feat: markdown rendering for response viewer
Add marked.js (39KB) for rich text display in the response viewer.
Renders headings, code blocks, lists, tables, blockquotes, and
inline formatting with dark theme styling.

Falls back to escaped plain text if marked.js fails to load.
2026-04-10 19:12:06 +08:00
Teigen 3cb7b510f8 feat: mobile response viewer — read full Claude responses via native scroll
Claude Code's Ink framework uses alternate screen buffer + VPA cursor
positioning, resulting in near-zero xterm.js scrollback on mobile.
Instead of fighting terminal scrollback, this adds a native scrollable
overlay that reads structured responses from Claude's JSONL transcripts.

- New API: GET /api/sessions/:id/last-response reads transcript JSONL
  - ?context=full returns full conversation thread (user + assistant)
  - Fallback to terminal buffer with ANSI stripping if no transcript
- Response viewer panel: bottom sheet with native iOS/Android scroll
- "More" button loads full conversation context as threaded view
- Eye icon in header bar (mobile only), no toolbar space impact
2026-04-10 19:07:02 +08:00
Aamer AkhterandClaude Opus 4.6 6a12a72c9c local: add PWA support for Android home screen install
Add app icons, update manifest with icon entries, and add app-shell
caching to service worker for offline/instant startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:15:24 -04:00
Marco Migozzi f1a126efeb feat: add named Cloudflare tunnel support
Add named tunnel mode alongside existing quick tunnel, with systemd
service and setup helper. All tunnel parameters are configurable via
environment variables:

  CLOUDFLARED_TUNNEL_NAME   — tunnel name (default: codeman)
  CLOUDFLARED_TUNNEL_ID     — tunnel UUID (from: cloudflared tunnel list)
  CODEMAN_TUNNEL_HOSTNAME   — public hostname

Backward compatible: ./tunnel.sh [start|stop|status|url] still works.
2026-04-04 13:41:28 +02:00
Marco Migozzi 12fd780af8 feat: add named Cloudflare tunnel support
Add named tunnel mode alongside existing quick tunnel, with systemd
service and setup helper. Tunnel ID and hostname are configurable via
CLOUDFLARED_TUNNEL_ID and CODEMAN_TUNNEL_HOSTNAME env vars.
2026-04-04 13:37:20 +02:00
Teigen fd74a42933 Merge remote-tracking branch 'origin/master' into dev 2026-04-04 08:07:15 +08:00
arkonandClaude Opus 4.6 7101e64800 refactor: restructure repo for cleaner GitHub landing page
Reduce visible top-level items from 21 to 14:
- Untrack test-results/, tmp/, public symlink (added to .gitignore)
- Move agent-teams/ → docs/agent-teams/
- Move mobile-test/ → test/mobile/
- Move tools/remotion/ → scripts/remotion/
- Move eslint.config.js, vitest.config.ts → config/

All path references updated across CLAUDE.md, package.json,
.prettierignore, vitest configs, and capture scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:23:54 +02:00
Teigen 1b10d9b733 feat: mobile logo, expandable history, fix session resume
- Show Codeman logo on mobile as compact home button (was hidden)
- Add "Show More" button for history sessions (initial 4, expand all)
- Deduplicate by projectKey instead of workingDir (lossy decode fix)
- Fix project key decoding: handle '_' encoded as '-' with look-ahead
- Pre-validate resumeSessionId before passing to Claude CLI
- Apply content validation to all session files regardless of size
2026-04-03 11:02:43 +08:00
arkon 5078f5251d chore: version packages 2026-04-03 04:28:36 +02:00
arkonandClaude Opus 4.6 196af8fba7 fix: allow bracket chars in model flag for opus[1m] context window
The model validation regex rejected brackets, silently dropping models
like opus[1m]. Also quote the model flag to prevent bash glob expansion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 04:27:32 +02:00
arkonandClaude Opus 4.6 a9b22b86a4 docs: use launchctl bootstrap instead of deprecated load
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 04:20:42 +02:00
arkonandClaude Opus 4.6 28cace5858 docs: clean up README install and service sections
Remove fork/branch install instructions and env vars table for cleaner
first impression. Reformat systemd and launchd service blocks as
readable multi-line heredocs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 04:19:45 +02:00
arkon 0a594b61bd chore: version packages 2026-04-03 04:17:33 +02:00
arkon 89d787a949 chore: version packages 2026-04-03 04:01:08 +02:00
arkonandClaude Opus 4.6 bd9797b68c fix: sanitize case names from filesystem to prevent XSS in inline handlers
Filter readdir and linked-case names through /^[a-zA-Z0-9_-]+$/ before
returning them from GET /api/cases. Prevents XSS via maliciously-named
directories reaching frontend inline onclick handlers where escapeHtml
is insufficient (HTML-decoded back to quotes before JS execution).

Also fix misleading "Drag or use arrows" hint (no drag-and-drop exists).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 03:58:29 +02:00
Ark0N 0e6cd94312 Merge pull request #56 from TeigenZhang/feat/case-manage-reorder-delete
feat: add case reorder and delete in Manage tab
2026-04-03 03:53:25 +02:00
arkonandClaude Opus 4.6 24a6f1cac8 chore: remove accidentally committed build artifact and dev-specific script
Remove dist/state-store.js (compiled build artifact that should not be tracked)
and scripts/claudeman-launchd-wrapper.sh (developer-specific launchd wrapper
with hardcoded paths) that were included in #55.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 03:51:00 +02:00
Ark0N 8e679a280b Merge pull request #55 from TeigenZhang/fix/auto-attach-on-restart
fix: auto-attach PTY on server restart
2026-04-03 03:50:31 +02:00
Teigen c642689bbd feat: add case reorder and delete in Manage tab
Add a "Manage" tab to the create-case modal with up/down reorder
buttons and delete for each case. Linked cases are unlinked (folder
preserved); CASES_DIR cases are permanently deleted.

Backend:
- DELETE /api/cases/:name — unlink or delete
- PUT /api/cases/order — persist ordering to settings.json
- GET /api/cases now respects saved caseOrder

Frontend:
- Third "Manage" tab in createCaseModal with case list
- Delete button in mobile case picker bottom sheet
- SSE events: case:deleted, case:order-changed
2026-04-03 09:45:19 +08:00
Teigen 28a6247c27 fix: auto-attach PTY to surviving tmux sessions on server restart
Previously, restoreMuxSessions() only created Session objects without
attaching PTY processes. Sessions stayed at pid=null until the client
manually selected them, causing terminals to appear "closed" after deploy.

Now the server calls startInteractive() for each recovered session during
startup, so all sessions resume capturing output immediately. The frontend
auto-attach condition is also relaxed from (pid===null && status==='idle')
to (pid===null && !_ended) as a safety net for edge cases.
2026-04-02 21:35:00 +08:00
Teigen 0ceb455c4b feat: add Ctrl+O button to mobile keyboard accessory bar 2026-04-02 21:13:17 +08:00
Teigen e51117dfa9 feat: add left/right arrow buttons to mobile keyboard accessory bar
Support cursor left/right movement on mobile, using the same blue
accessory-btn-arrow style as the existing up/down arrows.
2026-04-02 15:19:15 +08:00
Teigen 13d41cf7c7 feat: add Tab, Esc, ⌥Enter buttons to mobile keyboard accessory bar
- Add Tab (forward), Esc, and Option+Enter (newline) buttons
- Reorder buttons: ↑ ↓ 📋 Tab ⇧Tab ⌥Enter Esc /init /clear /compact dismiss
- Unify dismiss button style with arrow buttons (was oversized with custom class)
- Remove unused .accessory-btn-dismiss CSS rules
2026-04-02 14:32:54 +08:00
Teigen 2c7557d002 fix state store temp file collisions 2026-04-02 14:24:10 +08:00
arkonandClaude Opus 4.6 53b473708f fix: macOS support — HTML cache, launchd service, trust dialog
Three fixes for macOS deployments:

1. HTML cache bug: @fastify/static with preCompressed serves .html.br/.html.gz
   files, so path.endsWith('.html') missed them — HTML got 1-year immutable
   cache headers instead of no-cache, causing stale pages after deploys.

2. Installer launchd support: macOS now gets proper LaunchAgent setup (like
   systemd on Linux). Removes competing LaunchDaemons to prevent duplicate
   services fighting over the port. Update/uninstall also handle launchd.

3. Trust dialog auto-accept: Claude CLI 2.x shows a workspace trust prompt
   on first launch per directory. Sessions detect "trust this folder" in PTY
   output and auto-send Enter, preventing sessions from hanging on startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 08:51:37 +02:00
arkonandClaude Opus 4.6 2cba393ae5 fix: installer fails on macOS when piped via curl | bash
When running `curl | bash`, stdin is the pipe, not the terminal.
Homebrew and sudo need TTY access to prompt for the password.
Redirect /dev/tty as stdin for these subprocesses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 19:34:39 +02:00
arkon 64b8ea30b2 chore: version packages 2026-03-31 03:10:22 +02:00
Ark0N 5743af3339 Merge pull request #52 from TeigenZhang/feat/default-model-support
Thanks for the contribution @TeigenZhang! Clean, well-scoped change — applied consistently across all session creation paths. 🎉
2026-03-30 19:19:04 +02:00
Teigen 2011bd8d89 fix: terminal flicker regression — move viewport clear inside dimension guard
Three fixes from the WIP flicker branch that were lost during master merges:

1. Move viewport clear (\x1b[3J\x1b[H\x1b[2J) inside the dimension-change
   guard so it only fires when cols/rows actually change. Previously every
   resize event cleared the screen even at identical dimensions, causing
   visible flicker with no subsequent Ink redraw to repaint.

2. Sync _lastResizeDims in sendResize() so restoreTerminalSize() doesn't
   trigger a redundant viewport clear on the next throttledResize tick.

3. Add didScroll tracking to touch events — tap (no scroll) now refocuses
   xterm's hidden textarea, fixing mobile keyboard input routing after
   tapping the terminal area.
2026-03-30 16:45:20 +08:00
Teigen b76724690d Merge branch 'feat/mobile-shift-tab' into dev 2026-03-30 16:45:16 +08:00
Teigen f277f9664c feat: add Shift+Tab button to mobile keyboard accessory bar
Mobile users cannot press Shift+Tab on virtual keyboards. Add a ⇧Tab
button that sends the escape sequence (\x1b[Z) to the PTY, enabling
mode switching on mobile devices.

Also fix accessory bar overflow on narrow screens by making it
horizontally scrollable with hidden scrollbar.
2026-03-30 16:44:43 +08:00
Teigen cd49171bbc feat: support "Default (CLI default)" option for model selection
Allow users to leave the default model unset, so sessions use whatever
the Claude CLI defaults to rather than forcing a specific model.

- Add empty-value "Default (CLI default)" option to the model dropdown
- Treat empty string as undefined when passing model to Session
- Apply consistently across session creation, quick-start, and Ralph
2026-03-30 16:44:00 +08:00
arkon 0f57342b10 chore: version packages 2026-03-29 05:10:16 +02:00
arkonandClaude Opus 4.6 e1f0ac993a fix: default new sessions to opus[1m] (1M context) instead of opus (200k)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 05:09:24 +02:00
112 changed files with 2271 additions and 335 deletions
+6 -1
View File
@@ -48,7 +48,12 @@ Thumbs.db
# Generated output
out/
screenshots-echo-diag/
tools/remotion/out/
scripts/remotion/out/
# Artifacts that should not be tracked
test-results/
tmp/
public
# Claude Code plan tracking
plan.json
+1 -1
View File
@@ -6,4 +6,4 @@ src/web/public/app.js
src/web/public/styles.css
src/web/public/mobile.css
src/web/public/index.html
tools/
scripts/remotion/
+83
View File
@@ -1,5 +1,88 @@
# aicodeman
## 0.5.13
### Patch Changes
- Fix "Case path not found" error in Quick Start when `~/codeman-cases/` does not exist (issue #64). Two bugs in `session-ui.js`:
- `runClaude()` auto-create read `createCaseData.case`, but `POST /api/cases` returns `{ success, data: { case } }` — corrected to `createCaseData.data.case`.
- `runShell()` had no auto-create logic and would immediately throw on a missing case directory — now mirrors `runClaude()`'s create-on-demand flow.
## 0.5.12
### Patch Changes
- Fix quick-start to resolve linked cases before codeman-cases fallback. `/api/quick-start` was always resolving `caseName` against `CASES_DIR`, ignoring entries in `~/.codeman/linked-cases.json`. Sessions started via quick-start now correctly honour linked external project directories, consistent with regular case routes.
## 0.5.11
### Patch Changes
- Community contributions and security hardening:
- Mobile response viewer: native-scroll panel for reading full Claude responses with markdown rendering via marked.js (PR #62)
- PWA support: service worker caching, web app manifest, and Android home screen install (PR #59)
- Named Cloudflare tunnel support (PR #58)
- Markdown rendering for response viewer with HTML sanitization (XSS prevention) — strips dangerous elements, event handlers, and javascript: URIs
- Service worker switched from stale-while-revalidate to network-first caching so deploys take effect immediately
- Content-Disposition filename sanitization to prevent header injection in file downloads
- Expose session.muxName public getter, replace unsafe `as any` cast in session-routes
- Static import for execFile in session-routes
- Keyboard shortcut updates: Alt+1-9 tab switching, Shift+Enter newline
- Repo restructure for cleaner GitHub landing page
- Mobile logo, expandable history, session resume fixes
## 0.5.10
### Patch Changes
- fix: allow bracket characters in model validation regex so models like opus[1m] (1M context window) are accepted instead of silently dropped. Quote the model flag value in tmux spawn commands to prevent bash glob expansion of bracket patterns.
docs: update macOS launchd instructions to use `launchctl bootstrap` instead of deprecated `load`. Clean up README install and service sections.
## 0.5.9
### Patch Changes
- Mobile keyboard accessory bar: add configurable "Extended Keyboard Bar" setting (Settings > Display > Input) that toggles between simple mode (up/down arrows, /init, /clear, /compact, paste, dismiss) and extended mode (adds left/right arrows, Tab, Shift+Tab, Ctrl+O, Alt+Enter, Esc). Default is simple mode. Setting is device-specific (not synced to server).
Restyle dismiss button: muted steel-blue tone, fills remaining bar space via flex, larger tap target. Arrow buttons now blue.
Fix paste overlay visibility on mobile: dialog repositioned to top of screen (15vh from top) so the virtual keyboard doesn't cover it. Textarea enlarged for better usability.
(Also includes all v0.5.8 changes: case reorder/delete, XSS sanitization, auto-attach PTY on restart, mobile keyboard buttons, macOS installer fixes, terminal flicker fix, state store collision fix.)
## 0.5.8
### Patch Changes
- Case management: add Manage tab with reorder (up/down arrows) and delete for cases; linked cases are unlinked (folder preserved), CASES_DIR cases are permanently deleted. New endpoints: DELETE /api/cases/:name, PUT /api/cases/order. SSE events: case:deleted, case:order-changed.
Security: sanitize case names from filesystem with /^[a-zA-Z0-9_-]+$/ regex before returning from GET /api/cases to prevent XSS via maliciously-named directories reaching frontend inline onclick handlers.
Auto-attach PTY: server now calls startInteractive() for recovered tmux sessions during startup so all sessions resume capturing output immediately after deploy, instead of waiting for client selection. Frontend auto-attach condition relaxed from (pid===null && status==='idle') to (pid===null && !\_ended).
Mobile keyboard accessory: add Shift+Tab, Tab, Esc, Alt+Enter, Left/Right arrow, and Ctrl+O buttons.
Terminal: fix flicker regression by moving viewport clear inside dimension guard.
State store: fix temp file collisions on concurrent writes.
macOS: fix installer failures when piped via curl | bash, add HTML cache support, launchd service template, and trust dialog handling.
Housekeeping: remove accidentally committed dist/state-store.js build artifact.
## 0.5.7
### Patch Changes
- feat: support "Default (CLI default)" option for model selection. Adds a new empty-value option to the model dropdown that defers to the CLI's own default model instead of forcing a specific model. Ensures empty defaultModel values are treated as undefined when passed to session creation and Ralph loop start, preventing empty strings from being sent as model flags.
## 0.5.6
### Patch Changes
- fix: default new sessions to opus[1m] (1M context window) instead of plain opus (200k context)
## 0.5.5
### Patch Changes
+7 -7
View File
@@ -52,7 +52,7 @@ When user says "COM":
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`
**Version**: 0.5.5 (must match `package.json`)
**Version**: 0.5.13 (must match `package.json`)
## Project Overview
@@ -80,7 +80,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**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).
**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
@@ -110,7 +110,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (14 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Frontend** | `src/web/public/app.js` (~2.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
| **Frontend** | `src/web/public/app.js` (~2.8K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
@@ -136,7 +136,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`.
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `agent-teams/`.
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`.
@@ -150,7 +150,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+Enter (quick start), Ctrl+W (kill), Ctrl+Tab (next), Ctrl+K (kill all), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font).
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl/Cmd +/- (font).
### Security
@@ -204,7 +204,7 @@ npx vitest run -t "pattern" # By name (SAFE)
**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
@@ -226,7 +226,7 @@ Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: termina
## References
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`.
Deep-dive docs in `docs/`: `respawn-state-machine.md`, `ralph-wiggum-guide.md`, `claude-code-hooks-reference.md`, `terminal-anti-flicker.md`, `opencode-integration.md`, `qr-auth-plan.md`, `orchestrator-loop-architecture.md`, `browser-testing-guide.md`. Agent Teams: `docs/agent-teams/README.md`. SSE events: `src/web/sse-events.ts` + `constants.js`.
## Scripts
+43 -20
View File
@@ -30,24 +30,6 @@ curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | b
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it.
**Install from a fork or specific branch:**
```bash
curl -fsSL https://raw.githubusercontent.com/<user>/Codeman/<branch>/install.sh | \
CODEMAN_REPO_URL=https://github.com/<user>/Codeman.git \
CODEMAN_BRANCH=<branch> bash
```
The installer supports these environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `CODEMAN_REPO_URL` | upstream Codeman | Custom git repository URL |
| `CODEMAN_BRANCH` | `master` | Git branch to install |
| `CODEMAN_INSTALL_DIR` | `~/.codeman/app` | Custom install directory |
| `CODEMAN_SKIP_SYSTEMD` | `0` | Skip systemd service setup prompt |
| `CODEMAN_NODE_VERSION` | `22` | Node.js major version to install |
| `CODEMAN_NONINTERACTIVE` | `0` | Skip all prompts (for CI/automation) |
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [OpenCode](https://opencode.ai) (or both). After install:
```bash
@@ -60,12 +42,53 @@ codeman web
**Linux (systemd):**
```bash
mkdir -p ~/.config/systemd/user && printf '[Unit]\nDescription=Codeman Web Server\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=%s %s/dist/index.js web\nRestart=always\nRestartSec=10\n\n[Install]\nWantedBy=default.target\n' "$(which node)" "$HOME/.codeman/app" > ~/.config/systemd/user/codeman-web.service && systemctl --user daemon-reload && systemctl --user enable --now codeman-web && loginctl enable-linger $USER
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/codeman-web.service << EOF
[Unit]
Description=Codeman Web Server
After=network.target
[Service]
Type=simple
ExecStart=$(which node) $HOME/.codeman/app/dist/index.js web
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now codeman-web
loginctl enable-linger $USER
```
**macOS (launchd):**
```bash
mkdir -p ~/Library/LaunchAgents && printf '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>com.codeman.web</string><key>ProgramArguments</key><array><string>%s</string><string>%s/dist/index.js</string><string>web</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>StandardOutPath</key><string>/tmp/codeman.log</string><key>StandardErrorPath</key><string>/tmp/codeman.log</string></dict></plist>\n' "$(which node)" "$HOME/.codeman/app" > ~/Library/LaunchAgents/com.codeman.web.plist && launchctl load ~/Library/LaunchAgents/com.codeman.web.plist
mkdir -p ~/Library/LaunchAgents
cat > ~/Library/LaunchAgents/com.codeman.web.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.codeman.web</string>
<key>ProgramArguments</key>
<array>
<string>$(which node)</string>
<string>$HOME/.codeman/app/dist/index.js</string>
<string>web</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key>
<string>/tmp/codeman.log</string>
<key>StandardErrorPath</key>
<string>/tmp/codeman.log</string>
</dict>
</plist>
EOF
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
```
</details>
+1 -2
View File
@@ -22,8 +22,7 @@ export default tseslint.config(
'src/web/public/vendor/**',
'src/web/public/app.js',
'scripts/**/*.mjs',
'tools/**',
'remotion/**',
'scripts/remotion/**',
],
}
);
@@ -1,7 +1,11 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vitest/config';
const root = resolve(import.meta.dirname, '..');
export default defineConfig({
test: {
root,
globals: true,
environment: 'node',
include: ['test/**/*.test.ts'],
+147 -22
View File
@@ -7,7 +7,7 @@
# Environment variables:
# CODEMAN_NONINTERACTIVE=1 - Skip all prompts (for CI/automation)
# CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app)
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd service setup prompt
# CODEMAN_SKIP_SYSTEMD=1 - Skip systemd/launchd service setup prompt
# CODEMAN_NODE_VERSION - Node.js major version to install (default: 22)
# CODEMAN_REPO_URL - Custom git repository URL (default: upstream Codeman)
# CODEMAN_BRANCH - Git branch to install (default: master)
@@ -353,8 +353,15 @@ ensure_sudo() {
die "sudo is required but not installed. Please install packages manually or run as root."
fi
# Validate sudo access
if ! sudo -v 2>/dev/null; then
die "Failed to obtain sudo privileges."
# When piped (curl | bash), stdin is the pipe — redirect from /dev/tty so sudo can prompt
if [[ -e /dev/tty ]]; then
if ! sudo -v 2>/dev/null < /dev/tty; then
die "Failed to obtain sudo privileges."
fi
else
if ! sudo -v 2>/dev/null; then
die "Failed to obtain sudo privileges. Try running the script directly instead of piping."
fi
fi
}
@@ -372,7 +379,12 @@ ensure_homebrew() {
fi
info "Installing Homebrew first..."
/bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# When piped (curl | bash), stdin is the pipe — Homebrew needs TTY for sudo password prompt
if [[ -e /dev/tty ]]; then
/bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" < /dev/tty
else
NONINTERACTIVE=1 /bin/bash -c "$(download_to_stdout https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
# Add Homebrew to PATH for Apple Silicon
if [[ -f /opt/homebrew/bin/brew ]]; then
@@ -787,9 +799,84 @@ setup_sc_alias() {
}
# ============================================================================
# Systemd Service Setup (Linux only)
# Service Setup (Linux systemd / macOS launchd)
# ============================================================================
setup_launchd_service() {
local plist_label="com.codeman.web"
local agent_dir="$HOME/Library/LaunchAgents"
local agent_plist="$agent_dir/$plist_label.plist"
local daemon_plist="/Library/LaunchDaemons/$plist_label.plist"
info "Setting up macOS LaunchAgent..."
# Remove any existing LaunchDaemon (system-level) to prevent duplicates.
# We standardize on LaunchAgent (user-level) — it doesn't require sudo,
# inherits the user's environment, and is the correct choice for user apps.
if [[ -f "$daemon_plist" ]]; then
warn "Found system-level LaunchDaemon at $daemon_plist — removing to prevent duplicate"
sudo launchctl unload "$daemon_plist" 2>/dev/null || true
sudo rm -f "$daemon_plist"
success "Removed duplicate LaunchDaemon"
fi
# Unload existing agent before overwriting
if [[ -f "$agent_plist" ]]; then
launchctl unload "$agent_plist" 2>/dev/null || true
fi
mkdir -p "$agent_dir"
# Build PATH: ensure /opt/homebrew/bin (Apple Silicon) and ~/.local/bin are included
local svc_path="/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
# Find node binary path
local node_path
node_path=$(command -v node)
cat > "$agent_plist" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$plist_label</string>
<key>ProgramArguments</key>
<array>
<string>$node_path</string>
<string>$INSTALL_DIR/dist/index.js</string>
<string>web</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>$svc_path</string>
<key>HOME</key>
<string>$HOME</string>
<key>LANG</key>
<string>en_US.UTF-8</string>
</dict>
<key>WorkingDirectory</key>
<string>$HOME</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>/tmp/codeman.log</string>
<key>StandardErrorPath</key>
<string>/tmp/codeman.log</string>
</dict>
</plist>
EOF
launchctl load "$agent_plist" 2>/dev/null || true
success "LaunchAgent installed and started"
}
setup_systemd_service() {
local service_dir="$HOME/.config/systemd/user"
local service_file="$service_dir/codeman-web.service"
@@ -1139,17 +1226,25 @@ main() {
echo ""
local launch_choice=""
local has_systemd=false
local has_service=false
local service_type=""
if [[ "$os" == "linux" ]] && [[ "$SKIP_SYSTEMD" != "1" ]] && command -v systemctl &>/dev/null; then
has_systemd=true
has_service=true
service_type="systemd"
elif [[ "$os" == "macos" ]] && [[ "$SKIP_SYSTEMD" != "1" ]]; then
has_service=true
service_type="launchd"
fi
if [[ "$has_systemd" == "true" ]]; then
if [[ "$has_service" == "true" ]]; then
local service_label="systemd service"
[[ "$service_type" == "launchd" ]] && service_label="LaunchAgent"
echo -e " ${BOLD}How would you like to run Codeman?${NC}"
echo ""
echo -e " ${CYAN}1)${NC} Run now in this terminal"
echo -e " ${CYAN}2)${NC} Install as systemd service (auto-start on boot)"
echo -e " ${CYAN}2)${NC} Install as $service_label (auto-start on boot)"
echo -e " ${CYAN}3)${NC} Don't start — I'll run it later"
echo ""
@@ -1166,7 +1261,7 @@ main() {
done
fi
else
# macOS or no systemd — only offer run now or skip
# No service manager available — only offer run now or skip
echo -e " ${BOLD}Would you like to start Codeman now?${NC}"
echo ""
echo -e " ${CYAN}1)${NC} Run now in this terminal"
@@ -1192,12 +1287,16 @@ main() {
echo ""
# Handle systemd setup
# Handle service setup
if [[ "$launch_choice" == "2" ]]; then
setup_systemd_service
if [[ "$service_type" == "launchd" ]]; then
setup_launchd_service
else
setup_systemd_service
fi
# Offer tunnel service if cloudflared is available
if check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
# Offer tunnel service if cloudflared is available (Linux only — systemd tunnel service)
if [[ "$service_type" == "systemd" ]] && check_cloudflared && [[ -f "$INSTALL_DIR/scripts/codeman-tunnel.service" ]]; then
echo ""
if prompt_yes_no "Also set up Cloudflare tunnel service? (requires CODEMAN_PASSWORD)" "n"; then
setup_tunnel_service
@@ -1212,10 +1311,16 @@ main() {
echo ""
echo -e " ${BOLD}Manage the service:${NC}"
echo ""
echo -e " ${CYAN}systemctl --user stop codeman-web${NC} # Stop"
echo -e " ${CYAN}systemctl --user restart codeman-web${NC} # Restart"
echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status"
echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs"
if [[ "$service_type" == "launchd" ]]; then
echo -e " ${CYAN}launchctl unload ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Stop"
echo -e " ${CYAN}launchctl load ~/Library/LaunchAgents/com.codeman.web.plist${NC} # Start"
echo -e " ${CYAN}tail -f /tmp/codeman.log${NC} # View logs"
else
echo -e " ${CYAN}systemctl --user stop codeman-web${NC} # Stop"
echo -e " ${CYAN}systemctl --user restart codeman-web${NC} # Restart"
echo -e " ${CYAN}systemctl --user status codeman-web${NC} # Check status"
echo -e " ${CYAN}journalctl --user -u codeman-web -f${NC} # View logs"
fi
echo ""
fi
@@ -1289,11 +1394,17 @@ update() {
success "Updated to $(node -e "console.log(require('./package.json').version)")"
echo ""
# Auto-restart systemd service if it's running, otherwise tell the user
if systemctl --user is-active codeman-web.service &>/dev/null; then
# Auto-restart service if running, otherwise tell the user
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
if systemctl --user is-active codeman-web.service &>/dev/null 2>&1; then
info "Restarting codeman-web service..."
systemctl --user restart codeman-web.service
success "codeman-web service restarted"
elif [[ -f "$agent_plist" ]]; then
info "Restarting LaunchAgent..."
launchctl unload "$agent_plist" 2>/dev/null || true
launchctl load "$agent_plist" 2>/dev/null || true
success "LaunchAgent restarted"
else
echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}"
@@ -1306,9 +1417,9 @@ uninstall() {
info "Uninstalling Codeman..."
echo ""
# Stop and remove systemd services
# Stop and remove systemd services (Linux)
for svc in codeman-web codeman-tunnel; do
if systemctl --user is-active "${svc}.service" &>/dev/null; then
if systemctl --user is-active "${svc}.service" &>/dev/null 2>&1; then
info "Stopping ${svc} service..."
systemctl --user stop "${svc}.service"
fi
@@ -1324,6 +1435,20 @@ uninstall() {
done
systemctl --user daemon-reload 2>/dev/null || true
# Stop and remove launchd services (macOS)
local agent_plist="$HOME/Library/LaunchAgents/com.codeman.web.plist"
local daemon_plist="/Library/LaunchDaemons/com.codeman.web.plist"
if [[ -f "$agent_plist" ]]; then
launchctl unload "$agent_plist" 2>/dev/null || true
rm -f "$agent_plist"
success "Removed LaunchAgent"
fi
if [[ -f "$daemon_plist" ]]; then
sudo launchctl unload "$daemon_plist" 2>/dev/null || true
sudo rm -f "$daemon_plist"
success "Removed LaunchDaemon"
fi
# Remove symlinks
local symlink_dir="$HOME/.local/bin"
if [[ -L "$symlink_dir/codeman" ]]; then
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.5.5",
"version": "0.5.13",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
@@ -15,12 +15,12 @@
"dev": "tsx src/index.ts web",
"web": "node dist/index.js web",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test": "vitest run --config config/vitest.config.ts",
"test:watch": "vitest --config config/vitest.config.ts",
"test:coverage": "vitest run --config config/vitest.config.ts --coverage",
"typecheck": "tsc --noEmit",
"lint": "eslint 'src/**/*.ts'",
"lint:fix": "eslint 'src/**/*.ts' --fix",
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
"format": "prettier --write 'src/**/*.ts'",
"format:check": "prettier --check 'src/**/*.ts'",
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
-1
View File
@@ -1 +0,0 @@
tools/remotion/public
+1 -1
View File
@@ -428,7 +428,7 @@ const SUBAGENT_ACTIVITY = {
'agent-002': [
{ type: 'tool', tool: 'Glob', input: { pattern: 'test/**/*.test.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/vitest.config.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
{ type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/config/vitest.config.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' },
{ type: 'message', role: 'assistant', text: 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...', timestamp: new Date().toISOString(), agentId: 'agent-002' },
],
};
+14 -14
View File
@@ -9,7 +9,7 @@
*
* Usage: node scripts/capture-video-screenshots.mjs
* Port: 3198 (static file server)
* Output: remotion/public/ (6 PNGs)
* Output: scripts/scripts/remotion/public/ (6 PNGs)
*/
import { chromium } from 'playwright';
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const PROJECT_ROOT = join(__dirname, '..');
const PUBLIC_DIR = join(PROJECT_ROOT, 'src', 'web', 'public');
const OUTPUT_DIR = join(PROJECT_ROOT, 'remotion', 'public');
const OUTPUT_DIR = join(PROJECT_ROOT, 'scripts', 'remotion', 'public');
const PORT = 3198;
const DESKTOP_VIEWPORT = { width: 1920, height: 1080 };
@@ -516,7 +516,7 @@ async function captureDesktopWelcome(browser) {
path: join(OUTPUT_DIR, 'desktop-welcome.png'),
fullPage: false,
});
console.log(' Saved: remotion/public/desktop-welcome.png');
console.log(' Saved: scripts/remotion/public/desktop-welcome.png');
} finally {
await context.close();
}
@@ -545,7 +545,7 @@ async function captureDesktopClaude(browser) {
path: join(OUTPUT_DIR, 'desktop-claude.png'),
fullPage: false,
});
console.log(' Saved: remotion/public/desktop-claude.png');
console.log(' Saved: scripts/remotion/public/desktop-claude.png');
} finally {
await context.close();
}
@@ -575,7 +575,7 @@ async function captureDesktopBothClaude(browser) {
path: join(OUTPUT_DIR, 'desktop-both-claude.png'),
fullPage: false,
});
console.log(' Saved: remotion/public/desktop-both-claude.png');
console.log(' Saved: scripts/remotion/public/desktop-both-claude.png');
} finally {
await context.close();
}
@@ -605,7 +605,7 @@ async function captureDesktopBothOpencode(browser) {
path: join(OUTPUT_DIR, 'desktop-both-opencode.png'),
fullPage: false,
});
console.log(' Saved: remotion/public/desktop-both-opencode.png');
console.log(' Saved: scripts/remotion/public/desktop-both-opencode.png');
} finally {
await context.close();
}
@@ -634,7 +634,7 @@ async function captureMobileClaude(browser) {
path: join(OUTPUT_DIR, 'mobile-claude.png'),
fullPage: false,
});
console.log(' Saved: remotion/public/mobile-claude.png');
console.log(' Saved: scripts/remotion/public/mobile-claude.png');
} finally {
await context.close();
}
@@ -663,7 +663,7 @@ async function captureMobileOpencode(browser) {
path: join(OUTPUT_DIR, 'mobile-opencode.png'),
fullPage: false,
});
console.log(' Saved: remotion/public/mobile-opencode.png');
console.log(' Saved: scripts/remotion/public/mobile-opencode.png');
} finally {
await context.close();
}
@@ -706,12 +706,12 @@ async function main() {
console.log('All 6 screenshots captured!');
console.log('='.repeat(60));
console.log('\nOutput files:');
console.log(' remotion/public/desktop-welcome.png');
console.log(' remotion/public/desktop-claude.png');
console.log(' remotion/public/desktop-both-claude.png');
console.log(' remotion/public/desktop-both-opencode.png');
console.log(' remotion/public/mobile-claude.png');
console.log(' remotion/public/mobile-opencode.png');
console.log(' scripts/remotion/public/desktop-welcome.png');
console.log(' scripts/remotion/public/desktop-claude.png');
console.log(' scripts/remotion/public/desktop-both-claude.png');
console.log(' scripts/remotion/public/desktop-both-opencode.png');
console.log(' scripts/remotion/public/mobile-claude.png');
console.log(' scripts/remotion/public/mobile-opencode.png');
} catch (err) {
console.error('\nFatal error:', err.message);
console.error(err.stack);
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Codeman Cloudflare Named Tunnel
After=network-online.target codeman-web.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/cloudflared tunnel --config %h/.cloudflared/codeman.yml run codeman
Restart=always
RestartSec=5
KillMode=process
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=codeman-tunnel-named
[Install]
WantedBy=default.target

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Before

Width:  |  Height:  |  Size: 390 KiB

After

Width:  |  Height:  |  Size: 390 KiB

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+199 -35
View File
@@ -1,45 +1,209 @@
#!/usr/bin/env bash
# Quick Cloudflare Tunnel for Codeman
# Usage: ./scripts/tunnel.sh [start|stop|status|url]
# Cloudflare Tunnel manager for Codeman
# Usage: ./scripts/tunnel.sh [quick|named] [start|stop|status|url]
#
# Modes:
# quick — Quick tunnel with random trycloudflare.com URL (default)
# named — Named tunnel on a fixed hostname (requires setup, see below)
#
# Environment variables:
# CLOUDFLARED_TUNNEL_NAME — tunnel name (default: codeman)
# CLOUDFLARED_TUNNEL_ID — tunnel UUID (from: cloudflared tunnel list)
# CODEMAN_TUNNEL_HOSTNAME — public hostname (e.g. codeman.example.com)
#
# First-time named tunnel setup:
# cloudflared tunnel login
# cloudflared tunnel create <tunnel-name>
# cloudflared tunnel route dns <tunnel-name> <hostname>
# ./scripts/tunnel.sh named setup # writes ~/.cloudflared/<tunnel-name>.yml
set -euo pipefail
SERVICE="codeman-tunnel"
QUICK_SERVICE="codeman-tunnel"
NAMED_SERVICE="codeman-tunnel-named"
TUNNEL_NAME="${CLOUDFLARED_TUNNEL_NAME:-codeman}"
TUNNEL_HOSTNAME="${CODEMAN_TUNNEL_HOSTNAME:-codeman.example.com}"
CODEMAN_PORT="3000"
LOG_FILE="$HOME/.codeman/tunnel.log"
case "${1:-start}" in
start)
if ! systemctl --user is-active "$SERVICE" &>/dev/null; then
# Install service if not already
if ! systemctl --user cat "$SERVICE" &>/dev/null 2>&1; then
cp "$(dirname "$0")/codeman-tunnel.service" "$HOME/.config/systemd/user/"
systemctl --user daemon-reload
fi
systemctl --user start "$SERVICE"
echo "Tunnel starting... waiting for URL"
sleep 6
fi
# Extract the tunnel URL from journal
URL=$(grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1)
if [ -n "$URL" ]; then
echo "$URL"
else
echo "URL not ready yet, try: $0 url"
fi
# ── helpers ──────────────────────────────────────────────────────────────────
_require_cloudflared() {
if ! command -v cloudflared &>/dev/null; then
echo "Error: cloudflared not found. Install with: yay -S cloudflared" >&2
exit 1
fi
}
_cloudflared_bin() {
command -v cloudflared
}
_install_service() {
local svc_file="$1"
local svc_name="$2"
if ! systemctl --user cat "$svc_name" &>/dev/null 2>&1; then
cp "$(dirname "$0")/$svc_file" "$HOME/.config/systemd/user/"
systemctl --user daemon-reload
echo "Service $svc_name installed."
fi
}
_install_named_service() {
if ! systemctl --user cat "$NAMED_SERVICE" &>/dev/null 2>&1; then
# Generate service file with the configured tunnel name
sed "s/codeman\.yml/$TUNNEL_NAME.yml/g; s/run codeman/run $TUNNEL_NAME/g" \
"$(dirname "$0")/codeman-tunnel-named.service" \
> "$HOME/.config/systemd/user/codeman-tunnel-named.service"
systemctl --user daemon-reload
echo "Service $NAMED_SERVICE installed (tunnel: $TUNNEL_NAME)."
fi
}
# ── named tunnel setup ───────────────────────────────────────────────────────
_named_setup() {
_require_cloudflared
local creds_dir="$HOME/.cloudflared"
local config_file="$creds_dir/$TUNNEL_NAME.yml"
# Replace with your tunnel ID (from: cloudflared tunnel list)
local tunnel_id="${CLOUDFLARED_TUNNEL_ID:-YOUR_TUNNEL_ID_HERE}"
local creds_file="$creds_dir/$tunnel_id.json"
if [ ! -f "$creds_file" ]; then
echo "Credentials not found: $creds_file"
echo "Run: cloudflared tunnel create $TUNNEL_NAME"
exit 1
fi
cat > "$config_file" <<EOF
tunnel: $tunnel_id
credentials-file: $creds_file
ingress:
- hostname: $TUNNEL_HOSTNAME
service: http://localhost:$CODEMAN_PORT
- service: http_status:404
EOF
echo "Config written to $config_file"
echo "Tunnel ID: $tunnel_id"
echo "Hostname: $TUNNEL_HOSTNAME"
echo ""
echo "Next steps:"
echo " 1. Add Cloudflare Access policy for $TUNNEL_HOSTNAME (Zero Trust dashboard)"
echo " 2. ./scripts/tunnel.sh named start"
}
# ── quick mode ───────────────────────────────────────────────────────────────
_quick_start() {
if ! systemctl --user is-active "$QUICK_SERVICE" &>/dev/null; then
_install_service "codeman-tunnel.service" "$QUICK_SERVICE"
systemctl --user start "$QUICK_SERVICE"
echo "Quick tunnel starting... waiting for URL"
sleep 6
fi
local url
url=$(grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" 2>/dev/null | tail -1)
if [ -n "$url" ]; then
echo "$url"
else
echo "URL not ready yet, try: $0 quick url"
fi
}
_quick_stop() {
systemctl --user stop "$QUICK_SERVICE"
echo "Quick tunnel stopped"
}
_quick_status() {
systemctl --user status "$QUICK_SERVICE" --no-pager 2>&1 | head -10
echo ""
echo "URL:"
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" 2>/dev/null | tail -1
}
_quick_url() {
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG_FILE" 2>/dev/null | tail -1
}
# ── named mode ───────────────────────────────────────────────────────────────
_named_start() {
_require_cloudflared
if [ ! -f "$HOME/.cloudflared/$TUNNEL_NAME.yml" ]; then
echo "Config not found. Run: $0 named setup"
exit 1
fi
if ! systemctl --user is-active "$NAMED_SERVICE" &>/dev/null; then
_install_named_service
systemctl --user start "$NAMED_SERVICE"
echo "Named tunnel starting..."
sleep 3
fi
echo "https://$TUNNEL_HOSTNAME"
}
_named_stop() {
systemctl --user stop "$NAMED_SERVICE"
echo "Named tunnel stopped"
}
_named_status() {
systemctl --user status "$NAMED_SERVICE" --no-pager 2>&1 | head -10
echo ""
echo "URL: https://$TUNNEL_HOSTNAME"
}
_named_enable() {
_install_named_service
systemctl --user enable "$NAMED_SERVICE"
echo "Named tunnel enabled at boot."
}
_named_disable() {
systemctl --user disable "$NAMED_SERVICE"
echo "Named tunnel disabled."
}
# ── dispatch ─────────────────────────────────────────────────────────────────
MODE="${1:-quick}"
CMD="${2:-start}"
case "$MODE" in
quick)
case "$CMD" in
start) _quick_start ;;
stop) _quick_stop ;;
status) _quick_status ;;
url) _quick_url ;;
*) echo "Usage: $0 quick [start|stop|status|url]"; exit 1 ;;
esac
;;
stop)
systemctl --user stop "$SERVICE"
echo "Tunnel stopped"
;;
status)
systemctl --user status "$SERVICE" --no-pager 2>&1 | head -10
echo ""
echo "URL:"
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1
;;
url)
grep -oP 'https://[a-z0-9-]+\.trycloudflare\.com' "$HOME/.codeman/tunnel.log" 2>/dev/null | tail -1
named)
case "$CMD" in
start) _named_start ;;
stop) _named_stop ;;
status) _named_status ;;
url) echo "https://$TUNNEL_HOSTNAME" ;;
setup) _named_setup ;;
enable) _named_enable ;;
disable) _named_disable ;;
*) echo "Usage: $0 named [start|stop|status|url|setup|enable|disable]"; exit 1 ;;
esac
;;
# backward compat: no mode prefix → quick tunnel
start) _quick_start ;;
stop) _quick_stop ;;
status) _quick_status ;;
url) _quick_url ;;
*)
echo "Usage: $0 [start|stop|status|url]"
echo "Usage: $0 [quick|named] [start|stop|status|url]"
echo " $0 named setup # first-time named tunnel configuration"
echo " $0 named enable # start at boot"
exit 1
;;
esac
+16
View File
@@ -274,6 +274,7 @@ export class Session extends EventEmitter {
private _lastPromptTime: number = 0;
private activityTimeout: NodeJS.Timeout | null = null;
private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection
private _trustDialogAccepted: boolean = false; // Prevents repeated trust dialog auto-accept
private _taskTracker: TaskTracker;
// Token tracking for auto-clear
@@ -529,6 +530,11 @@ export class Session extends EventEmitter {
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 {
return this._totalCost;
}
@@ -1118,6 +1124,16 @@ export class Session extends EventEmitter {
this._handleTerminalOutput(data);
// === Auto-accept workspace trust dialog ===
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
// Codeman sessions always use --dangerously-skip-permissions, so auto-accept.
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
this._trustDialogAccepted = true;
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
// Send Enter to accept the default selection ("Yes, I trust this folder")
this.writeViaMux('\r');
}
// === Idle/working detection runs on every chunk (latency-sensitive) ===
// Detect if Claude is working or at prompt
// The prompt line contains "❯" when waiting for input
+2 -2
View File
@@ -309,7 +309,7 @@ export class StateStore {
this.ensureDir();
const tempPath = this.filePath + '.tmp';
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
const backupPath = this.filePath + '.bak';
// Step 1: Serialize state (validates it's JSON-safe)
@@ -373,7 +373,7 @@ export class StateStore {
this.ensureDir();
const tempPath = this.filePath + '.tmp';
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
const backupPath = this.filePath + '.bak';
const json = this.serializeState();
+2 -2
View File
@@ -185,8 +185,8 @@ function buildSpawnCommand(options: {
}): string {
if (options.mode === 'claude') {
// Validate model to prevent command injection
const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
// fall back to a new session with --session-id so the pane doesn't die.
+187 -6
View File
@@ -251,6 +251,23 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.ORCHESTRATOR_ERROR, '_onOrchestratorError'],
];
// ═══════════════════════════════════════════════════════════════
// Session Name Prefix Parser
// ═══════════════════════════════════════════════════════════════
// Parses w<N>-<caseName> or s<N>-<caseName> prefix from session names.
// Returns { prefix, suffix } or null if name does not match the pattern.
function parseSessionPrefix(name) {
if (!name) return null;
const m = name.match(/^(w\d+-[a-zA-Z0-9_-]+|s\d+-[a-zA-Z0-9_-]+)/);
if (!m) return null;
const prefix = m[1];
const rest = name.slice(prefix.length);
if (rest === "") return { prefix, suffix: "" };
if (rest.startsWith(": ")) return { prefix, suffix: rest.slice(2) };
return null;
}
// ═══════════════════════════════════════════════════════════════
// CodemanApp Class — constructor and global state
// ═══════════════════════════════════════════════════════════════
@@ -523,6 +540,9 @@ class CodemanApp {
SwipeHandler.init();
VoiceInput.init();
KeyboardAccessoryBar.init();
// Apply keyboard bar mode from settings
const _kbSettings = this.loadAppSettingsFromStorage();
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
this.applyHeaderVisibilitySettings();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
@@ -612,10 +632,8 @@ class CodemanApp {
// shift? (require Shift), action }.
const SHORTCUTS = [
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
{ key: 'Enter', ctrl: true, action: () => this.quickStart() },
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
{ key: 'k', ctrl: true, action: () => this.killAllSessions() },
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
@@ -634,6 +652,16 @@ class CodemanApp {
this.closeHelp();
}
// Alt+1-9: switch to Codeman session by index
if (e.altKey && !e.ctrlKey && !e.shiftKey && e.key >= '1' && e.key <= '9') {
const idx = parseInt(e.key) - 1;
if (idx < this.sessionOrder.length) {
e.preventDefault();
this.selectSession(this.sessionOrder[idx]);
}
return;
}
// Match against shortcut table
for (const s of SHORTCUTS) {
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
@@ -866,6 +894,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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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() {
// Server sends this after SSE backpressure clears — terminal data was dropped,
// so reload the buffer to recover from any display corruption.
@@ -1591,7 +1766,12 @@ class CodemanApp {
// Update name if changed
const nameEl = tab.querySelector('.tab-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
@@ -1699,7 +1879,7 @@ class CodemanApp {
<span class="tab-info">
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
<span class="tab-name" data-session-id="${id}">${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>
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
</span>
@@ -2105,8 +2285,9 @@ class CodemanApp {
// Track working directory for path normalization in Project Insights
this.currentSessionWorkingDir = session?.workingDir || null;
if (session && session.pid === null && session.status === 'idle') {
// This is a restored session - attach to the existing screen/shell
if (session && session.pid === null && !session._ended) {
// Session has no PTY attached — either restored after server restart
// or detached for some other reason. Re-attach regardless of status.
try {
const endpoint = session.mode === 'shell'
? `/api/sessions/${sessionId}/shell`
+6
View File
@@ -291,6 +291,12 @@ const SSE_EVENTS = {
ORCHESTRATOR_TASK_FAILED: 'orchestrator:taskFailed',
ORCHESTRATOR_COMPLETED: 'orchestrator:completed',
ORCHESTRATOR_ERROR: 'orchestrator:error',
// Cases
CASE_CREATED: 'case:created',
CASE_LINKED: 'case:linked',
CASE_DELETED: 'case:deleted',
CASE_ORDER_CHANGED: 'case:order-changed',
};
// ═══════════════════════════════════════════════════════════════
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+43 -3
View File
@@ -6,6 +6,9 @@
<meta name="description" content="Claude Code session manager with web interface">
<meta name="theme-color" content="#0a0a0a">
<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">
<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">
@@ -26,6 +29,7 @@
<!-- 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-zerolag-input.js"></script>
<script defer src="vendor/marked.min.js"></script>
<!-- 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>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
@@ -92,6 +96,7 @@
<span class="stat-value" id="statMem">--</span>
</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">
<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>
@@ -331,6 +336,19 @@
</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()">&times;</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 -->
<footer class="toolbar">
<div class="toolbar-left">
@@ -459,10 +477,9 @@
</div>
<div class="modal-body">
<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>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>+</kbd></div><div>Increase 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-row">
<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>
</div>
<div class="form-row">
@@ -892,6 +912,17 @@
</label>
</div>
<div class="settings-item settings-item-multiline" title="Show additional buttons (Tab, Shift+Tab, Ctrl+O, Esc, Alt+Enter, left/right arrows) in the mobile keyboard accessory bar.">
<div class="settings-item-text">
<span class="settings-item-label">Extended Keyboard Bar</span>
<span class="settings-item-desc">Extra keys: Tab, Esc, arrows, Ctrl+O</span>
</div>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsExtendedKeyboardBar">
<span class="slider"></span>
</label>
</div>
<!-- Header Displays Section -->
<div class="settings-section-header">Header Displays</div>
<div class="settings-item" title="Show A-/A+ font size buttons in header">
@@ -1085,6 +1116,7 @@
<div class="form-row">
<label>Default Model</label>
<select id="appSettingsDefaultModel" class="form-select">
<option value="">Default (CLI default)</option>
<option value="opus">Opus (Most capable)</option>
<option value="sonnet">Sonnet (Balanced)</option>
<option value="haiku">Haiku (Fast & cheap)</option>
@@ -1359,6 +1391,7 @@
<div class="modal-tabs">
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
<button class="modal-tab-btn" data-tab="case-manage">Manage</button>
</div>
<div class="modal-body">
<!-- Create New Tab -->
@@ -1386,6 +1419,13 @@
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
</div>
</div>
<!-- Manage Tab -->
<div class="modal-tab-content hidden" id="case-manage">
<div class="case-manage-list" id="caseManageList">
<!-- Populated by JS -->
</div>
<span class="form-hint" style="margin-top: 8px; display: block;">Use arrows to reorder. Changes are saved automatically.</span>
</div>
</div>
<div class="form-actions">
<button class="btn-toolbar" onclick="app.closeCreateCaseModal()">Cancel</button>
+86 -11
View File
@@ -33,16 +33,10 @@
*/
const KeyboardAccessoryBar = {
element: null,
_mode: 'simple', // 'simple' or 'extended'
/** Create and inject the accessory bar */
init() {
// Only on mobile
if (!MobileDetection.isTouchDevice()) return;
// Create accessory bar element
this.element = document.createElement('div');
this.element.className = 'keyboard-accessory-bar';
this.element.innerHTML = `
/** HTML for simple mode: arrows, commands, paste, dismiss */
_simpleButtons: `
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M5 15l7-7 7 7"/>
@@ -66,8 +60,59 @@ const KeyboardAccessoryBar = {
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M19 9l-7 7-7-7"/>
</svg>
</button>`,
/** HTML for extended mode: all keys including arrows, Tab, Esc, etc. */
_extendedButtons: `
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M5 15l7-7 7 7"/>
</svg>
</button>
`;
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-down" title="Arrow down">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M19 9l-7 7-7-7"/>
</svg>
</button>
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-left" title="Arrow left">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M15 19l-7-7 7-7"/>
</svg>
</button>
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-right" title="Arrow right">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M9 5l7 7-7 7"/>
</svg>
</button>
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
</svg>
</button>
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
<button class="accessory-btn" data-action="ctrl-o" title="Ctrl+O">⌃O</button>
<button class="accessory-btn" data-action="opt-enter" title="Option+Enter (newline)">⌥Enter</button>
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
<button class="accessory-btn" data-action="init" title="/init">/init</button>
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M19 9l-7 7-7-7"/>
</svg>
</button>`,
/** Create and inject the accessory bar */
init() {
// Only on mobile
if (!MobileDetection.isTouchDevice()) return;
// Create accessory bar element
this.element = document.createElement('div');
this.element.className = 'keyboard-accessory-bar';
this.element.innerHTML = this._simpleButtons;
// Add click handlers — preventDefault stops event from reaching terminal
this.element.addEventListener('click', (e) => {
@@ -80,7 +125,8 @@ const KeyboardAccessoryBar = {
this.handleAction(action, btn);
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
if ((action === 'scroll-up' || action === 'scroll-down') ||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc']);
if (refocusActions.has(action) ||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
if (typeof app !== 'undefined' && app.terminal) {
app.terminal.focus();
@@ -95,6 +141,14 @@ const KeyboardAccessoryBar = {
}
},
/** Switch between 'simple' and 'extended' button layouts */
setMode(mode) {
if (mode === this._mode || !this.element) return;
this._mode = mode;
this.clearConfirm();
this.element.innerHTML = mode === 'extended' ? this._extendedButtons : this._simpleButtons;
},
_confirmTimer: null,
_confirmAction: null,
@@ -109,6 +163,27 @@ const KeyboardAccessoryBar = {
case 'scroll-down':
this.sendKey('\x1b[B');
break;
case 'arrow-left':
this.sendKey('\x1b[D');
break;
case 'arrow-right':
this.sendKey('\x1b[C');
break;
case 'esc':
this.sendKey('\x1b');
break;
case 'opt-enter':
this.sendKey('\x1b\r');
break;
case 'tab':
this.sendKey('\t');
break;
case 'shift-tab':
this.sendKey('\x1b[Z');
break;
case 'ctrl-o':
this.sendKey('\x0f');
break;
case 'init':
this.sendCommand('/init');
break;
+17 -1
View File
@@ -1,8 +1,24 @@
{
"name": "Codeman",
"short_name": "Codeman",
"description": "Claude Code session manager",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"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"
}
]
}
+69 -33
View File
@@ -301,9 +301,15 @@ html.mobile-init .file-browser-panel {
Phone Breakpoint (<430px)
============================================================================ */
@media (max-width: 430px) {
/* Hide header brand on phones */
/* Compact header brand on phones — acts as home button */
.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 */
@@ -886,7 +892,8 @@ html.mobile-init .file-browser-panel {
padding-right: calc(8px + var(--safe-area-right));
gap: 8px;
align-items: center;
justify-content: center;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
z-index: 51;
transition: transform 0.15s ease-out;
will-change: transform;
@@ -896,10 +903,16 @@ html.mobile-init .file-browser-panel {
display: flex;
}
/* 隐藏滚动条但保留滑动能力 */
.keyboard-accessory-bar::-webkit-scrollbar {
display: none;
}
.accessory-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 4px;
padding: 6px 12px;
background: #2a2a2a;
@@ -930,31 +943,33 @@ html.mobile-init .file-browser-panel {
.accessory-btn-arrow {
padding: 6px 10px;
background: #1e3a5f;
border-color: rgba(59, 130, 246, 0.3);
color: #93c5fd;
background: #2563eb;
border-color: rgba(59, 130, 246, 0.5);
color: #fff;
}
.accessory-btn-arrow:active {
background: #2563eb;
background: #1d4ed8;
}
.accessory-btn-dismiss {
padding: 8px 14px;
background: #2a2a2a;
border: 1.5px solid rgba(255, 255, 255, 0.25);
border-radius: 6px;
color: #e5e5e5;
margin-left: auto;
flex: 1 1 0;
max-width: 100px;
padding: 10px 8px;
background: #2563eb;
border-color: rgba(59, 130, 246, 0.5);
color: #fff;
font-weight: 600;
}
.accessory-btn-dismiss svg {
width: 22px;
height: 22px;
stroke-width: 3;
}
.accessory-btn-dismiss:active {
background: #3a3a3a;
background: #1d4ed8;
}
/* Voice preview — positioned above accessory bar on mobile */
@@ -986,9 +1001,9 @@ html.mobile-init .file-browser-panel {
background: rgba(0, 0, 0, 0.6);
z-index: 10000;
display: flex;
align-items: flex-end;
align-items: flex-start;
justify-content: center;
padding-bottom: env(safe-area-inset-bottom, 12px);
padding-top: 15vh;
}
.paste-dialog {
@@ -998,13 +1013,12 @@ html.mobile-init .file-browser-panel {
padding: 12px;
width: calc(100% - 24px);
max-width: 400px;
margin-bottom: 8px;
}
.paste-textarea {
width: 100%;
min-height: 60px;
max-height: 120px;
min-height: 80px;
max-height: 200px;
background: var(--bg-primary, #0d0d14);
color: var(--text-primary, #e0e0e0);
border: 1px solid var(--border-color, #444);
@@ -1181,6 +1195,20 @@ html.mobile-init .file-browser-panel {
touch-action: none;
}
/* Response viewer — show eye icon in header on mobile */
.btn-response-viewer-header {
display: inline-flex !important;
}
.response-viewer {
padding-bottom: var(--safe-area-bottom, 0px);
}
.response-viewer-body {
font-size: 12px;
padding: 12px;
}
/* Compact welcome overlay for mobile */
.welcome-content {
max-width: calc(100vw - 1.5rem);
@@ -2068,7 +2096,8 @@ html.mobile-init .file-browser-panel {
padding: 6px 8px;
gap: 8px;
align-items: center;
justify-content: center;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
z-index: 51;
}
@@ -2076,10 +2105,15 @@ html.mobile-init .file-browser-panel {
display: flex;
}
.keyboard-accessory-bar::-webkit-scrollbar {
display: none;
}
.accessory-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 4px;
padding: 6px 12px;
background: #2a2a2a;
@@ -2109,31 +2143,33 @@ html.mobile-init .file-browser-panel {
.accessory-btn-arrow {
padding: 6px 10px;
background: #1e3a5f;
border-color: rgba(59, 130, 246, 0.3);
color: #93c5fd;
background: #2563eb;
border-color: rgba(59, 130, 246, 0.5);
color: #fff;
}
.accessory-btn-arrow:active {
background: #2563eb;
background: #1d4ed8;
}
.accessory-btn-dismiss {
padding: 8px 14px;
background: #2a2a2a;
border: 1.5px solid rgba(255, 255, 255, 0.25);
border-radius: 6px;
color: #e5e5e5;
margin-left: auto;
flex: 1 1 0;
max-width: 80px;
padding: 10px 8px;
background: #334d6e;
border-color: rgba(100, 150, 200, 0.4);
color: #c0d4e8;
font-weight: 600;
}
.accessory-btn-dismiss svg {
width: 22px;
height: 22px;
stroke-width: 3;
width: 20px;
height: 20px;
}
.accessory-btn-dismiss:active {
background: #3a3a3a;
background: #3d5f85;
}
/* ============================================================================
+5
View File
@@ -2318,12 +2318,17 @@ Object.assign(CodemanApp.prototype, {
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()">&#x2B07;</a>`
: '';
html.push(`
<div class="file-tree-item${hiddenClass}" data-path="${escapeHtml(node.path)}" data-type="${node.type}" data-depth="${depth}">
${expandIcon}
<span class="file-tree-icon">${icon}</span>
<span class="${nameClass}">${escapeHtml(node.name)}</span>
${sizeStr}
${downloadBtn}
</div>
`);
+202 -20
View File
@@ -287,8 +287,8 @@ Object.assign(CodemanApp.prototype, {
});
const createCaseData = await createCaseRes.json();
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
// Use the newly created case data (API returns { success, case: { name, path } })
caseData = createCaseData.case;
// API returns { success, data: { case: { name, path } } }
caseData = createCaseData.data.case;
}
const workingDir = caseData.path;
@@ -298,7 +298,7 @@ Object.assign(CodemanApp.prototype, {
// Find the highest existing w-number for THIS case to avoid duplicates
let startNumber = 1;
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) {
const num = parseInt(match[1]);
if (num >= startNumber) {
@@ -425,14 +425,28 @@ Object.assign(CodemanApp.prototype, {
try {
// Get the case path
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;
if (!workingDir) throw new Error('Case path not found');
// Find the highest existing s-number for THIS case to avoid duplicates
let startNumber = 1;
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) {
const num = parseInt(match[1]);
if (num >= startNumber) {
@@ -596,8 +610,20 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
// Populate session name input
document.getElementById('modalSessionName').value = session.name || '';
// Populate session name input with prefix/suffix split
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
const currentColor = session.color || 'default';
@@ -644,7 +670,15 @@ Object.assign(CodemanApp.prototype, {
async saveSessionName() {
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 {
await this._apiPut(`/api/sessions/${this.editingSessionId}/name`, { name });
} catch (err) {
@@ -864,29 +898,46 @@ Object.assign(CodemanApp.prototype, {
if (!tabName) return;
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');
input.type = 'text';
input.value = session.name || '';
input.placeholder = currentName;
input.value = parsed ? parsed.suffix : (session.name || '');
input.placeholder = parsed ? 'Add description...' : currentName;
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;';
const originalContent = tabName.textContent;
tabName.textContent = '';
tabName.appendChild(input);
input.focus();
input.select();
const finishRename = async () => {
const newName = input.value.trim();
tabName.textContent = newName || originalContent;
const suffix = input.value.trim();
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 {
await fetch(`/api/sessions/${sessionId}/name`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
body: JSON.stringify({ name: fullName })
});
} catch (err) {
tabName.textContent = originalContent;
@@ -941,7 +992,7 @@ Object.assign(CodemanApp.prototype, {
const stored = localStorage.getItem('caseSettings_' + caseName);
if (stored) return JSON.parse(stored);
} catch { /* ignore */ }
return { agentTeams: false, opusContext1m: false };
return { agentTeams: false, opusContext1m: true };
},
saveCaseSettings(caseName, settings) {
@@ -1038,13 +1089,19 @@ Object.assign(CodemanApp.prototype, {
modal.querySelectorAll('.modal-tab-content').forEach(content => {
content.classList.toggle('hidden', content.id !== tabName);
});
// Update submit button text
// Update submit button (hide for manage tab)
const submitBtn = document.getElementById('caseModalSubmit');
submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link';
if (tabName === 'case-manage') {
submitBtn.style.display = 'none';
this.renderCaseManageList();
} else {
submitBtn.style.display = '';
submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link';
}
// Focus appropriate input
if (tabName === 'case-create') {
document.getElementById('newCaseName').focus();
} else {
} else if (tabName === 'case-link') {
document.getElementById('linkCaseName').focus();
}
},
@@ -1152,6 +1209,107 @@ Object.assign(CodemanApp.prototype, {
},
// ═══════════════════════════════════════════════════════════════
// Case Management (reorder + delete)
// ═══════════════════════════════════════════════════════════════
renderCaseManageList() {
const container = document.getElementById('caseManageList');
const cases = this.cases || [];
if (cases.length === 0) {
container.innerHTML = '<div class="form-hint" style="text-align: center; padding: 2rem 0;">No cases yet</div>';
return;
}
let html = '';
cases.forEach((c, idx) => {
const isFirst = idx === 0;
const isLast = idx === cases.length - 1;
const pathDisplay = c.path ? c.path.replace(/^\/Users\/[^/]+/, '~') : '';
html += `
<div class="case-manage-item" data-case="${escapeHtml(c.name)}">
<div class="case-manage-info">
<span class="case-manage-name">${escapeHtml(c.name)}</span>
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
</div>
<div class="case-manage-actions">
<button class="case-manage-btn" onclick="app.moveCaseUp('${escapeHtml(c.name)}')"
title="Move up" ${isFirst ? 'disabled' : ''}>&#x25B2;</button>
<button class="case-manage-btn" onclick="app.moveCaseDown('${escapeHtml(c.name)}')"
title="Move down" ${isLast ? 'disabled' : ''}>&#x25BC;</button>
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase('${escapeHtml(c.name)}')"
title="Delete case">&#x2715;</button>
</div>
</div>
`;
});
container.innerHTML = html;
},
async moveCaseUp(name) {
const cases = this.cases || [];
const idx = cases.findIndex(c => c.name === name);
if (idx <= 0) return;
// Swap positions (immutable)
const reordered = [...cases];
[reordered[idx - 1], reordered[idx]] = [reordered[idx], reordered[idx - 1]];
this.cases = reordered;
this.renderCaseManageList();
await this.saveCaseOrder(reordered.map(c => c.name));
},
async moveCaseDown(name) {
const cases = this.cases || [];
const idx = cases.findIndex(c => c.name === name);
if (idx < 0 || idx >= cases.length - 1) return;
const reordered = [...cases];
[reordered[idx], reordered[idx + 1]] = [reordered[idx + 1], reordered[idx]];
this.cases = reordered;
this.renderCaseManageList();
await this.saveCaseOrder(reordered.map(c => c.name));
},
async deleteCase(name) {
if (!confirm(`Delete case "${name}"? Linked cases will only be unlinked (folder preserved). Created cases will be permanently deleted.`)) {
return;
}
try {
const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success');
// Remove from current list and refresh
this.cases = (this.cases || []).filter(c => c.name !== name);
this.renderCaseManageList();
// Refresh the dropdown
const select = document.getElementById('quickStartCase');
const currentCase = select.value;
await this.loadQuickStartCases(currentCase === name ? null : currentCase);
} else {
this.showToast(data.error || 'Failed to delete case', 'error');
}
} catch (err) {
this.showToast('Failed to delete case: ' + err.message, 'error');
}
},
async saveCaseOrder(order) {
try {
await fetch('/api/cases/order', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order })
});
// Refresh dropdown to reflect new order
const select = document.getElementById('quickStartCase');
const currentCase = select.value;
await this.loadQuickStartCases(currentCase);
} catch (err) {
this.showToast('Failed to save case order: ' + err.message, 'error');
}
},
// ═══════════════════════════════════════════════════════════════
// Mobile Case Picker
// ═══════════════════════════════════════════════════════════════
@@ -1181,6 +1339,11 @@ Object.assign(CodemanApp.prototype, {
</svg>
</span>
<span class="mobile-case-item-name">${escapeHtml(c.name)}</span>
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile('${escapeHtml(c.name)}')" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</span>
<span class="mobile-case-item-check">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
@@ -1226,6 +1389,25 @@ Object.assign(CodemanApp.prototype, {
}
},
async deleteCaseMobile(name) {
if (!confirm(`Delete case "${name}"?`)) return;
try {
const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success');
this.cases = (this.cases || []).filter(c => c.name !== name);
// Refresh mobile picker and dropdown
this.closeMobileCasePicker();
await this.loadQuickStartCases();
} else {
this.showToast(data.error || 'Failed to delete case', 'error');
}
} catch (err) {
this.showToast('Failed to delete case: ' + err.message, 'error');
}
},
showCreateCaseFromMobile() {
// Close mobile picker first
this.closeMobileCasePicker();
+9 -4
View File
@@ -319,6 +319,7 @@ Object.assign(CodemanApp.prototype, {
this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
// Claude CLI settings
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
@@ -1125,6 +1126,7 @@ Object.assign(CodemanApp.prototype, {
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
// Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value,
@@ -1245,9 +1247,12 @@ Object.assign(CodemanApp.prototype, {
// Apply CJK input visibility immediately
this._updateCjkInputState();
// Apply keyboard bar mode
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
// Save to server (includes notification prefs for cross-browser persistence)
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, ...serverSettings } = settings;
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, ...serverSettings } = settings;
try {
await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings });
@@ -1278,7 +1283,7 @@ Object.assign(CodemanApp.prototype, {
// Default model
const defaultModelEl = document.getElementById('appSettingsDefaultModel');
if (defaultModelEl) {
defaultModelEl.value = config.defaultModel || 'opus';
defaultModelEl.value = config.defaultModel || '';
}
// Show recommendations
const showRecsEl = document.getElementById('appSettingsShowModelRecommendations');
@@ -1317,7 +1322,7 @@ Object.assign(CodemanApp.prototype, {
if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value;
const config = {
defaultModel: defaultModelEl?.value || 'opus',
defaultModel: defaultModelEl?.value || '',
showRecommendations: showRecsEl?.checked ?? true,
agentTypeOverrides,
};
@@ -1631,7 +1636,7 @@ Object.assign(CodemanApp.prototype, {
const displayKeys = new Set([
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
]);
// Merge settings: non-display keys always sync from server,
// display keys only seed from server when localStorage has no value
+381 -1
View File
@@ -330,6 +330,15 @@ body {
text-overflow: ellipsis;
}
.session-tab .tab-prefix {
color: var(--text-muted);
}
.session-tab .tab-suffix {
color: var(--text);
font-weight: 500;
}
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
.session-tab .tab-folder {
font-size: 0.6rem;
@@ -2224,6 +2233,24 @@ body {
text-align: right;
}
.history-show-more {
width: 100%;
padding: 0.5rem;
margin-top: 0.25rem;
background: rgba(255, 255, 255, 0.04);
border: 1px dashed rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: var(--text-muted);
font-size: 0.75rem;
cursor: pointer;
transition: background var(--transition-smooth), color var(--transition-smooth);
}
.history-show-more:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
}
.welcome-hint {
color: var(--text-muted);
font-size: 0.8rem;
@@ -2964,6 +2991,93 @@ body {
font-size: 0.6rem;
}
/* Case Management List (Manage tab) */
.case-manage-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 320px;
overflow-y: auto;
}
.case-manage-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
transition: background var(--transition-smooth);
}
.case-manage-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.case-manage-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.case-manage-name {
font-size: 0.8rem;
font-weight: 500;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.case-manage-path {
font-size: 0.65rem;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.case-manage-actions {
display: flex;
gap: 4px;
margin-left: 10px;
flex-shrink: 0;
}
.case-manage-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 4px;
color: var(--text-dim);
font-size: 0.65rem;
cursor: pointer;
transition: all var(--transition-smooth);
}
.case-manage-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: var(--text);
}
.case-manage-btn:disabled {
opacity: 0.25;
cursor: not-allowed;
}
.case-manage-btn-delete:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.toolbar-input {
padding: 0.4rem 0.5rem;
background: var(--bg-input);
@@ -3692,6 +3806,25 @@ body {
opacity: 1;
}
.mobile-case-item-delete {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
opacity: 0.4;
flex-shrink: 0;
border-radius: 4px;
transition: all var(--transition-smooth);
}
.mobile-case-item-delete:active {
opacity: 1;
color: #ef4444;
background: rgba(239, 68, 68, 0.15);
}
.mobile-case-picker-footer {
padding: 12px 20px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
@@ -5379,7 +5512,12 @@ kbd {
/* Responsive */
@media (max-width: 600px) {
.header-brand {
display: none;
padding-right: 0.4rem;
margin-right: 0.3rem;
}
.header-brand .logo {
font-size: 0.75rem;
}
.connection-text {
@@ -6450,6 +6588,22 @@ kbd {
padding-left: 0.5rem;
}
.file-tree-download {
display: none;
margin-left: auto;
padding: 0 4px;
color: #888;
text-decoration: none;
font-size: 12px;
flex-shrink: 0;
}
.file-tree-item:hover .file-tree-download {
display: inline;
}
.file-tree-download:hover {
color: #4fc3f7;
}
/* File tree indent levels */
.file-tree-item[data-depth="0"] { padding-left: 0.5rem; }
.file-tree-item[data-depth="1"] { padding-left: 1.25rem; }
@@ -7635,6 +7789,232 @@ kbd {
padding-left: 0.5rem;
}
/* ═══════════════════════════════════════════════════════════════
Response Viewer — native-scroll overlay for reading Claude responses
═══════════════════════════════════════════════════════════════ */
/* Hidden on desktop — only shown on mobile via mobile.css override */
.btn-response-viewer-header {
display: none !important;
}
.response-viewer {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 85vh;
background: #1a1a2e;
border-top: 1px solid #333;
border-radius: 12px 12px 0 0;
z-index: 5000;
flex-direction: column;
transform: translateY(100%);
transition: transform 0.25s ease-out;
}
.response-viewer.visible {
display: flex;
transform: translateY(0);
}
.response-viewer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #333;
flex-shrink: 0;
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
}
.response-viewer-actions {
display: flex;
align-items: center;
gap: 8px;
}
.response-viewer-more {
background: #2a2a4a;
border: 1px solid #444;
color: #aaa;
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
}
.response-viewer-more:active {
background: #3a3a5a;
}
.response-viewer-close {
background: none;
border: none;
color: #888;
font-size: 22px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
/* Conversation thread messages */
.rv-message {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #2a2a3a;
}
.rv-message:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.rv-role {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.rv-role-user {
color: #5c7cfa;
}
.rv-role-assistant {
color: #51cf66;
}
/* Markdown rendered content inside response viewer */
.rv-text {
word-break: break-word;
line-height: 1.6;
}
.rv-text p {
margin: 0 0 0.6em;
}
.rv-text p:last-child {
margin-bottom: 0;
}
.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4 {
color: #e0e0e0;
margin: 1em 0 0.4em;
line-height: 1.3;
}
.rv-text h1 { font-size: 1.3em; }
.rv-text h2 { font-size: 1.15em; }
.rv-text h3 { font-size: 1.05em; }
.rv-text code {
background: #2a2a3e;
padding: 1px 5px;
border-radius: 3px;
font-size: 0.9em;
}
.rv-text pre {
background: #1e1e2e;
border: 1px solid #333;
border-radius: 6px;
padding: 10px 12px;
overflow-x: auto;
margin: 0.6em 0;
}
.rv-text pre code {
background: none;
padding: 0;
font-size: 0.85em;
line-height: 1.5;
}
.rv-text ul, .rv-text ol {
margin: 0.4em 0;
padding-left: 1.4em;
}
.rv-text li {
margin-bottom: 0.2em;
}
.rv-text blockquote {
border-left: 3px solid #444;
margin: 0.6em 0;
padding: 0.3em 0 0.3em 12px;
color: #999;
}
.rv-text strong {
color: #f0f0f0;
}
.rv-text a {
color: #5c7cfa;
text-decoration: none;
}
.rv-text table {
border-collapse: collapse;
margin: 0.6em 0;
width: 100%;
font-size: 0.9em;
}
.rv-text th, .rv-text td {
border: 1px solid #333;
padding: 4px 8px;
text-align: left;
}
.rv-text th {
background: #2a2a3e;
color: #e0e0e0;
}
.rv-text hr {
border: none;
border-top: 1px solid #333;
margin: 1em 0;
}
.response-viewer-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 16px;
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, monospace;
font-size: 13px;
line-height: 1.5;
color: #d4d4d4;
}
.response-viewer-body:empty::after {
content: 'No response yet';
color: #555;
font-style: italic;
}
.response-viewer-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 4999;
}
.response-viewer-backdrop.visible {
display: block;
}
/* ═══════════════════════════════════════════════════════════════
CJK IME Input
═══════════════════════════════════════════════════════════════ */
+91 -16
View File
@@ -1,30 +1,105 @@
/**
* @fileoverview Service worker for Web Push notifications.
* @fileoverview Service worker for PWA install + Web Push notifications.
*
* Receives push events from the Codeman server (via web-push library) and displays
* OS-level notifications. Handles notification clicks to focus an existing Codeman
* tab or open a new one. Supports action buttons, per-session deep linking, and
* critical notification persistence (requireInteraction).
* App-shell caching: on install, precaches the core UI assets so the app
* launches instantly and works offline (or on flaky connections). Uses a
* network-first strategy for navigation and API calls, cache-first for
* static assets.
*
* Lifecycle: skipWaiting on install, claim clients on activate — ensures the latest
* service worker takes control immediately without waiting for tab refresh.
* Push notifications: receives push events from the Codeman server (via
* web-push library) and displays OS-level notifications. Handles notification
* clicks to focus an existing Codeman tab or open a new one.
*
* Lifecycle: skipWaiting on install, claim clients on activate -- ensures the
* latest service worker takes control immediately without waiting for tab
* refresh.
*
* @dependency None (runs in ServiceWorkerGlobalScope, isolated from page scripts)
* @see src/push-store.ts — server-side VAPID key management and subscription CRUD
* @see src/push-store.ts -- server-side VAPID key management and subscription CRUD
*/
// Codeman Service Worker — Web Push notifications
// This service worker receives push events from the server and displays OS-level notifications.
// It also handles notification clicks to focus or open the Codeman tab.
const CACHE_NAME = 'codeman-v1';
self.addEventListener('install', () => {
// Core app shell -- cached on install for instant startup
const APP_SHELL = [
'/',
'/styles.css',
'/mobile.css',
'/constants.js',
'/app.js',
'/api-client.js',
'/terminal-ui.js',
'/session-ui.js',
'/settings-ui.js',
'/panels-ui.js',
'/notification-manager.js',
'/mobile-handlers.js',
'/keyboard-accessory.js',
'/voice-input.js',
'/vendor/xterm.min.js',
'/vendor/xterm-addon-fit.min.js',
'/vendor/xterm-addon-unicode11.min.js',
'/vendor/xterm-zerolag-input.js',
'/vendor/xterm.css',
'/icon-192.png',
'/icon-512.png',
'/manifest.json',
];
// --- Install: precache app shell ---
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
// Use addAll but don't fail install if some assets 404 (hashed filenames)
return Promise.allSettled(
APP_SHELL.map((url) => cache.add(url).catch(() => {}))
);
})
);
self.skipWaiting();
});
// --- Activate: clean old caches, claim clients ---
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
)
).then(() => self.clients.claim())
);
});
// --- Fetch: network-first with cache fallback ---
// Network-first ensures deploys take effect immediately when online.
// Cache is only used when the network is unavailable (offline/flaky).
self.addEventListener('fetch', (event) => {
const { request } = event;
// Skip non-GET, WebSocket upgrades, and SSE streams
if (request.method !== 'GET') return;
if (request.headers.get('upgrade') === 'websocket') return;
if (request.headers.get('accept') === 'text/event-stream') return;
if (request.url.includes('/api/')) return;
event.respondWith(
fetch(request)
.then((response) => {
if (response && response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => caches.match(request))
);
});
// --- Push notifications ---
self.addEventListener('push', (event) => {
if (!event.data) return;
@@ -40,8 +115,8 @@ self.addEventListener('push', (event) => {
const options = {
body: body || '',
tag: tag || 'codeman-default',
icon: '/favicon.ico',
badge: '/favicon.ico',
icon: '/icon-192.png',
badge: '/icon-192.png',
data: { sessionId, url: sessionId ? `/?session=${sessionId}` : '/' },
renotify: true,
requireInteraction: urgency === 'critical',
@@ -75,7 +150,7 @@ self.addEventListener('notificationclick', (event) => {
return client.focus();
}
}
// No existing tab — open a new one
// No existing tab -- open a new one
return self.clients.openWindow(targetUrl);
})
);
+172 -68
View File
@@ -77,9 +77,72 @@ Object.assign(CodemanApp.prototype, {
// during composition, causing duplicate or garbled input.
this.terminal.attachCustomKeyEventHandler((ev) => {
if (ev.isComposing || ev.keyCode === 229) return false;
// Let Alt+digit pass through to browser (tab switching)
if (ev.altKey && ev.key >= '0' && ev.key <= '9') return false;
// Shift+Enter / Ctrl+Enter: insert newline for multi-line input.
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
// which the inner application recognizes as "insert newline" vs carriage return.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
if (this.activeSessionId) {
if (this._localEchoEnabled) {
const text = this._localEchoOverlay?.pendingText || '';
this._localEchoOverlay?.clear();
this._localEchoOverlay?.suppressBufferDetection();
this._flushedOffsets?.delete(this.activeSessionId);
this._flushedTexts?.delete(this.activeSessionId);
if (text) {
this._pendingInput += text;
flushInput();
}
setTimeout(() => {
fetch(`/api/sessions/${this.activeSessionId}/send-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
});
}, text ? 80 : 0);
} else {
fetch(`/api/sessions/${this.activeSessionId}/send-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
});
}
}
return false;
}
return true;
});
// Android virtual keyboard fix: catch non-composition input events.
// On Android Chrome, typing symbols (e.g., "/" from Gboard's symbol keyboard)
// sends keyCode 229 + input event WITHOUT compositionstart/end wrapping.
// The custom key handler above returns false for keyCode 229, telling xterm
// to ignore the keydown. This listener catches those orphaned input events.
{
const xtermTextarea = container.querySelector('.xterm-helper-textarea');
if (xtermTextarea && MobileDetection.isTouchDevice()) {
let composing = false;
xtermTextarea.addEventListener('compositionstart', () => { composing = true; });
xtermTextarea.addEventListener('compositionend', () => { composing = false; });
xtermTextarea.addEventListener('input', (e) => {
if (composing || e.isComposing) return;
if (e.inputType !== 'insertText' || !e.data) return;
const data = e.data;
Promise.resolve().then(() => {
const val = xtermTextarea.value;
if (!val || val.trim() === '') return;
this.terminal._core.coreService.triggerDataEvent(data, true);
xtermTextarea.value = '';
});
});
}
}
// WebGL renderer for GPU-accelerated terminal rendering.
// Previously caused "page unresponsive" crashes from synchronous GPU stalls,
// but the 48KB/frame flush cap in flushPendingWrites() now prevents
@@ -179,6 +242,7 @@ Object.assign(CodemanApp.prototype, {
// Accumulate sub-line pixel deltas so slow swipes still scroll
let pixelAccum = 0;
let didScroll = false; // track whether touchmove fired (tap vs scroll)
container.addEventListener(
'touchstart',
(ev) => {
@@ -187,6 +251,7 @@ Object.assign(CodemanApp.prototype, {
velocity = 0;
pixelAccum = 0;
isTouching = true;
didScroll = false;
lastTime = 0;
if (scrollFrame) {
cancelAnimationFrame(scrollFrame);
@@ -201,6 +266,7 @@ Object.assign(CodemanApp.prototype, {
'touchmove',
(ev) => {
if (ev.touches.length === 1 && isTouching) {
didScroll = true;
const touchY = ev.touches[0].clientY;
const delta = touchLastY - touchY; // positive = scroll down
pixelAccum += delta;
@@ -225,6 +291,12 @@ Object.assign(CodemanApp.prototype, {
if (!scrollFrame && Math.abs(velocity) > 0.3) {
scrollFrame = requestAnimationFrame(scrollLoop);
}
// Tap (no scroll): refocus xterm's hidden textarea so keyboard input
// routes back to the terminal. Without this, a tap on the terminal area
// consumes the touch event but xterm's textarea never regains focus.
if (!didScroll && this.terminal) {
this.terminal.focus();
}
},
{ passive: true }
);
@@ -284,22 +356,6 @@ Object.assign(CodemanApp.prototype, {
}
this.flushFlickerBuffer();
}
// Clear viewport + scrollback for Ink-based sessions before sending SIGWINCH.
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
// pushing overflow into scrollback. Ink's cursor-up count is based on the
// pre-reflow line count, so ghost renders accumulate in scrollback.
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
if (
activeResizeSession &&
activeResizeSession.mode !== 'shell' &&
!activeResizeSession._ended &&
this.terminal &&
this.isTerminalAtBottom()
) {
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
}
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
// causes Ink to re-render at the new row count, garbling terminal output.
// Local fit() still runs so xterm knows the viewport size for scrolling.
@@ -311,6 +367,24 @@ Object.assign(CodemanApp.prototype, {
const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS;
// Only send resize if dimensions actually changed
if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) {
// Clear viewport + scrollback ONLY when dimensions actually change.
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
// pushing overflow into scrollback. Ink's cursor-up count is based on the
// pre-reflow line count, so ghost renders accumulate in scrollback.
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
// IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed).
// Clearing without a subsequent Ink redraw leaves the terminal blank.
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
if (
activeResizeSession &&
activeResizeSession.mode !== 'shell' &&
!activeResizeSession._ended &&
this.terminal &&
this.isTerminalAtBottom()
) {
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
}
this._lastResizeDims = { cols, rows };
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
method: 'POST',
@@ -723,84 +797,110 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Fetch and deduplicate history sessions (up to 2 per dir, max `limit` total).
* @returns {Promise<Array>} deduplicated session list, sorted by lastModified desc
* Fetch and deduplicate history sessions (up to 3 per project, sorted by date).
* Uses projectKey for grouping because workingDir decoding is lossy.
* @returns {Promise<Array>} deduplicated session list, most recent first
*/
async _fetchHistorySessions(limit = 12) {
async _fetchHistorySessions() {
const res = await fetch('/api/history/sessions');
const data = await res.json();
const sessions = data.sessions || [];
if (sessions.length === 0) return [];
const byDir = new Map();
const byProject = new Map();
for (const s of sessions) {
if (!byDir.has(s.workingDir)) byDir.set(s.workingDir, []);
byDir.get(s.workingDir).push(s);
const key = s.projectKey || s.workingDir;
if (!byProject.has(key)) byProject.set(key, []);
byProject.get(key).push(s);
}
const items = [];
for (const [, group] of byDir) {
items.push(...group.slice(0, 2));
for (const [, group] of byProject) {
items.push(...group.slice(0, 3));
}
items.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
return items.slice(0, limit);
return items;
},
/** Build a single history item DOM element */
_buildHistoryItem(s) {
const size =
s.sizeBytes < 1024
? `${s.sizeBytes}B`
: s.sizeBytes < 1048576
? `${(s.sizeBytes / 1024).toFixed(0)}K`
: `${(s.sizeBytes / 1048576).toFixed(1)}M`;
const date = new Date(s.lastModified);
const timeStr =
date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) +
' ' +
date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
const item = document.createElement('div');
item.className = 'history-item';
item.title = s.workingDir;
item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
const textCol = document.createElement('div');
textCol.className = 'history-item-text';
const titleSpan = document.createElement('span');
titleSpan.className = 'history-item-title';
titleSpan.textContent = s.firstPrompt || shortDir;
const subtitleSpan = document.createElement('span');
subtitleSpan.className = 'history-item-subtitle';
subtitleSpan.textContent = shortDir;
textCol.append(titleSpan, subtitleSpan);
const metaSpan = document.createElement('span');
metaSpan.className = 'history-item-meta';
metaSpan.textContent = timeStr;
const sizeSpan = document.createElement('span');
sizeSpan.className = 'history-item-size';
sizeSpan.textContent = size;
item.append(textCol, metaSpan, sizeSpan);
return item;
},
/** Number of history items shown before "Show More" */
_HISTORY_INITIAL_COUNT: 4,
async loadHistorySessions() {
const container = document.getElementById('historySessions');
const list = document.getElementById('historyList');
if (!container || !list) return;
try {
const display = await this._fetchHistorySessions(12);
if (display.length === 0) {
const allSessions = await this._fetchHistorySessions(30);
if (allSessions.length === 0) {
container.style.display = 'none';
return;
}
// Build DOM safely (no innerHTML with user data)
list.replaceChildren();
for (const s of display) {
const size =
s.sizeBytes < 1024
? `${s.sizeBytes}B`
: s.sizeBytes < 1048576
? `${(s.sizeBytes / 1024).toFixed(0)}K`
: `${(s.sizeBytes / 1048576).toFixed(1)}M`;
const date = new Date(s.lastModified);
const timeStr =
date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) +
' ' +
date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/');
const initialCount = this._HISTORY_INITIAL_COUNT;
const item = document.createElement('div');
item.className = 'history-item';
item.title = s.workingDir;
item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
// Render initial items
for (let i = 0; i < Math.min(initialCount, allSessions.length); i++) {
list.appendChild(this._buildHistoryItem(allSessions[i]));
}
const textCol = document.createElement('div');
textCol.className = 'history-item-text';
const titleSpan = document.createElement('span');
titleSpan.className = 'history-item-title';
titleSpan.textContent = s.firstPrompt || shortDir;
const subtitleSpan = document.createElement('span');
subtitleSpan.className = 'history-item-subtitle';
subtitleSpan.textContent = shortDir;
textCol.append(titleSpan, subtitleSpan);
const metaSpan = document.createElement('span');
metaSpan.className = 'history-item-meta';
metaSpan.textContent = timeStr;
const sizeSpan = document.createElement('span');
sizeSpan.className = 'history-item-size';
sizeSpan.textContent = size;
item.append(textCol, metaSpan, sizeSpan);
list.appendChild(item);
// Add "Show More" button if there are more items
if (allSessions.length > initialCount) {
const moreBtn = document.createElement('button');
moreBtn.className = 'history-show-more';
moreBtn.textContent = `Show ${allSessions.length - initialCount} more`;
moreBtn.addEventListener('click', () => {
for (let i = initialCount; i < allSessions.length; i++) {
list.insertBefore(this._buildHistoryItem(allSessions[i]), moreBtn);
}
moreBtn.remove();
});
list.appendChild(moreBtn);
}
container.style.display = '';
@@ -1348,6 +1448,10 @@ Object.assign(CodemanApp.prototype, {
if (this.fitAddon) this.fitAddon.fit();
const dims = this.getTerminalDimensions();
if (!dims) return;
// Update _lastResizeDims so the throttledResize handler won't redundantly
// clear the terminal for the same dimensions (which would blank the screen
// without a subsequent Ink redraw to repaint it).
this._lastResizeDims = { cols: dims.cols, rows: dims.rows };
// Fast path: WebSocket resize
if (this._wsReady && this._wsSessionId === sessionId) {
try {
File diff suppressed because one or more lines are too long
+79 -4
View File
@@ -11,14 +11,15 @@ import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import type { ApiResponse, CaseInfo } from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
import { CreateCaseSchema, LinkCaseSchema, CaseOrderSchema } from '../schemas.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { writeHooksConfig } from '../../hooks-config.js';
import { CASES_DIR, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort } from '../ports/index.js';
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
async function readLinkedCases(): Promise<Record<string, string>> {
@@ -46,7 +47,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
try {
const entries = await fs.readdir(CASES_DIR, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory()) {
if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
cases.push({
name: e.name,
path: join(CASES_DIR, e.name),
@@ -62,7 +63,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const linkedCases = await readLinkedCases();
const existingNames = new Set(cases.map((c) => c.name));
for (const [name, path] of Object.entries(linkedCases)) {
if (!existingNames.has(name) && existsSync(path)) {
if (!existingNames.has(name) && SAFE_CASE_NAME.test(name) && existsSync(path)) {
cases.push({
name,
path,
@@ -71,6 +72,18 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
}
// Sort by persisted caseOrder from settings.json
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings', {});
const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : [];
if (caseOrder.length > 0) {
const orderMap = new Map(caseOrder.map((name, idx) => [name, idx]));
cases.sort((a, b) => {
const ai = orderMap.get(a.name) ?? Number.MAX_SAFE_INTEGER;
const bi = orderMap.get(b.name) ?? Number.MAX_SAFE_INTEGER;
return ai - bi;
});
}
return cases;
});
@@ -150,6 +163,68 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
});
// ========== Delete / Unlink Case ==========
app.delete('/api/cases/:name', async (req): Promise<ApiResponse<{ name: string }>> => {
const { name } = req.params as { name: string };
if (!validatePathWithinBase(name, CASES_DIR)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
// Check linked cases first — unlink only, don't delete the actual directory
const linkedCases = await readLinkedCases();
if (linkedCases[name]) {
delete linkedCases[name];
try {
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' });
return { success: true, data: { name } };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
}
// Case in CASES_DIR — delete the entire directory
const casePath = join(CASES_DIR, name);
if (!existsSync(casePath)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`);
}
try {
await fs.rm(casePath, { recursive: true, force: true });
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'deleted' });
return { success: true, data: { name } };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
// ========== Reorder Cases ==========
app.put('/api/cases/order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
try {
const dir = join(homedir(), '.codeman');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
} catch {
/* ignore */
}
const merged = { ...existing, caseOrder: order };
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
ctx.broadcast(SseEvent.CaseOrderChanged, { order });
return { success: true, data: { order } };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
app.get('/api/cases/:name', async (req) => {
const { name } = req.params as { name: string };
+13 -1
View File
@@ -239,7 +239,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
// Serve raw file content (for images/binary files)
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
const { id } = req.params as { id: string };
const { path: filePath } = req.query as { path?: string };
const { path: filePath, download } = req.query as { path?: string; download?: string };
const session = findSessionOrFail(ctx, id);
if (!filePath) {
@@ -292,6 +292,18 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
};
const content = await fs.readFile(resolvedPath);
if (download === 'true') {
const rawBasename = filePath!.split('/').pop() || 'download';
// Sanitize filename for Content-Disposition header (prevent header injection)
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
reply.raw.writeHead(200, {
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${basename}"`,
'Content-Length': content.length,
});
reply.raw.end(content);
return;
}
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
reply.send(content);
} catch (err) {
+1 -1
View File
@@ -308,7 +308,7 @@ export function registerRalphRoutes(
useMux: true,
mode: 'claude',
niceConfig,
model: rlModelConfig?.defaultModel,
model: rlModelConfig?.defaultModel || undefined,
claudeMode: rlClaudeModeConfig.claudeMode,
allowedTools: rlClaudeModeConfig.allowedTools,
});
+302 -26
View File
@@ -6,7 +6,9 @@
import { FastifyInstance } from 'fastify';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import {
ApiErrorCode,
@@ -52,6 +54,9 @@ import { RunSummaryTracker } from '../../run-summary.js';
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
// eslint-disable-next-line no-control-regex
const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
@@ -182,11 +187,45 @@ export function registerSessionRoutes(
}
}
// Pre-validate resumeSessionId: check that the conversation file actually exists
// in Claude's projects directory. If not, skip resume to avoid confusing
// "No conversation found" errors from Claude CLI.
let validatedResumeId = body.resumeSessionId;
if (validatedResumeId) {
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
let found = false;
try {
const projectDirs = await fs.readdir(projectsDir);
for (const projDir of projectDirs) {
const sessionFile = join(projectsDir, projDir, `${validatedResumeId}.jsonl`);
try {
const stat = await fs.stat(sessionFile);
if (stat.size > 4000) {
found = true;
break;
}
} catch {
// File doesn't exist in this project dir
}
}
} catch {
// Projects dir doesn't exist
}
if (!found) {
console.log(`[Session] Resume session ${validatedResumeId} not found on disk, starting fresh`);
validatedResumeId = undefined;
}
}
const globalNice = await ctx.getGlobalNiceConfig();
const modelConfig = await ctx.getModelConfig();
const mode = body.mode || 'claude';
const model =
mode === 'opencode' ? body.openCodeConfig?.model : mode !== 'shell' ? modelConfig?.defaultModel : undefined;
mode === 'opencode'
? body.openCodeConfig?.model
: mode !== 'shell'
? modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
const session = new Session({
workingDir,
@@ -199,7 +238,7 @@ export function registerSessionRoutes(
claudeMode: claudeModeConfig.claudeMode,
allowedTools: claudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
resumeSessionId: body.resumeSessionId,
resumeSessionId: validatedResumeId,
});
ctx.addSession(session);
@@ -490,6 +529,46 @@ export function registerSessionRoutes(
return { success: true };
});
// ========== Send Named Key (tmux send-keys -H) ==========
// Sends raw hex bytes to tmux pane for keys like Shift+Enter / Ctrl+Enter.
// Uses send-keys -H (hex) to inject 0x0a (line feed) which Claude Code's
// Ink input recognizes as "insert newline" vs 0x0d (carriage return = submit).
app.post('/api/sessions/:id/send-key', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const body = req.body as Record<string, unknown>;
const key = typeof body?.key === 'string' ? body.key : '';
// Map key names to hex byte sequences
const KEY_HEX_MAP: Record<string, string[]> = {
'S-Enter': ['0a'], // \n (line feed)
'C-Enter': ['0a'], // \n (line feed)
};
const hex = KEY_HEX_MAP[key];
if (!hex) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
}
const session = findSessionOrFail(ctx, id);
const muxName = session.muxName;
if (!muxName) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
}
try {
await new Promise<void>((resolve, reject) => {
execFile('tmux', ['send-keys', '-H', '-t', muxName, ...hex], { timeout: 5000 }, (err) => {
if (err) reject(err);
else resolve();
});
});
} catch (err) {
console.error('[Server] send-key failed:', err);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
}
return { success: true };
});
// ========== Resize Terminal ==========
app.post('/api/sessions/:id/resize', async (req): Promise<ApiResponse> => {
@@ -501,6 +580,117 @@ export function registerSessionRoutes(
return { success: true };
});
// ========== Get Last Response (from transcript JSONL) ==========
app.get('/api/sessions/:id/last-response', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id);
// The Claude conversation ID (used as JSONL filename)
const claudeSessionId = session.claudeSessionId || session.id;
// Scan ~/.claude/projects/*/ for the transcript file
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
let transcriptText = '';
let transcriptTimestamp = '';
try {
const projectDirs = await fs.readdir(projectsDir);
for (const projDir of projectDirs) {
const jsonlPath = join(projectsDir, projDir, `${claudeSessionId}.jsonl`);
try {
const content = await fs.readFile(jsonlPath, 'utf8');
const lines = content.trim().split('\n');
// Search from end for last assistant message with text
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
if (entry.type === 'assistant' && entry.message?.content) {
const blocks = Array.isArray(entry.message.content)
? entry.message.content
: [{ type: 'text', text: String(entry.message.content) }];
const textBlocks = blocks
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
.map((b: { type: string; text?: string }) => b.text);
if (textBlocks.length > 0) {
transcriptText = textBlocks.join('\n\n');
transcriptTimestamp = entry.timestamp || '';
break;
}
}
} catch {
// Skip unparseable lines
}
}
if (transcriptText) break; // Found it, stop scanning directories
} catch {
// File doesn't exist in this project dir, continue
}
}
} catch {
// projects dir doesn't exist
}
// If ?context=full, return all user+assistant messages for conversation view
const query = req.query as { context?: string };
if (query.context === 'full' && transcriptText) {
const allMessages: Array<{ role: string; text: string; timestamp?: string }> = [];
try {
const projectDirs = await fs.readdir(projectsDir);
for (const projDir of projectDirs) {
const jsonlPath = join(projectsDir, projDir, `${claudeSessionId}.jsonl`);
try {
const content = await fs.readFile(jsonlPath, 'utf8');
const lines = content.trim().split('\n');
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.type === 'user' && entry.message?.content) {
const text =
typeof entry.message.content === 'string'
? entry.message.content
: (entry.message.content as Array<{ type: string; text?: string }>)
.filter((b) => b.type === 'text' && b.text)
.map((b) => b.text)
.join('\n');
// Skip system/command messages
if (text && !text.startsWith('<local-command') && !text.startsWith('<command-name>')) {
allMessages.push({ role: 'user', text, timestamp: entry.timestamp });
}
} else if (entry.type === 'assistant' && entry.message?.content) {
const blocks = Array.isArray(entry.message.content)
? entry.message.content
: [{ type: 'text', text: String(entry.message.content) }];
const text = blocks
.filter((b: { type: string; text?: string }) => b.type === 'text' && b.text)
.map((b: { type: string; text?: string }) => b.text)
.join('\n\n');
if (text) {
allMessages.push({ role: 'assistant', text, timestamp: entry.timestamp });
}
}
} catch {
/* skip */
}
}
if (allMessages.length > 0) break;
} catch {
/* continue */
}
}
} catch {
/* ignore */
}
return { text: transcriptText, timestamp: transcriptTimestamp, messages: allMessages };
}
return {
text: transcriptText,
timestamp: transcriptTimestamp,
};
});
// ========== Get Terminal Buffer ==========
// Query params:
@@ -733,12 +923,23 @@ export function registerSessionRoutes(
}
}
const casePath = validatePathWithinBase(caseName, CASES_DIR);
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
// external project directories are honoured by quick-start just like regular case routes.
let linkedCases: Record<string, string> = {};
try {
const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
linkedCases = JSON.parse(raw);
} catch {
// File missing or unparseable — treat as empty registry
}
const linkedCasePath = linkedCases[caseName];
const casePath = linkedCasePath || validatePathWithinBase(caseName, CASES_DIR);
if (!casePath) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
}
// Create case folder and CLAUDE.md if it doesn't exist
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked cases)
if (!existsSync(casePath)) {
try {
mkdirSync(casePath, { recursive: true });
@@ -766,7 +967,11 @@ export function registerSessionRoutes(
const niceConfig = await ctx.getGlobalNiceConfig();
const qsModelConfig = await ctx.getModelConfig();
const qsModel =
mode === 'opencode' ? openCodeConfig?.model : mode !== 'shell' ? qsModelConfig?.defaultModel : undefined;
mode === 'opencode'
? openCodeConfig?.model
: mode !== 'shell'
? qsModelConfig?.defaultModel || undefined
: undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const session = new Session({
workingDir: casePath,
@@ -908,7 +1113,7 @@ export function registerSessionRoutes(
text.length < 8
)
continue;
return text.length > MAX_PROMPT_LEN ? text.slice(0, MAX_PROMPT_LEN) + '…' : text;
return text.length > MAX_PROMPT_LEN ? text.slice(0, MAX_PROMPT_LEN) + '\u2026' : text;
} catch {
// Malformed line — skip
}
@@ -916,6 +1121,74 @@ export function registerSessionRoutes(
return undefined;
}
/**
* Decode a Claude project key (e.g. "-Users-teigen-Documents-Workspace-AI-project-Mirror")
* back to a filesystem path ("/Users/teigen/Documents/Workspace/AI_project/Mirror").
*
* Claude CLI encodes both '/' and '_' as '-', so each '-' in the key could be
* any of: '/' (path separator), '_' (underscore), or '-' (literal dash).
*
* Strategy: look-ahead matching. At each '-', try consuming multiple segments
* joined by '_' or '-' to find an existing child directory, then recurse.
* E.g. for segments [AI, project, Mirror] inside /Workspace:
* try /Workspace/AI (no) -> /Workspace/AI_project (yes!) -> continue with [Mirror]
*/
async function decodeProjectKey(projKey: string): Promise<string> {
const encoded = projKey.startsWith('-') ? projKey.slice(1) : projKey;
const segments = encoded.split('-');
const isDir = async (p: string): Promise<boolean> =>
fs
.stat(p)
.then((s) => s.isDirectory())
.catch(() => false);
let current = '';
let i = 0;
while (i < segments.length) {
// Try progressively longer child names by joining segments with '_' or '-'
let matched = false;
// Limit look-ahead to avoid excessive fs checks (max 4 segments per component)
const maxLook = Math.min(i + 4, segments.length);
for (let end = i; end < maxLook; end++) {
// Build candidate child name from segments[i..end]
// Try all separator combinations: for 2+ segments, try '_' first then '-'
const candidates: string[] = [];
if (end === i) {
candidates.push(segments[i]);
} else {
// Build with underscores between joined segments
candidates.push(segments.slice(i, end + 1).join('_'));
// Build with dashes (literal)
candidates.push(segments.slice(i, end + 1).join('-'));
}
for (const child of candidates) {
const candidate = current + '/' + child;
if (await isDir(candidate)) {
current = candidate;
i = end + 1;
matched = true;
break;
}
}
if (matched) break;
}
if (!matched) {
// No directory match found — append as-is and move on
current = current + '/' + segments[i];
i++;
}
}
const finalExists = await fs
.access(current)
.then(() => true)
.catch(() => false);
return finalExists ? current : process.env.HOME || '/tmp';
}
/** Read the first 16KB of a file for content sniffing. */
async function readFileHead(path: string, buf: Buffer): Promise<string | null> {
try {
@@ -966,15 +1239,11 @@ export function registerSessionRoutes(
const stat = await fs.stat(projPath).catch(() => null);
if (!stat?.isDirectory()) continue;
// Decode project key to working dir. The encoding replaces '/' with '-',
// which is lossy when path components contain '-'. Do naive decode first,
// then verify it exists. Fall back to HOME if the decoded path is invalid.
const naiveDecode = projDir.replace(/^-/, '/').replace(/-/g, '/');
const dirExists = await fs
.access(naiveDecode)
.then(() => true)
.catch(() => false);
const workingDir = dirExists ? naiveDecode : process.env.HOME || '/tmp';
// Decode project key to working dir. Claude CLI encodes '/' as '-',
// but path components may also contain '-' (e.g. "AI_project" vs "AI-project").
// Use recursive backtracking: try each '-' as either '/' or literal '-',
// verify which decoded path actually exists on disk.
const workingDir = await decodeProjectKey(projDir);
const entries = await fs.readdir(projPath);
for (const entry of entries) {
@@ -996,24 +1265,31 @@ export function registerSessionRoutes(
// Read first 16KB to check content and extract first user prompt.
let firstPrompt: string | undefined;
const head = await readFileHead(filePath, headBuf);
const hasConversation = (text: string) =>
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
if (fileStat.size < 50000) {
if (
!head ||
(!head.includes('"type":"user"') &&
!head.includes('"type":"assistant"') &&
!head.includes('"type":"summary"'))
) {
continue; // No conversation content — skip
}
let foundContent = head ? hasConversation(head) : false;
// For large files, head may not contain user messages (e.g. /init followed
// by large system entries). Check the tail as well.
let tail: string | null = null;
if (!foundContent && fileStat.size > 16384) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
if (tail) foundContent = hasConversation(tail);
}
if (!foundContent) continue; // No conversation content — skip
if (head) firstPrompt = extractFirstUserPrompt(head);
// If head scan found no usable prompt (e.g. session started with /init),
// try reading the tail for a recent user message.
if (!firstPrompt && fileStat.size > 65536) {
const tailBuf = Buffer.alloc(32768);
const tail = await readFileTail(filePath, tailBuf, fileStat.size);
if (!tail) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
}
if (tail) firstPrompt = extractFirstUserPrompt(tail);
}
+5
View File
@@ -431,6 +431,11 @@ export const LinkCaseSchema = z.object({
path: safePathSchema,
});
/** PUT /api/cases/order */
export const CaseOrderSchema = z.object({
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
});
/** POST /api/auth/revoke */
export const RevokeSessionSchema = z.object({
sessionToken: z.string().min(1).max(200).optional(),
+23 -9
View File
@@ -544,7 +544,8 @@ export class WebServer extends EventEmitter {
cacheControl: false,
preCompressed: true,
setHeaders: (res, path) => {
if (path.endsWith('.html')) {
// Use .includes() not .endsWith() — preCompressed serves .html.br/.html.gz
if (path.includes('.html')) {
res.setHeader('Cache-Control', 'no-cache');
} else {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
@@ -1766,15 +1767,28 @@ export class WebServer extends EventEmitter {
this.sessions.set(session.id, session);
await this.setupSessionListeners(session);
this.persistSessionState(session);
// Mark it as restored (not started yet - user needs to attach)
getLifecycleLog().log({
event: 'recovered',
sessionId: session.id,
name: session.name,
});
console.log(`[Server] Restored session ${session.id} from mux ${muxSession.muxName}`);
// Auto-attach PTY to the surviving tmux session immediately.
// This ensures ALL sessions resume capturing output right away,
// not just the one the client happens to select first.
try {
await session.startInteractive();
getLifecycleLog().log({
event: 'recovered',
sessionId: session.id,
name: session.name,
});
console.log(`[Server] Restored and attached session ${session.id} from mux ${muxSession.muxName}`);
} catch (attachErr) {
console.error(`[Server] Failed to attach session ${session.id}, keeping as detached:`, attachErr);
getLifecycleLog().log({
event: 'recovered',
sessionId: session.id,
name: session.name,
});
}
this.persistSessionState(session);
}
}
+6
View File
@@ -325,6 +325,10 @@ export const OrchestratorError = 'orchestrator:error' as const;
export const CaseCreated = 'case:created' as const;
/** Existing directory linked as a case. */
export const CaseLinked = 'case:linked' as const;
/** Case deleted or unlinked. */
export const CaseDeleted = 'case:deleted' as const;
/** Case ordering changed. */
export const CaseOrderChanged = 'case:order-changed' as const;
// ─── Namespace Re-export ─────────────────────────────────────────────────────
@@ -485,4 +489,6 @@ export const SseEvent = {
// Cases
CaseCreated,
CaseLinked,
CaseDeleted,
CaseOrderChanged,
} as const;
-4
View File
@@ -1,4 +0,0 @@
{
"status": "failed",
"failedTests": []
}
@@ -18,20 +18,20 @@ Validates Codeman's mobile UI across 135 devices, covering:
```bash
# Run all mobile tests
npx vitest run --config mobile-test/vitest.config.ts
npx vitest run --config test/mobile/vitest.config.ts
# Run a single test file
npx vitest run --config mobile-test/vitest.config.ts mobile-test/keyboard.test.ts
npx vitest run --config test/mobile/vitest.config.ts test/mobile/keyboard.test.ts
# Quick mode — 6 representative devices, skip full matrix
CI_QUICK=1 npx vitest run --config mobile-test/vitest.config.ts
CI_QUICK=1 npx vitest run --config test/mobile/vitest.config.ts
# Full device matrix only (135 devices)
npx vitest run --config mobile-test/vitest.config.ts mobile-test/device-matrix.test.ts
npx vitest run --config test/mobile/vitest.config.ts test/mobile/device-matrix.test.ts
# Update visual baselines (delete old baselines, re-run)
rm -rf mobile-test/snapshots/*.png
npx vitest run --config mobile-test/vitest.config.ts mobile-test/visual-regression.test.ts
rm -rf test/mobile/snapshots/*.png
npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regression.test.ts
```
## Test Files
@@ -126,7 +126,7 @@ Swipe simulation includes intermediate move points and timing to satisfy `SwipeH
Uses `pixelmatch` + `pngjs` (both in devDeps) for pixel-level comparison:
- **Baselines** stored in `mobile-test/snapshots/` (git-tracked)
- **Baselines** stored in `test/mobile/snapshots/` (git-tracked)
- **Tolerance**: 0.5% pixel diff (handles anti-aliasing)
- **On failure**: `.actual.png` and `.diff.png` generated (gitignored)
- **Update**: Delete baseline PNGs and re-run — new baselines auto-created
@@ -96,7 +96,7 @@ export const SUBAGENT = {
export const VISUAL = {
DEFAULT_THRESHOLD: 0.1,
MAX_DIFF_PERCENT: 0.5,
SNAPSHOT_DIR: 'mobile-test/snapshots',
SNAPSHOT_DIR: 'test/mobile/snapshots',
} as const;
// Visual regression breakpoints (width values for screenshot comparison)

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Some files were not shown because too many files have changed in this diff Show More