Compare commits

..
Author SHA1 Message Date
arkonandClaude Opus 4.6 78c568e5f7 test: add automated tests for WebSocket terminal I/O route
16 tests covering session-not-found close code, terminal output with
DEC 2026 sync markers, client input forwarding, resize bounds
validation, malformed message handling, and connection cleanup of
session event listeners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 18:00:55 +01:00
arkonandClaude Opus 4.6 5844720525 fix: validate WS resize dimensions to match HTTP route bounds
The HTTP resize route validates via ResizeSchema (cols: 1-500, rows:
1-200, integers only). The WS handler only checked typeof === 'number',
allowing floats, negatives, and extreme values through to ptyProcess.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 17:57:20 +01:00
Aaron FieldsandClaude Opus 4.6 ceaf4624a1 feat: add WebSocket terminal I/O with server-side DEC 2026 sync
Replace per-keystroke HTTP POST + SSE terminal output with a single
bidirectional WebSocket connection for dramatically lower input latency.
The existing SSE+POST paths remain fully functional as fallback.

Server-side: ws-routes.ts provides /ws/sessions/:id/terminal with 8ms
micro-batching and 16KB flush threshold. Each batch is wrapped in
DEC 2026 synchronized update markers so xterm.js renders atomically —
Ink's DA capability negotiation fails through the PTY→server→WS proxy
chain, so without server-injected markers, cursor-up redraws flicker.

Frontend: _connectWs/_disconnectWs manage per-session WS lifecycle.
Input and resize use WS fast path with HTTP POST fallback. SSE terminal
events are suppressed when WS is active to prevent double rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:02:28 -04:00
arkonandClaude Opus 4.6 a6597e4a9a fix: patch 5 dependency vulnerabilities (basic-ftp, fastify, minimatch, serialize-javascript)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:26:56 +01:00
arkon f869e823af chore: version packages 2026-03-12 23:59:16 +01:00
arkonandClaude Opus 4.6 8d0b179f94 fix: repair 15 pre-existing subagent-watcher test failures
Root causes:
- Mock readline (EventEmitter) lacked .close() method, causing TypeError
  that blocked extractDescriptionFromFile's Promise from ever resolving
- Mock stream lacked .destroy() method (same issue after .close() fix)
- Entry-processing tests shared one readline mock between description
  extraction and tailing — events emitted before tailFile started were lost
- Liveness checker marked agents as 'completed' instead of 'idle' because
  fixed stat timestamps became stale after fake timer advancement

Fixes:
- Add createMockRl() helper with .close() method
- Use { destroy: vi.fn() } for stream mocks
- Use mockReturnValueOnce() for two-readline pattern in 7 entry tests
- Use mockImplementation() for dynamic stat timestamps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:08:39 +01:00
arkonandClaude Opus 4.6 98fa55b7b2 chore: codebase cleanup — remove dead code, consolidate imports, extract constants
- Remove 3 unused exported constants (TRIM_MESSAGES_TO, MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS)
- Consolidate 8 direct util imports into barrel imports (./utils/index.js)
- Extract magic number 8191 to FILE_PEEK_BYTES constant in buffer-limits.ts
- Add explanatory comments to 9 undocumented .catch(() => {}) handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:50:40 +01:00
arkonandClaude Opus 4.6 c46ac30631 fix: hide subagent monitor panel by default
Change showSubagents default from true to false so the subagent
panel doesn't auto-show on page load. Users can still enable it
via Settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:35:27 +01:00
arkonandClaude Opus 4.6 dfcc14bfd2 fix: one-liner restart command that works for background processes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:34:42 +01:00
arkonandClaude Opus 4.6 a068008409 fix: clarify restart instructions — stop first, then start
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:33:34 +01:00
arkonandClaude Opus 4.6 0aa31f100e fix: show restart command when codeman-web is not a systemd service
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:31:19 +01:00
arkonandClaude Opus 4.6 314a160458 feat: auto-restart codeman-web service after update if running
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:23:55 +01:00
arkonandClaude Opus 4.6 e7ee5595c5 feat: auto-detect existing install and run update instead of fresh install
Re-running the install script now detects ~/.codeman/app/.git and
automatically updates instead of re-installing. Removes the separate
`bash -s update` instructions from README since it's no longer needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:09:37 +01:00
arkon 625d4976d3 chore: version packages 2026-03-11 19:37:17 +01:00
arkonandClaude Opus 4.6 d02cddece6 fix: correct claudeSessionId for resumed sessions and clean up DEC sync dead code
Use resumeSessionId for Claude conversation ID when resuming sessions,
increase default font size to 14, extract shared history fetch logic,
and remove unused DEC 2026 sync constants/functions (xterm.js 6.0 handles natively).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:36:58 +01:00
Ark0N abbc4b13fd Merge pull request #39 from sunnyzhouy/master
feat: session resume, xterm.js 6.0 upgrade, and resize fix
2026-03-11 19:20:28 +01:00
sunnyzhouy 754a966b53 Merge branch 'Ark0N:master' into master 2026-03-12 01:06:14 +08:00
zhouyuan 28dfc279d4 fix: resolve terminal resize scrollback ghost renders
- Switch resize handler to 300ms trailing-edge debounce for single reflow
- Add \x1b[3J (Erase Saved Lines) to clear scrollback reflow debris
- Remove client-side cursor-up flicker filter and DEC 2026 marker
  stripping — xterm.js 6.0 handles synchronized output natively
- Remove server-side DEC 2026 wrapping to prevent premature sync exit
  from non-reference-counted nested markers
2026-03-12 01:04:21 +08:00
zhouyuan 2329dab240 feat: upgrade xterm.js 5.3 to 6.0 for native DEC 2026 synchronized output
xterm.js 6.0.0 natively handles DEC mode 2026 (synchronized output),
which renders Ink's cursor-up redraws atomically at the parser level.
This eliminates split-frame rendering that caused table header loss
and garbled overlapping text in Claude CLI sessions.

- Migrate from xterm/xterm-addon-* to @xterm/* scoped packages
- Update build.mjs and postinstall.js vendor paths
- Remove old xterm 5.x dependencies
2026-03-10 14:59:26 +08:00
zhouyuan 31ce7405a6 perf: increase terminal scrollback from 5000 to 20000 lines
Long-running Claude sessions can exceed 5000 lines easily, causing
earlier content to be lost. 20000 lines retains ~4x more history.
2026-03-10 14:36:10 +08:00
zhouyuan 06f7d40c42 feat: reduce default font size and persist tabs across refresh
- Default terminal font 14px → 12px, min 10px → 8px
- Save tab metadata to localStorage on every render
- Restore ended sessions as dimmed tabs after page refresh
- Ended tabs show "Session ended" message on click
2026-03-10 14:30:21 +08:00
zhouyuan 05eba70598 feat: improve session resume reliability and persist user settings
- Filter empty sessions from history API (check for conversation content)
- Add --resume fallback to new session if resume fails (prevents dead panes)
- Pass resumeSessionId through respawnPane for dead pane recovery
- Persist respawn presets and runMode to server settings (cross-device sync)
- Fix mobile touch handling for Recent Sessions dropdown (DOM API + touch CSS)
2026-03-10 14:03:42 +08:00
zhouyuan d27974ff6e chore: update package-lock.json 2026-03-10 02:23:15 +08:00
zhouyuan 3cca5380ba fix: route shell sessions to correct endpoint on tab click
selectSession() was always calling /interactive for restored idle
sessions regardless of mode. Shell sessions now correctly call /shell.
Also add loadHistorySessions and resumeHistorySession to frontend.
2026-03-10 02:23:10 +08:00
zhouyuan 6d7efc13e6 feat: add history session resume UI and API
Add GET /api/history/sessions endpoint that scans Claude conversation
files for resume. Add welcome overlay UI with clickable history items.
Path decoding validates existence via fs.access with HOME fallback.
2026-03-10 02:23:02 +08:00
zhouyuan 63f86807ad feat: add resumeSessionId support for conversation resume after reboot
Add resumeSessionId field throughout the session creation pipeline,
allowing sessions to resume previous Claude conversations via --resume
flag instead of --session-id.
2026-03-10 02:22:53 +08:00
35 changed files with 1801 additions and 522 deletions
+45
View File
@@ -1,5 +1,50 @@
# aicodeman # aicodeman
## 0.3.11
### Patch Changes
- ### Session Resume & History
- Add `resumeSessionId` support for conversation resume after reboot
- Add history session resume UI and API with route shell sessions routing fix
- Improve session resume reliability and persist user settings across refresh
- Correct `claudeSessionId` for resumed sessions
### Terminal & Frontend
- Upgrade xterm.js 5.3 → 6.0 with native DEC 2026 synchronized output
- Increase terminal scrollback from 5,000 to 20,000 lines
- Reduce default font size and persist tab state across refresh
- Resolve terminal resize scrollback ghost renders
- Hide subagent monitor panel by default
### Installer
- Auto-detect existing install and run update instead of fresh install
- Auto-restart codeman-web service after update if running
- Show restart command when codeman-web is not a systemd service
- Fix one-liner restart command for background processes
### Codebase Quality
- Remove dead code, consolidate imports, extract constants
- Repair 15 pre-existing subagent-watcher test failures
- Clean up DEC sync dead code
## 0.3.10
### Patch Changes
- - feat: upgrade xterm.js from 5.3 to 6.0 with native DEC 2026 synchronized output support
- feat: add history session resume UI and API — resume Claude conversations after reboot
- feat: add resumeSessionId support for conversation resume across session restarts
- feat: persist active tabs across page refresh
- feat: improve session resume reliability and persist user settings
- perf: increase terminal scrollback from 5,000 to 20,000 lines
- fix: resolve terminal resize scrollback ghost renders
- fix: route shell sessions to correct endpoint on tab click
- fix: correct claudeSessionId for resumed sessions (use original Claude conversation ID)
- fix: increase default desktop font size from 12 to 14
- refactor: extract shared \_fetchHistorySessions() method to eliminate duplication
- refactor: remove dead DEC 2026 sync code (extractSyncSegments, DEC_SYNC_START/END constants)
## 0.3.9 ## 0.3.9
### Patch Changes ### Patch Changes
+1 -1
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` 4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web` 5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
**Version**: 0.3.9 (must match `package.json`) **Version**: 0.3.11 (must match `package.json`)
## Project Overview ## Project Overview
-5
View File
@@ -35,11 +35,6 @@ codeman web
# Open http://localhost:3000 — press Ctrl+Enter to start your first session # Open http://localhost:3000 — press Ctrl+Enter to start your first session
``` ```
**Update to latest version:**
```bash
curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash -s update
```
<details> <details>
<summary><strong>Run as a background service</strong></summary> <summary><strong>Run as a background service</strong></summary>
Binary file not shown.
+18 -2
View File
@@ -1283,7 +1283,16 @@ update() {
npm run build --quiet 2>/dev/null || npm run build npm run build --quiet 2>/dev/null || npm run build
success "Updated to $(node -e "console.log(require('./package.json').version)")" success "Updated to $(node -e "console.log(require('./package.json').version)")"
echo "" echo ""
echo -e " ${DIM}Restart codeman web to use the new version.${NC}"
# Auto-restart systemd service if it's running, otherwise tell the user
if systemctl --user is-active codeman-web.service &>/dev/null; then
info "Restarting codeman-web service..."
systemctl --user restart codeman-web.service
success "codeman-web service restarted"
else
echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
echo -e " ${CYAN}pkill -f 'codeman.*web'; codeman web &${NC}"
fi
echo "" echo ""
} }
@@ -1355,5 +1364,12 @@ uninstall() {
case "${1:-}" in case "${1:-}" in
update) update ;; update) update ;;
uninstall) uninstall ;; uninstall) uninstall ;;
*) main "$@" ;; *)
if [[ -z "${1:-}" && -d "$INSTALL_DIR/.git" ]]; then
print_banner
update
else
main "$@"
fi
;;
esac esac
+140 -56
View File
@@ -1,12 +1,12 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "0.3.1", "version": "0.3.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aicodeman", "name": "aicodeman",
"version": "0.3.1", "version": "0.3.11",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
@@ -17,8 +17,11 @@
"@fastify/compress": "^8.3.1", "@fastify/compress": "^8.3.1",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.0.0", "@fastify/static": "^8.0.0",
"@remotion/compositor-linux-x64-gnu": "^4.0.432", "@fastify/websocket": "^11.2.0",
"@rspack/binding-linux-x64-gnu": "^1.7.7", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"commander": "^12.1.0", "commander": "^12.1.0",
@@ -27,10 +30,6 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-unicode11": "^0.6.0",
"xterm-addon-webgl": "^0.16.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"bin": { "bin": {
@@ -47,6 +46,7 @@
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"agent-browser": "^0.6.0", "agent-browser": "^0.6.0",
"esbuild": "^0.27.3", "esbuild": "^0.27.3",
@@ -64,6 +64,10 @@
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
},
"optionalDependencies": {
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
"@rspack/binding-linux-x64-gnu": "^1.7.7"
} }
}, },
"node_modules/@asamuzakjp/css-color": { "node_modules/@asamuzakjp/css-color": {
@@ -943,6 +947,53 @@
"glob": "^11.0.0" "glob": "^11.0.0"
} }
}, },
"node_modules/@fastify/websocket": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
"integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"duplexify": "^4.1.3",
"fastify-plugin": "^5.0.0",
"ws": "^8.16.0"
}
},
"node_modules/@fastify/websocket/node_modules/duplexify": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.4.1",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1",
"stream-shift": "^1.0.2"
}
},
"node_modules/@fastify/websocket/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"dev": true, "dev": true,
@@ -1432,6 +1483,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"optional": true,
"os": [ "os": [
"linux" "linux"
] ]
@@ -1861,6 +1913,7 @@
"x64" "x64"
], ],
"license": "MIT", "license": "MIT",
"optional": true,
"os": [ "os": [
"linux" "linux"
] ]
@@ -2114,6 +2167,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yauzl": { "node_modules/@types/yauzl": {
"version": "2.10.3", "version": "2.10.3",
"dev": true, "dev": true,
@@ -2599,6 +2662,33 @@
"@xtuc/long": "4.2.2" "@xtuc/long": "4.2.2"
} }
}, },
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
"license": "MIT"
},
"node_modules/@xterm/addon-webgl": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/@xtuc/ieee754": { "node_modules/@xtuc/ieee754": {
"version": "1.2.0", "version": "1.2.0",
"dev": true, "dev": true,
@@ -2988,7 +3078,9 @@
} }
}, },
"node_modules/basic-ftp": { "node_modules/basic-ftp": {
"version": "5.1.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
"integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -4385,7 +4477,9 @@
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/fastify": { "node_modules/fastify": {
"version": "5.7.4", "version": "5.8.2",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.2.tgz",
"integrity": "sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -4407,7 +4501,7 @@
"fast-json-stringify": "^6.0.0", "fast-json-stringify": "^6.0.0",
"find-my-way": "^9.0.0", "find-my-way": "^9.0.0",
"light-my-request": "^6.0.0", "light-my-request": "^6.0.0",
"pino": "^10.1.0", "pino": "^9.14.0 || ^10.1.0",
"process-warning": "^5.0.0", "process-warning": "^5.0.0",
"rfdc": "^1.3.1", "rfdc": "^1.3.1",
"secure-json-parse": "^4.0.0", "secure-json-parse": "^4.0.0",
@@ -4570,6 +4664,20 @@
"dev": true, "dev": true,
"license": "Unlicense" "license": "Unlicense"
}, },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"dev": true, "dev": true,
@@ -5610,7 +5718,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.2", "version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"brace-expansion": "^5.0.2" "brace-expansion": "^5.0.2"
@@ -6132,6 +6242,21 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pngjs": { "node_modules/pngjs": {
"version": "7.0.0", "version": "7.0.0",
"dev": true, "dev": true,
@@ -6594,14 +6719,6 @@
"version": "4.0.4", "version": "4.0.4",
"license": "MIT" "license": "MIT"
}, },
"node_modules/randombytes": {
"version": "2.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.4", "version": "19.2.4",
"dev": true, "dev": true,
@@ -6992,14 +7109,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/serialize-javascript": {
"version": "6.0.2",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"randombytes": "^2.1.0"
}
},
"node_modules/set-blocking": { "node_modules/set-blocking": {
"version": "2.0.0", "version": "2.0.0",
"license": "ISC" "license": "ISC"
@@ -7370,14 +7479,15 @@
} }
}, },
"node_modules/terser-webpack-plugin": { "node_modules/terser-webpack-plugin": {
"version": "5.3.16", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz",
"integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"jest-worker": "^27.4.5", "jest-worker": "^27.4.5",
"schema-utils": "^4.3.0", "schema-utils": "^4.3.0",
"serialize-javascript": "^6.0.2",
"terser": "^5.31.1" "terser": "^5.31.1"
}, },
"engines": { "engines": {
@@ -8457,7 +8567,6 @@
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.19.0", "version": "8.19.0",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
@@ -8495,31 +8604,6 @@
"node": ">=0.4" "node": ">=0.4"
} }
}, },
"node_modules/xterm": {
"version": "5.3.0",
"license": "MIT"
},
"node_modules/xterm-addon-fit": {
"version": "0.8.0",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-unicode11": {
"version": "0.6.0",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-webgl": {
"version": "0.16.0",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-zerolag-input": { "node_modules/xterm-zerolag-input": {
"resolved": "packages/xterm-zerolag-input", "resolved": "packages/xterm-zerolag-input",
"link": true "link": true
+7 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "0.3.9", "version": "0.3.11",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -51,6 +51,11 @@
"@fastify/compress": "^8.3.1", "@fastify/compress": "^8.3.1",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.0.0", "@fastify/static": "^8.0.0",
"@fastify/websocket": "^11.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"commander": "^12.1.0", "commander": "^12.1.0",
@@ -59,10 +64,6 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-unicode11": "^0.6.0",
"xterm-addon-webgl": "^0.16.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
@@ -76,6 +77,7 @@
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"agent-browser": "^0.6.0", "agent-browser": "^0.6.0",
"esbuild": "^0.27.3", "esbuild": "^0.27.3",
+6 -6
View File
@@ -35,12 +35,12 @@ run('prepare dirs', 'mkdir -p dist/web dist/templates dist/web/public/vendor');
run('copy web assets', 'cp -r src/web/public dist/web/'); run('copy web assets', 'cp -r src/web/public dist/web/');
run('copy template', 'cp src/templates/case-template.md dist/templates/'); run('copy template', 'cp src/templates/case-template.md dist/templates/');
// 3. Vendor xterm bundles // 3. Vendor xterm bundles (xterm.js 6.x — @xterm scoped packages)
run('xterm css', 'cp node_modules/xterm/css/xterm.css dist/web/public/vendor/'); run('xterm css', 'cp node_modules/@xterm/xterm/css/xterm.css dist/web/public/vendor/');
run('xterm js', 'npx esbuild node_modules/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js'); run('xterm js', 'npx esbuild node_modules/@xterm/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
run('xterm-addon-fit', 'npx esbuild node_modules/xterm-addon-fit/lib/xterm-addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js'); run('xterm-addon-fit', 'npx esbuild node_modules/@xterm/addon-fit/lib/addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
run('xterm-addon-webgl', 'cp node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js'); run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
run('xterm-addon-unicode11', 'npx esbuild node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js'); run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js'); run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)` // Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
+9 -9
View File
@@ -250,10 +250,10 @@ if (isGlobalInstall) {
} else { } else {
try { try {
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const xtermDir = join(require.resolve('xterm'), '..', '..'); const xtermDir = join(require.resolve('@xterm/xterm'), '..', '..');
const fitDir = join(require.resolve('xterm-addon-fit'), '..', '..'); const fitDir = join(require.resolve('@xterm/addon-fit'), '..', '..');
const webglDir = join(require.resolve('xterm-addon-webgl'), '..', '..'); const webglDir = join(require.resolve('@xterm/addon-webgl'), '..', '..');
const unicode11Dir = join(require.resolve('xterm-addon-unicode11'), '..', '..'); const unicode11Dir = join(require.resolve('@xterm/addon-unicode11'), '..', '..');
const vendorDir = join(srcDir, 'web', 'public', 'vendor'); const vendorDir = join(srcDir, 'web', 'public', 'vendor');
const { mkdirSync, copyFileSync } = await import('fs'); const { mkdirSync, copyFileSync } = await import('fs');
@@ -263,19 +263,19 @@ if (isGlobalInstall) {
// Minify xterm JS for dev vendor dir (npm packages don't ship .min.js) // Minify xterm JS for dev vendor dir (npm packages don't ship .min.js)
try { try {
execSync(`npx esbuild "${join(xtermDir, 'lib', 'xterm.js')}" --minify --outfile="${join(vendorDir, 'xterm.min.js')}"`, { stdio: 'pipe' }); execSync(`npx esbuild "${join(xtermDir, 'lib', 'xterm.js')}" --minify --outfile="${join(vendorDir, 'xterm.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(fitDir, 'lib', 'xterm-addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' }); execSync(`npx esbuild "${join(fitDir, 'lib', 'addon-fit.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-fit.min.js')}"`, { stdio: 'pipe' });
execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'xterm-addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' }); execSync(`npx esbuild "${join(unicode11Dir, 'lib', 'addon-unicode11.js')}" --minify --outfile="${join(vendorDir, 'xterm-addon-unicode11.min.js')}"`, { stdio: 'pipe' });
console.log(colors.green('✓ xterm vendor files copied to src/web/public/vendor/')); console.log(colors.green('✓ xterm vendor files copied to src/web/public/vendor/'));
} catch { } catch {
// Fallback: copy unminified // Fallback: copy unminified
copyFileSync(join(xtermDir, 'lib', 'xterm.js'), join(vendorDir, 'xterm.min.js')); copyFileSync(join(xtermDir, 'lib', 'xterm.js'), join(vendorDir, 'xterm.min.js'));
copyFileSync(join(fitDir, 'lib', 'xterm-addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js')); copyFileSync(join(fitDir, 'lib', 'addon-fit.js'), join(vendorDir, 'xterm-addon-fit.min.js'));
copyFileSync(join(unicode11Dir, 'lib', 'xterm-addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js')); copyFileSync(join(unicode11Dir, 'lib', 'addon-unicode11.js'), join(vendorDir, 'xterm-addon-unicode11.min.js'));
console.log(colors.green('✓ xterm vendor files copied') + colors.dim(' (unminified — esbuild not available)')); console.log(colors.green('✓ xterm vendor files copied') + colors.dim(' (unminified — esbuild not available)'));
} }
// WebGL addon: copy unminified (matches build script behavior) // WebGL addon: copy unminified (matches build script behavior)
copyFileSync(join(webglDir, 'lib', 'xterm-addon-webgl.js'), join(vendorDir, 'xterm-addon-webgl.min.js')); copyFileSync(join(webglDir, 'lib', 'addon-webgl.js'), join(vendorDir, 'xterm-addon-webgl.min.js'));
// xterm-zerolag-input: bundle local package as IIFE for <script> tag loading // xterm-zerolag-input: bundle local package as IIFE for <script> tag loading
try { try {
+1 -2
View File
@@ -28,8 +28,7 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { getAugmentedPath } from './utils/claude-cli-resolver.js'; import { getAugmentedPath, ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js';
import { ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js';
import { AI_CHECK_MAX_BACKOFF_MS } from './config/ai-defaults.js'; import { AI_CHECK_MAX_BACKOFF_MS } from './config/ai-defaults.js';
// ========== Security Validation ========== // ========== Security Validation ==========
+11 -5
View File
@@ -56,11 +56,6 @@ export const TRIM_TEXT_TO = 768 * 1024; // 768KB
*/ */
export const MAX_MESSAGES = 1000; export const MAX_MESSAGES = 1000;
/**
* Number of messages to keep when trimming (80% of max).
*/
export const TRIM_MESSAGES_TO = 800;
// ============================================================================ // ============================================================================
// Line Buffer Limits // Line Buffer Limits
// ============================================================================ // ============================================================================
@@ -85,3 +80,14 @@ export const MAX_RESPAWN_BUFFER_SIZE = 1 * 1024 * 1024; // 1MB
* Size to trim respawn buffer to when max is exceeded. * Size to trim respawn buffer to when max is exceeded.
*/ */
export const TRIM_RESPAWN_BUFFER_TO = 512 * 1024; // 512KB export const TRIM_RESPAWN_BUFFER_TO = 512 * 1024; // 512KB
// ============================================================================
// File Peek Limits
// ============================================================================
/**
* Maximum bytes to read when peeking at the beginning of a file.
* Used with `createReadStream({ end })` (inclusive) to read the first 8KB,
* which is enough to extract metadata from the first few JSONL lines.
*/
export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
-6
View File
@@ -11,11 +11,5 @@
/** Max input length per API request (bytes) */ /** Max input length per API request (bytes) */
export const MAX_INPUT_LENGTH = 64 * 1024; export const MAX_INPUT_LENGTH = 64 * 1024;
/** Max terminal columns for resize requests */
export const MAX_TERMINAL_COLS = 500;
/** Max terminal rows for resize requests */
export const MAX_TERMINAL_ROWS = 200;
/** Max session name length (chars) */ /** Max session name length (chars) */
export const MAX_SESSION_NAME_LENGTH = 128; export const MAX_SESSION_NAME_LENGTH = 128;
+4
View File
@@ -61,6 +61,8 @@ export interface CreateSessionOptions {
claudeMode?: ClaudeMode; claudeMode?: ClaudeMode;
allowedTools?: string; allowedTools?: string;
openCodeConfig?: OpenCodeConfig; openCodeConfig?: OpenCodeConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string;
} }
/** Options for respawning a dead pane. */ /** Options for respawning a dead pane. */
@@ -73,6 +75,8 @@ export interface RespawnPaneOptions {
claudeMode?: ClaudeMode; claudeMode?: ClaudeMode;
allowedTools?: string; allowedTools?: string;
openCodeConfig?: OpenCodeConfig; openCodeConfig?: OpenCodeConfig;
/** Resume a previous Claude conversation when respawning */
resumeSessionId?: string;
} }
/** /**
+2 -2
View File
@@ -521,7 +521,7 @@ export class PlanOrchestrator {
} finally { } finally {
// Always clean up session and progress interval — centralizing here // Always clean up session and progress interval — centralizing here
// prevents the race where cancel() and catch both try to manage the set // prevents the race where cancel() and catch both try to manage the set
await session.stop().catch(() => {}); await session.stop().catch(() => {}); // Ignore - session cleanup is best-effort in finally block
this.runningSessions.delete(session); this.runningSessions.delete(session);
clearInterval(progressInterval); clearInterval(progressInterval);
} }
@@ -651,7 +651,7 @@ export class PlanOrchestrator {
} finally { } finally {
// Always clean up session and progress interval — centralizing here // Always clean up session and progress interval — centralizing here
// prevents the race where cancel() and catch both try to manage the set // prevents the race where cancel() and catch both try to manage the set
await session.stop().catch(() => {}); await session.stop().catch(() => {}); // Ignore - session cleanup is best-effort in finally block
this.runningSessions.delete(session); this.runningSessions.delete(session);
clearInterval(progressInterval); clearInterval(progressInterval);
} }
+1 -2
View File
@@ -49,8 +49,7 @@ import { Session } from './session.js';
import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js'; import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js';
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js'; import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
import type { TeamWatcher } from './team-watcher.js'; import type { TeamWatcher } from './team-watcher.js';
import { BufferAccumulator } from './utils/buffer-accumulator.js'; import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
import { ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js'; import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
import { import {
isCompletionMessage, isCompletionMessage,
+1 -1
View File
@@ -9,7 +9,7 @@
*/ */
import type { ClaudeMode } from './types.js'; import type { ClaudeMode } from './types.js';
import { getAugmentedPath } from './utils/claude-cli-resolver.js'; import { getAugmentedPath } from './utils/index.js';
/** /**
* Build Claude CLI permission flags based on the configured mode. * Build Claude CLI permission flags based on the configured mode.
+14 -12
View File
@@ -48,8 +48,8 @@ import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js';
import { RalphTracker } from './ralph-tracker.js'; import { RalphTracker } from './ralph-tracker.js';
import { BashToolParser } from './bash-tool-parser.js'; import { BashToolParser } from './bash-tool-parser.js';
import { BufferAccumulator } from './utils/buffer-accumulator.js';
import { import {
BufferAccumulator,
ANSI_ESCAPE_PATTERN_FULL, ANSI_ESCAPE_PATTERN_FULL,
TOKEN_PATTERN, TOKEN_PATTERN,
SPINNER_PATTERN, SPINNER_PATTERN,
@@ -324,6 +324,7 @@ export class Session extends EventEmitter {
// OpenCode configuration (only for mode === 'opencode') // OpenCode configuration (only for mode === 'opencode')
private _openCodeConfig: OpenCodeConfig | undefined; private _openCodeConfig: OpenCodeConfig | undefined;
private _resumeSessionId: string | undefined;
// Session color for visual differentiation // Session color for visual differentiation
private _color: import('./types.js').SessionColor = 'default'; private _color: import('./types.js').SessionColor = 'default';
@@ -382,6 +383,8 @@ export class Session extends EventEmitter {
allowedTools?: string; allowedTools?: string;
/** OpenCode configuration (only for mode === 'opencode') */ /** OpenCode configuration (only for mode === 'opencode') */
openCodeConfig?: OpenCodeConfig; openCodeConfig?: OpenCodeConfig;
/** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string;
} }
) { ) {
super(); super();
@@ -398,12 +401,10 @@ export class Session extends EventEmitter {
this.createdAt = config.createdAt || Date.now(); this.createdAt = config.createdAt || Date.now();
this.mode = config.mode || 'claude'; this.mode = config.mode || 'claude';
this._name = config.name || ''; this._name = config.name || '';
this._resumeSessionId = config.resumeSessionId;
this._lastActivityAt = this.createdAt; this._lastActivityAt = this.createdAt;
// Set claudeSessionId immediately — Codeman always passes --session-id ${this.id} // Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
// to Claude CLI, so the Claude session ID always matches the Codeman session ID. this._claudeSessionId = config.resumeSessionId || this.id;
// This ensures subagent matching works even for recovered sessions (where
// startInteractive() hasn't been called yet).
this._claudeSessionId = this.id;
this._mux = config.mux || null; this._mux = config.mux || null;
this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable()); this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable());
this._muxSession = config.muxSession || null; this._muxSession = config.muxSession || null;
@@ -792,6 +793,7 @@ export class Session extends EventEmitter {
cliAccountType: this._cliAccountType || undefined, cliAccountType: this._cliAccountType || undefined,
cliLatestVersion: this._cliLatestVersion || undefined, cliLatestVersion: this._cliLatestVersion || undefined,
openCodeConfig: this._openCodeConfig, openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
}; };
} }
@@ -910,6 +912,7 @@ export class Session extends EventEmitter {
claudeMode: this._claudeMode, claudeMode: this._claudeMode,
allowedTools: this._allowedTools, allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig, openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
}); });
if (!newPid) { if (!newPid) {
console.error('[Session] Failed to respawn pane, will create new session'); console.error('[Session] Failed to respawn pane, will create new session');
@@ -936,6 +939,7 @@ export class Session extends EventEmitter {
claudeMode: this._claudeMode, claudeMode: this._claudeMode,
allowedTools: this._allowedTools, allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig, openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
}); });
console.log('[Session] Created mux session:', this._muxSession.muxName); console.log('[Session] Created mux session:', this._muxSession.muxName);
// No extra sleep — createSession() already waits for tmux readiness // No extra sleep — createSession() already waits for tmux readiness
@@ -955,9 +959,8 @@ export class Session extends EventEmitter {
} }
); );
// Set claudeSessionId immediately since we passed --session-id to Claude // Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
// The mux manager passes --session-id ${sessionId} to Claude this._claudeSessionId = this._resumeSessionId || this.id;
this._claudeSessionId = this.id;
} catch (spawnErr) { } catch (spawnErr) {
console.error('[Session] Failed to spawn PTY for mux attachment:', spawnErr); console.error('[Session] Failed to spawn PTY for mux attachment:', spawnErr);
this.emit('error', `Failed to attach to mux session: ${spawnErr}`); this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
@@ -1042,9 +1045,8 @@ export class Session extends EventEmitter {
} }
} }
// Set the claudeSessionId immediately since we passed --session-id // Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
// This ensures subagent matching works without waiting for JSON messages this._claudeSessionId = this._resumeSessionId || this.id;
this._claudeSessionId = this.id;
this._pid = this.ptyProcess.pid; this._pid = this.ptyProcess.pid;
console.log('[Session] Interactive PTY spawned with PID:', this._pid); console.log('[Session] Interactive PTY spawned with PID:', this._pid);
+5 -4
View File
@@ -17,7 +17,7 @@
* Tracks per-agent: status, token counts, model, description, tool call count, liveness (PID). * Tracks per-agent: status, token counts, model, description, tool call count, liveness (PID).
* *
* @dependencies config/map-limits (MAX_TRACKED_AGENTS, PENDING_TOOL_CALL_TTL_MS), * @dependencies config/map-limits (MAX_TRACKED_AGENTS, PENDING_TOOL_CALL_TTL_MS),
* utils (CleanupManager, KeyedDebouncer) * config/buffer-limits (FILE_PEEK_BYTES), utils (CleanupManager, KeyedDebouncer)
* @consumedby web/server (SSE broadcast), session (subagent-session correlation) * @consumedby web/server (SSE broadcast), session (subagent-session correlation)
* @emits subagent:discovered, subagent:updated, subagent:tool_call, subagent:tool_result, * @emits subagent:discovered, subagent:updated, subagent:tool_call, subagent:tool_result,
* subagent:progress, subagent:message, subagent:completed * subagent:progress, subagent:message, subagent:completed
@@ -35,6 +35,7 @@ import { execFile } from 'node:child_process';
import { readFile, readdir, stat as statAsync } from 'node:fs/promises'; import { readFile, readdir, stat as statAsync } from 'node:fs/promises';
import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS, MAX_TRACKED_AGENTS } from './config/map-limits.js'; import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS, MAX_TRACKED_AGENTS } from './config/map-limits.js';
import { STALE_DATA_MAX_AGE_MS } from './config/server-timing.js'; import { STALE_DATA_MAX_AGE_MS } from './config/server-timing.js';
import { FILE_PEEK_BYTES } from './config/buffer-limits.js';
import { CleanupManager, KeyedDebouncer } from './utils/index.js'; import { CleanupManager, KeyedDebouncer } from './utils/index.js';
// ========== Types ========== // ========== Types ==========
@@ -1009,7 +1010,7 @@ export class SubagentWatcher extends EventEmitter {
private async extractDescriptionFromFile(filePath: string): Promise<string | undefined> { private async extractDescriptionFromFile(filePath: string): Promise<string | undefined> {
try { try {
// Only read the first 8KB — more than enough for 5 JSONL lines // Only read the first 8KB — more than enough for 5 JSONL lines
const stream = createReadStream(filePath, { end: 8191 }); const stream = createReadStream(filePath, { end: FILE_PEEK_BYTES });
const rl = createInterface({ input: stream }); const rl = createInterface({ input: stream });
return await new Promise<string | undefined>((resolve) => { return await new Promise<string | undefined>((resolve) => {
@@ -1141,10 +1142,10 @@ export class SubagentWatcher extends EventEmitter {
if (this.fileAgentContext.has(filePath)) { if (this.fileAgentContext.has(filePath)) {
// Known file — handle content change // Known file — handle content change
this.handleFileChange(filePath).catch(() => {}); this.handleFileChange(filePath).catch(() => {}); // Ignore - errors logged internally, don't crash watcher callback
} else { } else {
// New file — register it // New file — register it
this.registerAgentFile(filePath, projectHash, sessionId).catch(() => {}); this.registerAgentFile(filePath, projectHash, sessionId).catch(() => {}); // Ignore - errors logged internally, don't crash watcher callback
} }
}); });
}); });
+5 -5
View File
@@ -61,7 +61,7 @@ export class TeamWatcher extends EventEmitter {
persistent: false, persistent: false,
}); });
const teamsHandler = () => this.pollAsync().catch(() => {}); const teamsHandler = () => this.pollAsync().catch(() => {}); // Ignore - poll errors are non-fatal, next poll will retry
this.teamsWatcher.on('add', teamsHandler); this.teamsWatcher.on('add', teamsHandler);
this.teamsWatcher.on('change', teamsHandler); this.teamsWatcher.on('change', teamsHandler);
this.teamsWatcher.on('unlink', teamsHandler); this.teamsWatcher.on('unlink', teamsHandler);
@@ -82,8 +82,8 @@ export class TeamWatcher extends EventEmitter {
persistent: false, persistent: false,
}); });
this.tasksWatcher.on('add', () => this.pollTasks().catch(() => {})); this.tasksWatcher.on('add', () => this.pollTasks().catch(() => {})); // Ignore - poll errors are non-fatal, next poll will retry
this.tasksWatcher.on('change', () => this.pollTasks().catch(() => {})); this.tasksWatcher.on('change', () => this.pollTasks().catch(() => {})); // Ignore - poll errors are non-fatal, next poll will retry
this.tasksWatcher.on('error', (err) => { this.tasksWatcher.on('error', (err) => {
console.warn('[TeamWatcher] chokidar tasks watcher error:', err); console.warn('[TeamWatcher] chokidar tasks watcher error:', err);
}); });
@@ -95,11 +95,11 @@ export class TeamWatcher extends EventEmitter {
stop(): void { stop(): void {
// Close chokidar watchers // Close chokidar watchers
if (this.teamsWatcher) { if (this.teamsWatcher) {
this.teamsWatcher.close().catch(() => {}); this.teamsWatcher.close().catch(() => {}); // Ignore - watcher cleanup is best-effort during shutdown
this.teamsWatcher = null; this.teamsWatcher = null;
} }
if (this.tasksWatcher) { if (this.tasksWatcher) {
this.tasksWatcher.close().catch(() => {}); this.tasksWatcher.close().catch(() => {}); // Ignore - watcher cleanup is best-effort during shutdown
this.tasksWatcher = null; this.tasksWatcher = null;
} }
if (this.pollTimer) { if (this.pollTimer) {
+39 -10
View File
@@ -40,8 +40,7 @@ import {
type SessionMode, type SessionMode,
type OpenCodeConfig, type OpenCodeConfig,
} from './types.js'; } from './types.js';
import { wrapWithNice } from './utils/nice-wrapper.js'; import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir } from './utils/index.js';
import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js';
import type { import type {
TerminalMultiplexer, TerminalMultiplexer,
MuxSession, MuxSession,
@@ -50,11 +49,6 @@ import type {
RespawnPaneOptions, RespawnPaneOptions,
} from './mux-interface.js'; } from './mux-interface.js';
// Claude CLI PATH resolution — shared utility
import { findClaudeDir } from './utils/claude-cli-resolver.js';
// OpenCode CLI PATH resolution
import { resolveOpenCodeDir } from './utils/opencode-cli-resolver.js';
// ============================================================================ // ============================================================================
// Timing Constants // Timing Constants
// ============================================================================ // ============================================================================
@@ -200,12 +194,24 @@ function buildSpawnCommand(options: {
claudeMode?: ClaudeMode; claudeMode?: ClaudeMode;
allowedTools?: string; allowedTools?: string;
openCodeConfig?: OpenCodeConfig; openCodeConfig?: OpenCodeConfig;
resumeSessionId?: string;
}): string { }): string {
if (options.mode === 'claude') { if (options.mode === 'claude') {
// Validate model to prevent command injection // Validate model to prevent command injection
const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined; const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
const modelFlag = safeModel ? ` --model ${safeModel}` : ''; const modelFlag = safeModel ? ` --model ${safeModel}` : '';
return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)} --session-id "${options.sessionId}"${modelFlag}`; // 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.
const safeResumeId =
options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined;
const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools);
if (safeResumeId) {
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}`;
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`;
return `${resumeCmd} || ${fallbackCmd}`;
}
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`;
} }
if (options.mode === 'opencode') { if (options.mode === 'opencode') {
return buildOpenCodeCommand(options.openCodeConfig); return buildOpenCodeCommand(options.openCodeConfig);
@@ -370,7 +376,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* In test mode: creates an in-memory session only (no real tmux session). * In test mode: creates an in-memory session only (no real tmux session).
*/ */
async createSession(options: CreateSessionOptions): Promise<MuxSession> { async createSession(options: CreateSessionOptions): Promise<MuxSession> {
const { sessionId, workingDir, mode, name, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options; const {
sessionId,
workingDir,
mode,
name,
niceConfig,
model,
claudeMode,
allowedTools,
openCodeConfig,
resumeSessionId,
} = options;
const muxName = `codeman-${sessionId.slice(0, 8)}`; const muxName = `codeman-${sessionId.slice(0, 8)}`;
if (!isValidMuxName(muxName)) { if (!isValidMuxName(muxName)) {
@@ -433,6 +450,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
claudeMode, claudeMode,
allowedTools, allowedTools,
openCodeConfig, openCodeConfig,
resumeSessionId,
}); });
const config = niceConfig || DEFAULT_NICE_CONFIG; const config = niceConfig || DEFAULT_NICE_CONFIG;
@@ -605,7 +623,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* preserving the session and its scrollback buffer. * preserving the session and its scrollback buffer.
*/ */
async respawnPane(options: RespawnPaneOptions): Promise<number | null> { async respawnPane(options: RespawnPaneOptions): Promise<number | null> {
const { sessionId, workingDir, mode, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options; const {
sessionId,
workingDir,
mode,
niceConfig,
model,
claudeMode,
allowedTools,
openCodeConfig,
resumeSessionId,
} = options;
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) return null; if (!session) return null;
const muxName = session.muxName; const muxName = session.muxName;
@@ -641,6 +669,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
claudeMode, claudeMode,
allowedTools, allowedTools,
openCodeConfig, openCodeConfig,
resumeSessionId,
}); });
const config = niceConfig || DEFAULT_NICE_CONFIG; const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config); const cmd = wrapWithNice(baseCmd, config);
+2
View File
@@ -143,6 +143,8 @@ export interface SessionState {
cliLatestVersion?: string; cliLatestVersion?: string;
/** OpenCode-specific configuration (only for mode === 'opencode') */ /** OpenCode-specific configuration (only for mode === 'opencode') */
openCodeConfig?: OpenCodeConfig; openCodeConfig?: OpenCodeConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string;
} }
/** /**
+429 -134
View File
@@ -63,7 +63,7 @@
* @class CodemanApp * @class CodemanApp
* @globals {CodemanApp} app - Singleton instance (also on window.app) * @globals {CodemanApp} app - Singleton instance (also on window.app)
* *
* @dependency constants.js (SSE_EVENTS, timing constants, escapeHtml, extractSyncSegments, DEC sync markers) * @dependency constants.js (SSE_EVENTS, timing constants, escapeHtml, DEC_SYNC_STRIP_RE)
* @dependency mobile-handlers.js (MobileDetection, KeyboardHandler, SwipeHandler) * @dependency mobile-handlers.js (MobileDetection, KeyboardHandler, SwipeHandler)
* @dependency voice-input.js (VoiceInput, DeepgramProvider) * @dependency voice-input.js (VoiceInput, DeepgramProvider)
* @dependency notification-manager.js (NotificationManager class) * @dependency notification-manager.js (NotificationManager class)
@@ -172,9 +172,9 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.SESSION_CREATED, '_onSessionCreated'], [SSE_EVENTS.SESSION_CREATED, '_onSessionCreated'],
[SSE_EVENTS.SESSION_UPDATED, '_onSessionUpdated'], [SSE_EVENTS.SESSION_UPDATED, '_onSessionUpdated'],
[SSE_EVENTS.SESSION_DELETED, '_onSessionDeleted'], [SSE_EVENTS.SESSION_DELETED, '_onSessionDeleted'],
[SSE_EVENTS.SESSION_TERMINAL, '_onSessionTerminal'], [SSE_EVENTS.SESSION_TERMINAL, '_onSSETerminal'],
[SSE_EVENTS.SESSION_NEEDS_REFRESH, '_onSessionNeedsRefresh'], [SSE_EVENTS.SESSION_NEEDS_REFRESH, '_onSSENeedsRefresh'],
[SSE_EVENTS.SESSION_CLEAR_TERMINAL, '_onSessionClearTerminal'], [SSE_EVENTS.SESSION_CLEAR_TERMINAL, '_onSSEClearTerminal'],
[SSE_EVENTS.SESSION_COMPLETION, '_onSessionCompletion'], [SSE_EVENTS.SESSION_COMPLETION, '_onSessionCompletion'],
[SSE_EVENTS.SESSION_ERROR, '_onSessionError'], [SSE_EVENTS.SESSION_ERROR, '_onSessionError'],
[SSE_EVENTS.SESSION_EXIT, '_onSessionExit'], [SSE_EVENTS.SESSION_EXIT, '_onSessionExit'],
@@ -361,6 +361,11 @@ class CodemanApp {
// Tracks pending hook events that need resolution (permission_prompt, elicitation_dialog, idle_prompt) // Tracks pending hook events that need resolution (permission_prompt, elicitation_dialog, idle_prompt)
this.pendingHooks = new Map(); this.pendingHooks = new Map();
// WebSocket terminal I/O (low-latency bypass of HTTP POST + SSE)
this._ws = null; // WebSocket instance for active session
this._wsSessionId = null; // Session ID the WS is connected to
this._wsReady = false; // True when WS is open and ready for I/O
// Terminal write batching with DEC 2026 sync support // Terminal write batching with DEC 2026 sync support
this.pendingWrites = []; this.pendingWrites = [];
this.writeFrameScheduled = false; this.writeFrameScheduled = false;
@@ -758,11 +763,46 @@ class CodemanApp {
const MIN_ROWS = 10; const MIN_ROWS = 10;
const throttledResize = () => { const throttledResize = () => {
if (this._resizeTimeout) return; // Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens
// once after the user stops resizing. During active resize, the terminal
// stays at its old dimensions for up to 300ms.
//
// Why not fit() immediately? Each fitAddon.fit() reflows content at the
// new width — lines that were 7 rows become 10, and the overflow gets
// pushed into scrollback. With continuous resize events, this creates
// dozens of intermediate reflow states in scrollback, appearing as
// duplicate/garbled content when the user scrolls up.
//
// By deferring fit() to the trailing edge, there's exactly ONE reflow
// at the final dimensions, ONE viewport clear, and ONE Ink redraw.
if (this._resizeTimeout) {
clearTimeout(this._resizeTimeout);
}
this._resizeTimeout = setTimeout(() => { this._resizeTimeout = setTimeout(() => {
this._resizeTimeout = null; this._resizeTimeout = null;
// Fit xterm.js to final container dimensions
if (this.fitAddon) { if (this.fitAddon) {
this.fitAddon.fit(); this.fitAddon.fit();
}
// Flush any stale flicker buffer before clearing viewport
if (this.flickerFilterBuffer) {
if (this.flickerFilterTimeout) {
clearTimeout(this.flickerFilterTimeout);
this.flickerFilterTimeout = null;
}
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 // Skip server resize while mobile keyboard is visible — sending SIGWINCH
// causes Ink to re-render at the new row count, garbling terminal output. // 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. // Local fit() still runs so xterm knows the viewport size for scrolling.
@@ -784,14 +824,12 @@ class CodemanApp {
}).catch(() => {}); }).catch(() => {});
} }
} }
} // Update subagent connection lines and local echo at new dimensions
// Update subagent connection lines when viewport resizes
this.updateConnectionLines(); this.updateConnectionLines();
// Re-render local echo overlay at new cell dimensions/positions
if (this._localEchoOverlay?.hasPending) { if (this._localEchoOverlay?.hasPending) {
this._localEchoOverlay.rerender(); this._localEchoOverlay.rerender();
} }
}, 100); // Throttle to 100ms }, 300); // Trailing-edge: only fire after 300ms of no resize events
}; };
window.addEventListener('resize', throttledResize); window.addEventListener('resize', throttledResize);
@@ -1131,6 +1169,7 @@ class CodemanApp {
if (overlay) { if (overlay) {
overlay.classList.add('visible'); overlay.classList.add('visible');
this.loadTunnelStatus(); this.loadTunnelStatus();
this.loadHistorySessions();
} }
} }
@@ -1147,6 +1186,121 @@ class CodemanApp {
} }
} }
/**
* Fetch and deduplicate history sessions (up to 2 per dir, max `limit` total).
* @returns {Promise<Array>} deduplicated session list, sorted by lastModified desc
*/
async _fetchHistorySessions(limit = 12) {
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();
for (const s of sessions) {
if (!byDir.has(s.workingDir)) byDir.set(s.workingDir, []);
byDir.get(s.workingDir).push(s);
}
const items = [];
for (const [, group] of byDir) {
items.push(...group.slice(0, 2));
}
items.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
return items.slice(0, limit);
}
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) {
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 item = document.createElement('div');
item.className = 'history-item';
item.title = s.workingDir;
item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
const dirSpan = document.createElement('span');
dirSpan.className = 'history-item-dir';
dirSpan.textContent = shortDir;
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(dirSpan, metaSpan, sizeSpan);
list.appendChild(item);
}
container.style.display = '';
} catch (err) {
console.error('[loadHistorySessions]', err);
container.style.display = 'none';
}
}
async resumeHistorySession(sessionId, workingDir) {
// Close the run mode menu if open
document.getElementById('runModeMenu')?.classList.remove('active');
try {
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
// Generate a session name from the working dir
const dirName = workingDir.split('/').pop() || 'session';
let startNumber = 1;
for (const [, session] of this.sessions) {
const match = session.name && session.name.match(/^w(\d+)-/);
if (match) {
const num = parseInt(match[1]);
if (num >= startNumber) startNumber = num + 1;
}
}
const name = `w${startNumber}-${dirName}`;
// Create session with resumeSessionId
const createRes = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, name, resumeSessionId: sessionId })
});
const createData = await createRes.json();
if (!createData.success) throw new Error(createData.error);
const newSessionId = createData.session.id;
// Start interactive
await fetch(`/api/sessions/${newSessionId}/interactive`, { method: 'POST' });
this.terminal.writeln(`\x1b[90m Session ${name} ready\x1b[0m`);
await this.selectSession(newSessionId);
this.terminal.focus();
} catch (err) {
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
}
}
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// Terminal Rendering // Terminal Rendering
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -1183,56 +1337,13 @@ class CodemanApp {
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
const flickerFilterEnabled = session?.flickerFilterEnabled ?? false; const flickerFilterEnabled = session?.flickerFilterEnabled ?? false;
// Always buffer Ink's cursor-up redraws regardless of flicker filter setting. // xterm.js 6.0 handles DEC 2026 synchronized output natively — Ink's cursor-up
// Ink's status bar updates use cursor-up + erase-line + rewrite, which can split // redraws are wrapped in 2026h/2026l markers and rendered atomically by xterm.js.
// across render frames causing old/new status text to overlap (garbled output). // No client-side cursor-up detection/buffering needed. The old 50ms flicker filter
// Buffering for 50ms ensures the full redraw arrives atomically. // was actively harmful: it accumulated multiple resize redraws and flushed them
// // together, causing stacked ghost renders due to reflow line-count mismatches.
// Shell mode is excluded: shell readline also uses cursor-up for prompt redraws
// (e.g. zsh syntax highlighting on every keystroke), and there's no Ink status bar
// to protect. Applying the filter in shell mode delays character feedback until the
// user stops typing for 50ms, making the terminal feel unresponsive.
const isShellMode = session?.mode === 'shell';
const hasCursorUpRedraw = !isShellMode && /\x1b\[\d{1,2}A/.test(data);
if (hasCursorUpRedraw || (this.flickerFilterActive && !flickerFilterEnabled)) {
this.flickerFilterActive = true;
this.flickerFilterBuffer += data;
// Only reset the 50ms timer on cursor-up events (start of a new Ink redraw cycle). // Opt-in flicker filter: buffer screen clear patterns (for sessions that enable it)
// Non-cursor-up events while the filter is active are trailing data from the same
// redraw — don't extend the deadline further. Without this guard, a busy Claude
// session emitting terminal data faster than SYNC_WAIT_TIMEOUT_MS never flushes,
// accumulating MBs in flickerFilterBuffer that freeze Chrome all at once.
if (hasCursorUpRedraw) {
if (this.flickerFilterTimeout) {
clearTimeout(this.flickerFilterTimeout);
}
this.flickerFilterTimeout = setTimeout(() => {
this.flickerFilterTimeout = null;
this.flushFlickerBuffer();
}, SYNC_WAIT_TIMEOUT_MS); // 50ms buffer window
} else if (!this.flickerFilterTimeout) {
// Safety: if no timer is running for some reason, ensure we eventually flush.
this.flickerFilterTimeout = setTimeout(() => {
this.flickerFilterTimeout = null;
this.flushFlickerBuffer();
}, SYNC_WAIT_TIMEOUT_MS);
}
// Safety valve: if buffer grew very large (e.g. from a burst before the timer fired),
// flush immediately to avoid writing a huge block all at once.
if (this.flickerFilterBuffer.length > 256 * 1024) {
if (this.flickerFilterTimeout) {
clearTimeout(this.flickerFilterTimeout);
this.flickerFilterTimeout = null;
}
this.flushFlickerBuffer();
}
return;
}
// Opt-in flicker filter: also buffer screen clear patterns
if (flickerFilterEnabled) { if (flickerFilterEnabled) {
const hasScreenClear = data.includes('\x1b[2J') || const hasScreenClear = data.includes('\x1b[2J') ||
data.includes('\x1b[H\x1b[J') || data.includes('\x1b[H\x1b[J') ||
@@ -1265,34 +1376,10 @@ class CodemanApp {
if (!this.writeFrameScheduled) { if (!this.writeFrameScheduled) {
this.writeFrameScheduled = true; this.writeFrameScheduled = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (this.pendingWrites.length > 0 && this.terminal) { // xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers
// Join chunks for sync marker detection // content between 2026h/2026l and renders atomically. No need for
const pending = this.pendingWrites.join(''); // client-side incomplete-block detection; just flush every frame.
// Check if we have an incomplete sync block (SYNC_START without SYNC_END)
const hasStart = pending.includes(DEC_SYNC_START);
const hasEnd = pending.includes(DEC_SYNC_END);
if (hasStart && !hasEnd) {
// Incomplete sync block - wait for more data (up to 50ms max)
if (!this.syncWaitTimeout) {
this.syncWaitTimeout = setTimeout(() => {
this.syncWaitTimeout = null;
// Force flush after timeout to prevent stuck state
this.flushPendingWrites(); this.flushPendingWrites();
}, 50);
}
this.writeFrameScheduled = false;
return;
}
// Clear any pending sync wait timeout
if (this.syncWaitTimeout) {
clearTimeout(this.syncWaitTimeout);
this.syncWaitTimeout = null;
}
this.flushPendingWrites();
}
this.writeFrameScheduled = false; this.writeFrameScheduled = false;
}); });
} }
@@ -1377,42 +1464,26 @@ class CodemanApp {
if (this.pendingWrites.length === 0 || !this.terminal) return; if (this.pendingWrites.length === 0 || !this.terminal) return;
const _t0 = performance.now(); const _t0 = performance.now();
// Extract segments, stripping DEC 2026 markers // xterm.js 6.0+ natively handles DEC 2026 synchronized output markers.
// This implements synchronized output for xterm.js which doesn't support DEC 2026 natively // Pass raw data through — xterm.js buffers content between markers and
const _joinedLen = this.pendingWrites.reduce((s, w) => s + w.length, 0); // renders atomically, eliminating split-frame Ink redraws.
if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen/1024).toFixed(0)}KB`);
const joined = this.pendingWrites.join(''); const joined = this.pendingWrites.join('');
this.pendingWrites = []; this.pendingWrites = [];
const _joinedLen = joined.length;
if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen/1024).toFixed(0)}KB`);
const segments = extractSyncSegments(joined); // Per-frame byte budget to prevent main thread blocking.
// Large writes (141KB+) can freeze Chrome for 2+ minutes.
// Write segments respecting a per-frame byte budget.
// Each DEC 2026 sync segment is a complete Ink redraw — writing whole segments
// preserves atomicity (no flicker). But when total data exceeds 48KB, defer
// remaining segments to the next frame to prevent terminal.write() from blocking
// the main thread. 141KB single-frame writes have been observed to freeze Chrome
// for 2+ minutes even with the canvas renderer.
const MAX_FRAME_BYTES = 65536; // 64KB budget per frame const MAX_FRAME_BYTES = 65536; // 64KB budget per frame
let bytesThisFrame = 0;
let deferred = false; let deferred = false;
for (let i = 0; i < segments.length; i++) { if (_joinedLen <= MAX_FRAME_BYTES) {
const segment = segments[i]; this.terminal.write(joined);
if (!segment) continue; } else {
const content = segment.startsWith(DEC_SYNC_START) // Write first chunk now, defer rest to next frame
? segment.slice(DEC_SYNC_START.length) this.terminal.write(joined.slice(0, MAX_FRAME_BYTES));
: segment; this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES));
if (!content) continue; deferred = true;
// If we'd exceed the budget, defer this and all remaining segments
if (bytesThisFrame > 0 && bytesThisFrame + content.length > MAX_FRAME_BYTES) {
// Re-queue remaining segments as raw content for next flush
const remaining = segments.slice(i).map(s => {
if (!s) return '';
return s.startsWith(DEC_SYNC_START) ? s.slice(DEC_SYNC_START.length) : s;
}).filter(Boolean).join('');
if (remaining) {
this.pendingWrites.push(remaining);
if (!this.writeFrameScheduled) { if (!this.writeFrameScheduled) {
this.writeFrameScheduled = true; this.writeFrameScheduled = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -1421,13 +1492,7 @@ class CodemanApp {
}); });
} }
} }
deferred = true; const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen;
break;
}
this.terminal.write(content);
bytesThisFrame += content.length;
}
const _dt = performance.now() - _t0; const _dt = performance.now() - _t0;
if (_dt > 100 || deferred) console.warn(`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame/1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen/1024).toFixed(0)}KB)`); if (_dt > 100 || deferred) console.warn(`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame/1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen/1024).toFixed(0)}KB)`);
@@ -1804,6 +1869,7 @@ class CodemanApp {
} }
_onSessionDeleted(data) { _onSessionDeleted(data) {
if (this._wsSessionId === data.id) this._disconnectWs();
this._cleanupSessionData(data.id); this._cleanupSessionData(data.id);
if (this.activeSessionId === data.id) { if (this.activeSessionId === data.id) {
this.activeSessionId = null; this.activeSessionId = null;
@@ -1818,6 +1884,21 @@ class CodemanApp {
if (this.sessions.size === 0) this.stopSystemStatsPolling(); if (this.sessions.size === 0) this.stopSystemStatsPolling();
} }
// SSE wrappers — skip terminal events when WebSocket is delivering for this session.
// WS handler calls the underlying _onSession* methods directly.
_onSSETerminal(data) {
if (this._wsReady && this._wsSessionId === data.id) return;
this._onSessionTerminal(data);
}
_onSSENeedsRefresh(data) {
if (this._wsReady && this._wsSessionId === data?.id) return;
this._onSessionNeedsRefresh(data);
}
_onSSEClearTerminal(data) {
if (this._wsReady && this._wsSessionId === data?.id) return;
this._onSessionClearTerminal(data);
}
_onSessionTerminal(data) { _onSessionTerminal(data) {
if (data.id === this.activeSessionId) { if (data.id === this.activeSessionId) {
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`); if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
@@ -1922,6 +2003,7 @@ class CodemanApp {
} }
_onSessionExit(data) { _onSessionExit(data) {
if (this._wsSessionId === data.id) this._disconnectWs();
const session = this.sessions.get(data.id); const session = this.sessions.get(data.id);
if (session) { if (session) {
session.status = 'stopped'; session.status = 'stopped';
@@ -2775,6 +2857,72 @@ class CodemanApp {
} }
} }
// ═══════════════════════════════════════════════════════════════
// WebSocket Terminal I/O
// ═══════════════════════════════════════════════════════════════
/**
* Open a WebSocket for terminal I/O on the given session.
* Replaces HTTP POST input and SSE terminal output with a single
* bidirectional connection. Falls back to SSE+POST if WS fails.
*/
_connectWs(sessionId) {
this._disconnectWs();
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/ws/sessions/${sessionId}/terminal`;
const ws = new WebSocket(url);
this._ws = ws;
this._wsSessionId = sessionId;
ws.onopen = () => {
// Only mark ready if this is still the intended session
if (this._ws === ws) {
this._wsReady = true;
}
};
ws.onmessage = (event) => {
if (this._ws !== ws) return;
try {
const msg = JSON.parse(event.data);
if (msg.t === 'o') {
// Terminal output — route through the same batching pipeline as SSE
this._onSessionTerminal({ id: sessionId, data: msg.d });
} else if (msg.t === 'c') {
this._onSessionClearTerminal({ id: sessionId });
} else if (msg.t === 'r') {
this._onSessionNeedsRefresh({ id: sessionId });
}
} catch {
// Ignore malformed messages
}
};
ws.onclose = () => {
if (this._ws === ws) {
this._ws = null;
this._wsSessionId = null;
this._wsReady = false;
}
};
ws.onerror = () => {
// onclose will fire after onerror — cleanup happens there
};
}
/** Close the active WebSocket connection (if any). */
_disconnectWs() {
if (this._ws) {
this._ws.onclose = null; // Prevent re-entrant cleanup
this._ws.close();
this._ws = null;
this._wsSessionId = null;
this._wsReady = false;
}
}
/** /**
* Send input to server without blocking the keystroke flush cycle. * Send input to server without blocking the keystroke flush cycle.
* Uses a sequential promise chain to preserve character ordering * Uses a sequential promise chain to preserve character ordering
@@ -2787,11 +2935,19 @@ class CodemanApp {
return; return;
} }
// Chain on dispatch only — wait for the previous request to be sent before // Fast path: WebSocket — fire-and-forget, inherently ordered (single TCP stream).
// dispatching the next one (preserves keystroke ordering), but don't wait if (this._wsReady && this._wsSessionId === sessionId) {
// for the server's response. The server handles writeViaMux as try {
// fire-and-forget anyway, so the HTTP response carries no useful data this._ws.send(JSON.stringify({ t: 'i', d: input }));
// beyond success/failure for retry purposes. this.clearPendingHooks(sessionId);
return;
} catch {
// WS send failed — fall through to HTTP POST
}
}
// Slow path: HTTP POST — chain on dispatch only, don't wait for response.
// The server handles writeViaMux as fire-and-forget anyway.
this._inputSendChain = this._inputSendChain.then(() => { this._inputSendChain = this._inputSendChain.then(() => {
const fetchPromise = fetch(`/api/sessions/${sessionId}/input`, { const fetchPromise = fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST', method: 'POST',
@@ -3008,6 +3164,9 @@ class CodemanApp {
} }
}); });
// Restore tabs that were open before refresh but are no longer on the server
this._restoreEndedTabs();
// Sync sessionOrder with current sessions (preserve order, add new, remove stale) // Sync sessionOrder with current sessions (preserve order, add new, remove stale)
this.syncSessionOrder(); this.syncSessionOrder();
@@ -3299,7 +3458,8 @@ class CodemanApp {
const tallTabsEnabled = this._tallTabsEnabled ?? false; const tallTabsEnabled = this._tallTabsEnabled ?? false;
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name; const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}> const endedAttr = session._ended ? ' data-ended="1"' : '';
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}"${endedAttr} onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
<span class="tab-status ${status}" aria-hidden="true"></span> <span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info"> <span class="tab-info">
<span class="tab-name-row"> <span class="tab-name-row">
@@ -3317,6 +3477,9 @@ class CodemanApp {
container.innerHTML = parts.join(''); container.innerHTML = parts.join('');
// Persist tab metadata for refresh recovery
this._saveTabMetadata();
// Set up drag-and-drop handlers for tab reordering // Set up drag-and-drop handlers for tab reordering
this.setupTabDragHandlers(); this.setupTabDragHandlers();
@@ -3416,6 +3579,33 @@ class CodemanApp {
} }
} }
// Save tab metadata to localStorage so ended sessions can be restored after refresh
_saveTabMetadata() {
try {
const meta = {};
for (const [id, s] of this.sessions) {
if (s._ended) continue; // Don't persist ended stubs back
meta[id] = { id, name: s.name || '', workingDir: s.workingDir || '', mode: s.mode || 'claude', color: s.color || 'default' };
}
localStorage.setItem('codeman-tab-meta', JSON.stringify(meta));
} catch { /* ignore */ }
}
// Restore tabs that were open before refresh but are no longer on the server
_restoreEndedTabs() {
try {
const saved = localStorage.getItem('codeman-tab-meta');
if (!saved) return;
const meta = JSON.parse(saved);
for (const [id, info] of Object.entries(meta)) {
if (!this.sessions.has(id)) {
// Add a stub session so the tab renders
this.sessions.set(id, { id, name: info.name, workingDir: info.workingDir, mode: info.mode, color: info.color, status: 'ended', _ended: true });
}
}
} catch { /* ignore */ }
}
// Set up drag-and-drop handlers on tab elements // Set up drag-and-drop handlers on tab elements
setupTabDragHandlers() { setupTabDragHandlers() {
const container = this.$('sessionTabs'); const container = this.$('sessionTabs');
@@ -3538,6 +3728,9 @@ class CodemanApp {
if (selectGen !== this._selectGeneration) return; // newer tab switch won if (selectGen !== this._selectGeneration) return; // newer tab switch won
// Close WebSocket for previous session (new one opens after buffer load)
this._disconnectWs();
// Clean up flicker filter state when switching sessions // Clean up flicker filter state when switching sessions
if (this.flickerFilterTimeout) { if (this.flickerFilterTimeout) {
clearTimeout(this.flickerFilterTimeout); clearTimeout(this.flickerFilterTimeout);
@@ -3639,12 +3832,22 @@ class CodemanApp {
// Check if this is a restored session that needs to be attached // Check if this is a restored session that needs to be attached
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
// Ended tabs (restored from localStorage, no longer on server) — show message, skip buffer load
if (session?._ended) {
this.terminal.clear();
this.terminal.write('\r\n \x1b[2mSession ended. Close tab or click to reopen.\x1b[0m\r\n');
return;
}
// Track working directory for path normalization in Project Insights // Track working directory for path normalization in Project Insights
this.currentSessionWorkingDir = session?.workingDir || null; this.currentSessionWorkingDir = session?.workingDir || null;
if (session && session.pid === null && session.status === 'idle') { if (session && session.pid === null && session.status === 'idle') {
// This is a restored session - attach to the existing screen // This is a restored session - attach to the existing screen/shell
try { try {
await fetch(`/api/sessions/${sessionId}/interactive`, { method: 'POST' }); const endpoint = session.mode === 'shell'
? `/api/sessions/${sessionId}/shell`
: `/api/sessions/${sessionId}/interactive`;
await fetch(endpoint, { method: 'POST' });
// Update local session state // Update local session state
session.status = 'busy'; session.status = 'busy';
} catch (err) { } catch (err) {
@@ -3828,6 +4031,9 @@ class CodemanApp {
} }
}); });
// Open WebSocket for low-latency terminal I/O (after buffer load completes)
this._connectWs(sessionId);
_crashDiag.log('FOCUS'); _crashDiag.log('FOCUS');
this.terminal.focus(); this.terminal.focus();
this.terminal.scrollToBottom(); this.terminal.scrollToBottom();
@@ -4131,6 +4337,8 @@ class CodemanApp {
this._runMode = mode; this._runMode = mode;
try { localStorage.setItem('codeman_runMode', mode); } catch {} try { localStorage.setItem('codeman_runMode', mode); } catch {}
this._applyRunMode(); this._applyRunMode();
// Sync to server for cross-device persistence
this._apiPut('/api/settings', { runMode: mode }).catch(() => {});
// Close menu // Close menu
document.getElementById('runModeMenu')?.classList.remove('active'); document.getElementById('runModeMenu')?.classList.remove('active');
} }
@@ -4144,8 +4352,9 @@ class CodemanApp {
menu.querySelectorAll('.run-mode-option').forEach(btn => { menu.querySelectorAll('.run-mode-option').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.mode === this.runMode); btn.classList.toggle('selected', btn.dataset.mode === this.runMode);
}); });
// Close on click outside // Load history sessions when menu opens
if (menu.classList.contains('active')) { if (menu.classList.contains('active')) {
this._loadRunModeHistory();
const close = (ev) => { const close = (ev) => {
if (!menu.contains(ev.target)) { if (!menu.contains(ev.target)) {
menu.classList.remove('active'); menu.classList.remove('active');
@@ -4156,6 +4365,52 @@ class CodemanApp {
} }
} }
async _loadRunModeHistory() {
const container = document.getElementById('runModeHistory');
if (!container) return;
container.innerHTML = '<div class="run-mode-hist-empty">Loading...</div>';
try {
const display = await this._fetchHistorySessions(10);
if (display.length === 0) {
container.innerHTML = '<div class="run-mode-hist-empty">No history</div>';
return;
}
// Build items using DOM API for reliable mobile touch handling
container.replaceChildren();
for (const s of display) {
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 btn = document.createElement('button');
btn.className = 'run-mode-option';
btn.title = s.workingDir;
btn.dataset.sessionId = s.sessionId;
btn.dataset.workingDir = s.workingDir;
const dirSpan = document.createElement('span');
dirSpan.className = 'hist-dir';
dirSpan.textContent = shortDir;
const metaSpan = document.createElement('span');
metaSpan.className = 'hist-meta';
metaSpan.textContent = timeStr;
btn.append(dirSpan, metaSpan);
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.resumeHistorySession(s.sessionId, s.workingDir);
});
container.appendChild(btn);
}
} catch (err) {
container.innerHTML = '<div class="run-mode-hist-empty">Failed to load</div>';
}
}
_applyRunMode() { _applyRunMode() {
const mode = this.runMode; const mode = this.runMode;
const runBtn = document.getElementById('runBtn'); const runBtn = document.getElementById('runBtn');
@@ -5101,6 +5356,15 @@ class CodemanApp {
async sendResize(sessionId) { async sendResize(sessionId) {
const dims = this.getTerminalDimensions(); const dims = this.getTerminalDimensions();
if (!dims) return; if (!dims) return;
// Fast path: WebSocket resize
if (this._wsReady && this._wsSessionId === sessionId) {
try {
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows }));
return;
} catch {
// Fall through to HTTP POST
}
}
await fetch(`/api/sessions/${sessionId}/resize`, { await fetch(`/api/sessions/${sessionId}/resize`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -5482,6 +5746,9 @@ class CodemanApp {
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
loadRespawnPresets() { loadRespawnPresets() {
// Custom presets: prefer server-synced cache, fall back to legacy localStorage key
const serverCache = this._serverRespawnPresets;
if (serverCache) return [...BUILTIN_RESPAWN_PRESETS, ...serverCache];
const saved = localStorage.getItem('codeman-respawn-presets'); const saved = localStorage.getItem('codeman-respawn-presets');
const custom = saved ? JSON.parse(saved) : []; const custom = saved ? JSON.parse(saved) : [];
return [...BUILTIN_RESPAWN_PRESETS, ...custom]; return [...BUILTIN_RESPAWN_PRESETS, ...custom];
@@ -5490,7 +5757,11 @@ class CodemanApp {
saveRespawnPresets(presets) { saveRespawnPresets(presets) {
// Only save custom presets (not built-in) // Only save custom presets (not built-in)
const custom = presets.filter(p => !p.builtIn); const custom = presets.filter(p => !p.builtIn);
// Update local cache + legacy localStorage
this._serverRespawnPresets = custom;
localStorage.setItem('codeman-respawn-presets', JSON.stringify(custom)); localStorage.setItem('codeman-respawn-presets', JSON.stringify(custom));
// Persist to server (cross-device sync)
this._apiPut('/api/settings', { respawnPresets: custom }).catch(() => {});
} }
renderPresetDropdown() { renderPresetDropdown() {
@@ -6334,7 +6605,7 @@ class CodemanApp {
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? true; document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? true;
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false; document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? true; document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true; document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true;
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true; document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true;
document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false; document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false;
@@ -7502,7 +7773,7 @@ class CodemanApp {
const settings = this.loadAppSettingsFromStorage(); const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings(); const defaults = this.getDefaultSettings();
const showMonitor = settings.showMonitor ?? defaults.showMonitor ?? true; const showMonitor = settings.showMonitor ?? defaults.showMonitor ?? true;
const showSubagents = settings.showSubagents ?? defaults.showSubagents ?? true; const showSubagents = settings.showSubagents ?? defaults.showSubagents ?? false;
const showFileBrowser = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; const showFileBrowser = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
const monitorPanel = document.getElementById('monitorPanel'); const monitorPanel = document.getElementById('monitorPanel');
@@ -7649,7 +7920,7 @@ class CodemanApp {
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null); const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null);
if (settings) { if (settings) {
// Extract notification prefs before merging app settings // Extract notification prefs before merging app settings
const { notificationPreferences, voiceSettings, ...appSettings } = settings; const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings;
// Filter out display settings — these are device-specific (mobile vs desktop) // Filter out display settings — these are device-specific (mobile vs desktop)
// and should not be synced from the server to avoid overriding mobile defaults. // and should not be synced from the server to avoid overriding mobile defaults.
// NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled) // NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled)
@@ -7694,6 +7965,30 @@ class CodemanApp {
} }
} }
// Sync respawn presets from server (server is source of truth)
if (respawnPresets && Array.isArray(respawnPresets)) {
this._serverRespawnPresets = respawnPresets;
// Also update localStorage for offline access
localStorage.setItem('codeman-respawn-presets', JSON.stringify(respawnPresets));
} else {
// Migration: push existing localStorage presets to server
const localPresets = localStorage.getItem('codeman-respawn-presets');
if (localPresets) {
const parsed = JSON.parse(localPresets);
if (parsed.length > 0) {
this._serverRespawnPresets = parsed;
this._apiPut('/api/settings', { respawnPresets: parsed }).catch(() => {});
}
}
}
// Sync run mode from server
if (runMode) {
this.runMode = runMode;
try { localStorage.setItem('codeman_runMode', runMode); } catch {}
this._applyRunMode();
}
return merged; return merged;
} }
} catch (err) { } catch (err) {
+5 -65
View File
@@ -2,13 +2,12 @@
* @fileoverview Shared constants, utility functions, and SSE event type registry for all frontend modules. * @fileoverview Shared constants, utility functions, and SSE event type registry for all frontend modules.
* *
* This is the first script loaded in index.html. Every other frontend module depends on the * This is the first script loaded in index.html. Every other frontend module depends on the
* globals defined here: timing constants, Z-index layers, DEC 2026 sync markers, respawn * globals defined here: timing constants, Z-index layers, respawn
* preset definitions, the SSE_EVENTS registry, and shared utilities (escapeHtml, extractSyncSegments, * preset definitions, the SSE_EVENTS registry, and shared utilities (escapeHtml,
* getEventCoords, scheduleBackground, urlBase64ToUint8Array). * getEventCoords, scheduleBackground, urlBase64ToUint8Array).
* *
* @globals {function} urlBase64ToUint8Array - VAPID key conversion for Web Push * @globals {function} urlBase64ToUint8Array - VAPID key conversion for Web Push
* @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority) * @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority)
* @globals {function} extractSyncSegments - DEC 2026 terminal sync marker parser
* @globals {function} getEventCoords - Unified mouse/touch coordinate extractor * @globals {function} getEventCoords - Unified mouse/touch coordinate extractor
* @globals {function} escapeHtml - XSS-safe HTML escaping * @globals {function} escapeHtml - XSS-safe HTML escaping
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (~73 event types) * @globals {object} SSE_EVENTS - Centralized SSE event type constants (~73 event types)
@@ -42,7 +41,7 @@ function urlBase64ToUint8Array(base64String) {
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// Default terminal scrollback (can be changed via settings) // Default terminal scrollback (can be changed via settings)
const DEFAULT_SCROLLBACK = 5000; const DEFAULT_SCROLLBACK = 20000;
// Timing constants // Timing constants
const STUCK_THRESHOLD_DEFAULT_MS = 600000; // 10 minutes - default for stuck detection const STUCK_THRESHOLD_DEFAULT_MS = 600000; // 10 minutes - default for stuck detection
@@ -79,14 +78,8 @@ function scheduleBackground(fn) {
else { requestAnimationFrame(fn); } else { requestAnimationFrame(fn); }
} }
// DEC mode 2026 - Synchronized Output // DEC mode 2026 marker stripping — xterm.js 6.0 handles sync natively,
// Wrap terminal writes with these markers to prevent partial-frame flicker. // but server-sent terminal buffers may still contain markers from Claude CLI.
// Terminal buffers all output between markers and renders atomically.
// Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
// xterm.js doesn't support DEC 2026 natively, so we implement buffering ourselves.
const DEC_SYNC_START = '\x1b[?2026h';
const DEC_SYNC_END = '\x1b[?2026l';
// Pre-compiled regex for stripping DEC 2026 markers (single pass instead of two replaceAll calls)
const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g; const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g;
// Built-in respawn configuration presets // Built-in respawn configuration presets
@@ -303,59 +296,6 @@ function getEventCoords(e) {
return { clientX: e.clientX, clientY: e.clientY }; return { clientX: e.clientX, clientY: e.clientY };
} }
/**
* Process data containing DEC 2026 sync markers.
* Strips markers and returns segments that should be written atomically.
* Each returned segment represents content between SYNC_START and SYNC_END.
* Content outside sync blocks is returned as-is.
*
* @param {string} data - Raw terminal data with potential sync markers
* @returns {string[]} - Array of content segments to write (markers stripped)
*/
function extractSyncSegments(data) {
const segments = [];
let remaining = data;
while (remaining.length > 0) {
const startIdx = remaining.indexOf(DEC_SYNC_START);
if (startIdx === -1) {
// No more sync blocks, return rest as-is
if (remaining.length > 0) {
segments.push(remaining);
}
break;
}
// Content before sync block (if any)
if (startIdx > 0) {
segments.push(remaining.slice(0, startIdx));
}
// Find matching end marker
const afterStart = remaining.slice(startIdx + DEC_SYNC_START.length);
const endIdx = afterStart.indexOf(DEC_SYNC_END);
if (endIdx === -1) {
// No end marker found - sync block continues in next chunk
// Include the start marker so it can be handled when more data arrives
segments.push(remaining.slice(startIdx));
break;
}
// Extract synchronized content (without markers)
const syncContent = afterStart.slice(0, endIdx);
if (syncContent.length > 0) {
segments.push(syncContent);
}
// Continue with content after end marker
remaining = afterStart.slice(endIdx + DEC_SYNC_END.length);
}
return segments;
}
// HTML escape utility (shared by NotificationManager, CodemanApp, and ralph-wizard.js) // HTML escape utility (shared by NotificationManager, CodemanApp, and ralph-wizard.js)
const _htmlEscapeMap = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }; const _htmlEscapeMap = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const _htmlEscapePattern = /[&<>"']/g; const _htmlEscapePattern = /[&<>"']/g;
+7
View File
@@ -256,6 +256,10 @@
<div class="welcome-qr-inner" id="welcomeQrInner"></div> <div class="welcome-qr-inner" id="welcomeQrInner"></div>
<div class="welcome-qr-url" id="welcomeQrUrl"></div> <div class="welcome-qr-url" id="welcomeQrUrl"></div>
</div> </div>
<div class="history-sessions" id="historySessions" style="display:none">
<h3 class="history-title">Resume Conversation</h3>
<div class="history-list" id="historyList"></div>
</div>
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start</p> <p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start</p>
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop &rarr;</button> <button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop &rarr;</button>
</div> </div>
@@ -326,6 +330,9 @@
<button class="run-mode-option" data-mode="opencode" onclick="app.setRunMode('opencode')"> <button class="run-mode-option" data-mode="opencode" onclick="app.setRunMode('opencode')">
<span class="run-mode-dot opencode"></span>OpenCode <span class="run-mode-dot opencode"></span>OpenCode
</button> </button>
<div class="run-mode-sep"></div>
<div class="run-mode-header">Recent Sessions</div>
<div class="run-mode-history" id="runModeHistory"></div>
</div> </div>
</div> </div>
<div class="tab-count-group" title="Instance count"> <div class="tab-count-group" title="Instance count">
+9 -1
View File
@@ -662,12 +662,20 @@ html.mobile-init .file-browser-panel {
bottom: 100%; bottom: 100%;
left: 0; left: 0;
margin-bottom: 6px; margin-bottom: 6px;
min-width: 140px; min-width: 160px;
max-width: 80vw;
} }
.run-mode-option { .run-mode-option {
padding: 10px 12px; padding: 10px 12px;
font-size: 0.8rem; font-size: 0.8rem;
cursor: pointer;
-webkit-tap-highlight-color: rgba(255, 255, 255, 0.1);
}
.run-mode-history {
-webkit-overflow-scrolling: touch;
touch-action: manipulation;
} }
/* Stop button - visible on mobile, icon-only */ /* Stop button - visible on mobile, icon-only */
+102 -1
View File
@@ -270,6 +270,8 @@ body {
will-change: opacity; will-change: opacity;
} }
.session-tab .tab-status.error { background: var(--red); } .session-tab .tab-status.error { background: var(--red); }
.session-tab .tab-status.ended { background: var(--text-muted); opacity: 0.5; }
.session-tab[data-ended] { opacity: 0.55; }
/* Session color coding - left border indicator */ /* Session color coding - left border indicator */
.session-tab[data-color="red"] { border-left: 3px solid var(--session-red); } .session-tab[data-color="red"] { border-left: 3px solid var(--session-red); }
@@ -2084,6 +2086,70 @@ body {
text-align: center; text-align: center;
} }
/* History sessions panel (welcome overlay) */
.history-sessions {
margin-top: 1.5rem;
width: 100%;
max-width: 560px;
}
.history-title {
font-size: 0.85rem;
color: var(--text-dim);
margin-bottom: 0.5rem;
font-weight: 500;
text-align: left;
}
.history-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 240px;
overflow-y: auto;
}
.history-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
text-align: left;
}
.history-item:hover {
border-color: var(--accent);
background: var(--bg-hover);
}
.history-item-dir {
flex: 1;
font-size: 0.8rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-item-meta {
font-size: 0.7rem;
color: var(--text-muted);
white-space: nowrap;
}
.history-item-size {
font-size: 0.7rem;
color: var(--text-dim);
white-space: nowrap;
min-width: 45px;
text-align: right;
}
.welcome-hint { .welcome-hint {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.8rem; font-size: 0.8rem;
@@ -2264,7 +2330,8 @@ body {
border-radius: 6px; border-radius: 6px;
padding: 4px; padding: 4px;
z-index: 1000; z-index: 1000;
min-width: 150px; min-width: 180px;
max-width: 300px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4); box-shadow: 0 4px 12px rgba(0,0,0,0.4);
} }
.run-mode-menu.active { .run-mode-menu.active {
@@ -2301,6 +2368,40 @@ body {
.run-mode-dot.claude { background: #3b82f6; } .run-mode-dot.claude { background: #3b82f6; }
.run-mode-dot.opencode { background: #10b981; } .run-mode-dot.opencode { background: #10b981; }
.run-mode-sep {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.run-mode-header {
padding: 4px 10px 2px;
font-size: 0.72em;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
.run-mode-history {
max-height: 200px;
overflow-y: auto;
}
.run-mode-option .hist-dir {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-mode-option .hist-meta {
font-size: 0.85em;
color: var(--text-muted);
white-space: nowrap;
}
.run-mode-hist-empty {
padding: 6px 10px;
color: var(--text-muted);
font-style: italic;
}
/* Shell Button - Special styling */ /* Shell Button - Special styling */
.btn-toolbar.btn-shell { .btn-toolbar.btn-shell {
background: linear-gradient(135deg, #1a2e1a 0%, #234023 100%); background: linear-gradient(135deg, #1a2e1a 0%, #234023 100%);
+1
View File
@@ -14,3 +14,4 @@ export { registerSessionRoutes } from './session-routes.js';
export { registerRespawnRoutes } from './respawn-routes.js'; export { registerRespawnRoutes } from './respawn-routes.js';
export { registerRalphRoutes } from './ralph-routes.js'; export { registerRalphRoutes } from './ralph-routes.js';
export { registerPlanRoutes } from './plan-routes.js'; export { registerPlanRoutes } from './plan-routes.js';
export { registerWsRoutes } from './ws-routes.js';
+1 -1
View File
@@ -505,7 +505,7 @@ export function registerRalphRoutes(
settings.lastUsedCase = caseName; settings.lastUsedCase = caseName;
const dir = dirname(SETTINGS_PATH); const dir = dirname(SETTINGS_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
} catch { } catch {
/* non-critical */ /* non-critical */
} }
+88
View File
@@ -144,6 +144,7 @@ export function registerSessionRoutes(
claudeMode: claudeModeConfig.claudeMode, claudeMode: claudeModeConfig.claudeMode,
allowedTools: claudeModeConfig.allowedTools, allowedTools: claudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined, openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
resumeSessionId: body.resumeSessionId,
}); });
ctx.addSession(session); ctx.addSession(session);
@@ -917,4 +918,91 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
} }
}); });
// ═══════════════════════════════════════════════════════════════
// History — list past Claude conversations for resume
// ═══════════════════════════════════════════════════════════════
app.get('/api/history/sessions', async () => {
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
const results: Array<{
sessionId: string;
workingDir: string;
projectKey: string;
sizeBytes: number;
lastModified: string;
}> = [];
try {
const projectDirs = await fs.readdir(projectsDir);
for (const projDir of projectDirs) {
const projPath = join(projectsDir, projDir);
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';
const entries = await fs.readdir(projPath);
for (const entry of entries) {
if (!entry.endsWith('.jsonl')) continue;
const sessionId = entry.replace('.jsonl', '');
// Only valid UUIDs
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) continue;
const filePath = join(projPath, entry);
const fileStat = await fs.stat(filePath).catch(() => null);
if (!fileStat) continue;
// Skip files too small to contain real conversation (metadata-only sessions
// like file-history-snapshot entries are typically < 4KB)
if (fileStat.size < 4000) continue;
// Quick content check: verify actual conversation data exists.
// Sessions with only file-history-snapshot or hook_progress entries have
// no "user"/"assistant" messages and will fail claude --resume.
// Files > 50KB are almost certainly real conversations (skip the read).
if (fileStat.size < 50000) {
try {
const fd = await fs.open(filePath, 'r');
const buf = Buffer.alloc(16384);
const { bytesRead } = await fd.read(buf, 0, 16384, 0);
await fd.close();
const head = buf.toString('utf8', 0, bytesRead);
if (
!head.includes('"type":"user"') &&
!head.includes('"type":"assistant"') &&
!head.includes('"type":"summary"')
) {
continue; // No conversation content — skip
}
} catch {
continue;
}
}
results.push({
sessionId,
workingDir,
projectKey: projDir,
sizeBytes: fileStat.size,
lastModified: fileStat.mtime.toISOString(),
});
}
}
} catch {
// Projects dir may not exist
}
// Sort by lastModified descending
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
return { sessions: results.slice(0, 50) };
});
} }
+144
View File
@@ -0,0 +1,144 @@
/**
* @fileoverview WebSocket terminal I/O route.
*
* Provides a low-latency bidirectional channel for terminal input/output,
* bypassing the HTTP POST + SSE path that adds per-request middleware overhead.
* Auth is checked once on the WebSocket upgrade handshake (cookies are included
* automatically by the browser). After upgrade, the connection is raw — no
* per-message middleware processing.
*
* Additive: the existing HTTP POST /api/sessions/:id/input and SSE session:terminal
* paths remain fully functional. The frontend opts into WS when available and
* falls back transparently.
*
* Terminal output is micro-batched at 8ms to group Ink's rapid cursor-up redraws
* into single frames, preventing flicker from split ANSI sequences. This matches
* the SSE path's server-side batching (16-50ms) but at a shorter interval since
* WS has no Traefik buffering overhead.
*
* Protocol (all JSON text frames):
* Server -> Client:
* {"t":"o","d":"..."} — terminal output
* {"t":"c"} — clear terminal
* {"t":"r"} — needs refresh (reload buffer)
* Client -> Server:
* {"t":"i","d":"..."} — input (keystroke or paste)
* {"t":"z","c":N,"r":N} — resize terminal
*/
import { FastifyInstance } from 'fastify';
import type { WebSocket } from 'ws';
import type { SessionPort } from '../ports/session-port.js';
import { MAX_INPUT_LENGTH } from '../../config/terminal-limits.js';
/** Micro-batch interval for terminal output (ms). Short enough for low latency,
* long enough to group Ink's rapid cursor-up redraw sequences into single frames. */
const WS_BATCH_INTERVAL_MS = 8;
/** Flush immediately when batch exceeds this size (bytes) for responsiveness. */
const WS_BATCH_FLUSH_THRESHOLD = 16384;
/** DEC 2026 synchronized update markers. Wrapping output in these tells xterm.js
* to buffer all content and render atomically in a single frame — eliminates
* flicker from cursor-up redraws that Ink sends without its own sync markers
* (DA capability negotiation fails through the PTY→server→WS proxy chain). */
const DEC_2026_START = '\x1b[?2026h';
const DEC_2026_END = '\x1b[?2026l';
export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort): void {
app.get<{ Params: { id: string } }>('/ws/sessions/:id/terminal', { websocket: true }, (socket: WebSocket, req) => {
const { id } = req.params;
const session = ctx.sessions.get(id);
if (!session) {
socket.close(4004, 'Session not found');
return;
}
// Per-connection micro-batch state
let batchChunks: string[] = [];
let batchSize = 0;
let batchTimer: ReturnType<typeof setTimeout> | null = null;
const flushBatch = () => {
batchTimer = null;
if (batchChunks.length === 0 || socket.readyState !== 1) {
batchChunks = [];
batchSize = 0;
return;
}
const data = batchChunks.join('');
batchChunks = [];
batchSize = 0;
socket.send(`{"t":"o","d":${JSON.stringify(DEC_2026_START + data + DEC_2026_END)}}`);
};
// Attach message handler synchronously BEFORE any async work
// (@fastify/websocket requirement to avoid dropped messages).
socket.on('message', (raw) => {
try {
const msg = JSON.parse(String(raw));
if (msg.t === 'i' && typeof msg.d === 'string') {
if (msg.d.length > MAX_INPUT_LENGTH) return;
session.write(msg.d);
} else if (
msg.t === 'z' &&
Number.isInteger(msg.c) &&
Number.isInteger(msg.r) &&
msg.c >= 1 &&
msg.c <= 500 &&
msg.r >= 1 &&
msg.r <= 200
) {
session.resize(msg.c, msg.r);
}
} catch {
// Ignore malformed messages
}
});
// Terminal output -> micro-batched WS send
const onTerminal = (data: string) => {
batchChunks.push(data);
batchSize += data.length;
// Flush immediately for large batches (responsiveness during bulk output)
if (batchSize > WS_BATCH_FLUSH_THRESHOLD) {
if (batchTimer) {
clearTimeout(batchTimer);
}
flushBatch();
return;
}
// Start timer if not already running
if (!batchTimer) {
batchTimer = setTimeout(flushBatch, WS_BATCH_INTERVAL_MS);
}
};
const onClearTerminal = () => {
if (socket.readyState === 1) {
socket.send('{"t":"c"}');
}
};
const onNeedsRefresh = () => {
if (socket.readyState === 1) {
socket.send('{"t":"r"}');
}
};
session.on('terminal', onTerminal);
session.on('clearTerminal', onClearTerminal);
session.on('needsRefresh', onNeedsRefresh);
socket.on('close', () => {
if (batchTimer) clearTimeout(batchTimer);
batchChunks = [];
session.off('terminal', onTerminal);
session.off('clearTerminal', onClearTerminal);
session.off('needsRefresh', onNeedsRefresh);
});
});
}
+31 -1
View File
@@ -8,7 +8,7 @@
*/ */
import { z } from 'zod'; import { z } from 'zod';
import { SAFE_PATH_PATTERN } from '../utils/regex-patterns.js'; import { SAFE_PATH_PATTERN } from '../utils/index.js';
// ========== Path Validation ========== // ========== Path Validation ==========
@@ -125,6 +125,12 @@ export const CreateSessionSchema = z.object({
name: z.string().max(100).optional(), name: z.string().max(100).optional(),
envOverrides: safeEnvOverridesSchema, envOverrides: safeEnvOverridesSchema,
openCodeConfig: OpenCodeConfigSchema, openCodeConfig: OpenCodeConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z
.string()
.max(100)
.regex(/^[a-f0-9-]+$/, 'resumeSessionId must be a valid UUID')
.optional(),
}); });
/** /**
@@ -315,6 +321,30 @@ export const SettingsUpdateSchema = z
insertMode: z.string().max(20).optional(), insertMode: z.string().max(20).optional(),
}) })
.optional(), .optional(),
// Run mode preference (cross-device sync)
runMode: z.string().max(20).optional(),
// Custom respawn presets (cross-device sync, replaces localStorage-only storage)
respawnPresets: z
.array(
z.object({
id: z.string().max(100),
name: z.string().max(100),
config: z.object({
idleTimeoutMs: z.number().optional(),
updatePrompt: z.string().max(5000).optional(),
interStepDelayMs: z.number().optional(),
sendClear: z.boolean().optional(),
sendInit: z.boolean().optional(),
kickstartPrompt: z.string().max(5000).optional(),
autoAcceptPrompts: z.boolean().optional(),
}),
durationMinutes: z.number().optional(),
builtIn: z.boolean().optional(),
createdAt: z.number().optional(),
})
)
.max(20)
.optional(),
}) })
.strict(); .strict();
+11 -13
View File
@@ -31,6 +31,7 @@ import Fastify, { FastifyInstance, FastifyReply } from 'fastify';
import fastifyCompress from '@fastify/compress'; import fastifyCompress from '@fastify/compress';
import fastifyCookie from '@fastify/cookie'; import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync } from 'node:fs'; import { existsSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
@@ -103,6 +104,7 @@ import {
registerRespawnRoutes, registerRespawnRoutes,
registerRalphRoutes, registerRalphRoutes,
registerPlanRoutes, registerPlanRoutes,
registerWsRoutes,
} from './routes/index.js'; } from './routes/index.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -123,13 +125,6 @@ import {
INACTIVITY_TIMEOUT_MS, INACTIVITY_TIMEOUT_MS,
} from '../config/server-timing.js'; } from '../config/server-timing.js';
// DEC mode 2026 - Synchronized Output
// When terminal supports this, it buffers all output between start/end markers
// and renders atomically, eliminating partial-frame flicker from Ink redraws.
// Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update
const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update (flush to screen)
// SSE padding for Cloudflare tunnel buffer flushing. // SSE padding for Cloudflare tunnel buffer flushing.
// Cloudflare quick tunnels buffer small SSE responses, causing lag for real-time events. // Cloudflare quick tunnels buffer small SSE responses, causing lag for real-time events.
// Appending SSE comment padding (ignored by EventSource) forces the proxy to flush. // Appending SSE comment padding (ignored by EventSource) forces the proxy to flush.
@@ -570,6 +565,9 @@ export class WebServer extends EventEmitter {
this.qrAuthFailures = authState.qrAuthFailures; this.qrAuthFailures = authState.qrAuthFailures;
} }
// WebSocket support (terminal I/O — low-latency bidirectional channel)
await this.app.register(fastifyWebsocket);
// Security headers + CORS // Security headers + CORS
registerSecurityHeaders(this.app, this.https); registerSecurityHeaders(this.app, this.https);
// Service worker must never be cached — browsers check for SW updates on navigation // Service worker must never be cached — browsers check for SW updates on navigation
@@ -707,6 +705,7 @@ export class WebServer extends EventEmitter {
registerRespawnRoutes(this.app, ctx); registerRespawnRoutes(this.app, ctx);
registerRalphRoutes(this.app, ctx); registerRalphRoutes(this.app, ctx);
registerPlanRoutes(this.app, ctx); registerPlanRoutes(this.app, ctx);
registerWsRoutes(this.app, ctx);
} }
/** /**
@@ -2127,14 +2126,13 @@ export class WebServer extends EventEmitter {
if (chunks && chunks.length > 0) { if (chunks && chunks.length > 0) {
// Join chunks only at flush time (avoids O(n^2) string concatenation in batchTerminalData) // Join chunks only at flush time (avoids O(n^2) string concatenation in batchTerminalData)
const data = chunks.join(''); const data = chunks.join('');
// Wrap with DEC mode 2026 synchronized output markers // xterm.js 6.0+ handles DEC 2026 synchronized output natively.
// Terminal buffers all output between markers and renders atomically, // Claude CLI (Ink) already emits its own DEC 2026 markers around redraws.
// eliminating partial-frame flicker from Ink's full-screen redraws. // Do NOT add an outer wrapper — DEC 2026 is not reference-counted, so
// Unsupported terminals ignore these sequences harmlessly. // the inner 2026l would prematurely exit sync mode, defeating the purpose.
const syncData = DEC_SYNC_START + data + DEC_SYNC_END;
// Fast path: build SSE message directly without JSON.stringify on wrapper object. // Fast path: build SSE message directly without JSON.stringify on wrapper object.
// Only the terminal data string needs escaping; sessionId is a UUID (safe to template). // Only the terminal data string needs escaping; sessionId is a UUID (safe to template).
const escapedData = JSON.stringify(syncData); const escapedData = JSON.stringify(data);
// Append tunnel padding for immediate Cloudflare proxy flush — // Append tunnel padding for immediate Cloudflare proxy flush —
// terminal data is high-frequency and latency-sensitive. // terminal data is high-frequency and latency-sensitive.
const padding = this._isTunnelActive ? SSE_PADDING : ''; const padding = this._isTunnelActive ? SSE_PADDING : '';
+107 -9
View File
@@ -75,11 +75,7 @@ describe('session-routes', () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body.success).toBe(true);
expect(harness.ctx.cleanupSession).toHaveBeenCalledWith( expect(harness.ctx.cleanupSession).toHaveBeenCalledWith(harness.ctx._sessionId, true, 'user_delete');
harness.ctx._sessionId,
true,
'user_delete',
);
}); });
it('returns error for unknown session', async () => { it('returns error for unknown session', async () => {
@@ -123,10 +119,7 @@ describe('session-routes', () => {
expect(body.success).toBe(true); expect(body.success).toBe(true);
expect(body.name).toBe('new-name'); expect(body.name).toBe('new-name');
expect(harness.ctx.persistSessionState).toHaveBeenCalled(); expect(harness.ctx.persistSessionState).toHaveBeenCalled();
expect(harness.ctx.broadcast).toHaveBeenCalledWith( expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:updated', expect.anything());
'session:updated',
expect.anything(),
);
}); });
it('returns error for unknown session', async () => { it('returns error for unknown session', async () => {
@@ -462,4 +455,109 @@ describe('session-routes', () => {
expect(body.success).toBe(true); expect(body.success).toBe(true);
}); });
}); });
// ========== GET /api/history/sessions ==========
describe('GET /api/history/sessions', () => {
it('returns sessions array', async () => {
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body).toHaveProperty('sessions');
expect(Array.isArray(body.sessions)).toBe(true);
});
it('sessions have required fields', async () => {
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions',
});
const body = JSON.parse(res.body);
for (const session of body.sessions) {
expect(session).toHaveProperty('sessionId');
expect(session).toHaveProperty('workingDir');
expect(session).toHaveProperty('projectKey');
expect(session).toHaveProperty('sizeBytes');
expect(session).toHaveProperty('lastModified');
// sessionId must be a valid UUID
expect(session.sessionId).toMatch(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/);
}
});
it('sessions are sorted by lastModified descending', async () => {
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions',
});
const body = JSON.parse(res.body);
const dates = body.sessions.map((s: { lastModified: string }) => new Date(s.lastModified).getTime());
for (let i = 1; i < dates.length; i++) {
expect(dates[i - 1]).toBeGreaterThanOrEqual(dates[i]);
}
});
it('returns at most 50 sessions', async () => {
const res = await harness.app.inject({
method: 'GET',
url: '/api/history/sessions',
});
const body = JSON.parse(res.body);
expect(body.sessions.length).toBeLessThanOrEqual(50);
});
});
// ========== POST /api/sessions (with resumeSessionId) ==========
describe('POST /api/sessions with resumeSessionId', () => {
it('creates session with valid resumeSessionId', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: {
name: 'resume-test',
mode: 'claude',
workingDir: process.env.HOME || '/tmp',
resumeSessionId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.session).toBeDefined();
});
it('rejects invalid resumeSessionId format', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: {
name: 'bad-resume',
mode: 'claude',
workingDir: process.env.HOME || '/tmp',
resumeSessionId: 'not-a-uuid',
},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('creates session without resumeSessionId (optional field)', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: {
name: 'no-resume',
mode: 'claude',
workingDir: process.env.HOME || '/tmp',
},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
});
});
}); });
+362
View File
@@ -0,0 +1,362 @@
/**
* @fileoverview Tests for WebSocket terminal I/O route.
*
* Unlike other route tests that use app.inject(), WebSocket testing requires
* a real listening server since inject() doesn't support upgrade requests.
* Uses the `ws` package (transitive dep of @fastify/websocket) as the client.
*
* Port: 3170 (ws-routes tests)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import WebSocket from 'ws';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { registerWsRoutes } from '../../src/web/routes/ws-routes.js';
const PORT = 3170;
/** Helper: open a WebSocket connection and wait for it to reach OPEN state. */
function connectWs(path: string): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${PORT}${path}`);
ws.on('open', () => resolve(ws));
ws.on('error', reject);
});
}
/** Helper: wait for the next WS message, parsed as JSON. */
function nextMessage(ws: WebSocket, timeoutMs = 2000): Promise<unknown> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('WS message timeout')), timeoutMs);
ws.once('message', (raw) => {
clearTimeout(timer);
resolve(JSON.parse(String(raw)));
});
});
}
/** Helper: wait for WS close event and return { code, reason }. */
function waitForClose(ws: WebSocket, timeoutMs = 2000): Promise<{ code: number; reason: string }> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('WS close timeout')), timeoutMs);
ws.on('close', (code, reason) => {
clearTimeout(timer);
resolve({ code, reason: reason.toString() });
});
});
}
describe('ws-routes', () => {
let app: FastifyInstance;
let ctx: MockRouteContext;
beforeEach(async () => {
app = Fastify({ logger: false });
await app.register(fastifyWebsocket);
ctx = createMockRouteContext({ sessionId: 'ws-test-session' });
registerWsRoutes(app, ctx as never);
await app.listen({ port: PORT, host: '127.0.0.1' });
});
afterEach(async () => {
await app.close();
});
// ========== Session not found ==========
describe('session not found', () => {
it('closes with 4004 when session does not exist', async () => {
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws/sessions/nonexistent/terminal`);
const { code, reason } = await waitForClose(ws);
expect(code).toBe(4004);
expect(reason).toBe('Session not found');
});
});
// ========== Terminal output ==========
describe('terminal output', () => {
it('receives terminal output via WS with DEC 2026 sync markers', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
// Emit terminal data from mock session
session.emit('terminal', 'hello world');
// Wait for the micro-batched message (8ms batch interval + margin)
const msg = (await nextMessage(ws)) as { t: string; d: string };
expect(msg.t).toBe('o');
// Should contain DEC 2026 sync markers wrapping the data
expect(msg.d).toContain('hello world');
expect(msg.d).toMatch(/^\x1b\[\?2026h/); // starts with DEC 2026 start
expect(msg.d).toMatch(/\x1b\[\?2026l$/); // ends with DEC 2026 end
} finally {
ws.close();
}
});
it('sends clearTerminal event as {"t":"c"}', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
ctx._session.emit('clearTerminal');
const msg = (await nextMessage(ws)) as { t: string };
expect(msg.t).toBe('c');
} finally {
ws.close();
}
});
it('sends needsRefresh event as {"t":"r"}', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
ctx._session.emit('needsRefresh');
const msg = (await nextMessage(ws)) as { t: string };
expect(msg.t).toBe('r');
} finally {
ws.close();
}
});
});
// ========== Client input ==========
describe('client input', () => {
it('forwards input messages to session.write()', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
session.writeBuffer = [];
ws.send(JSON.stringify({ t: 'i', d: 'ls -la\r' }));
// Give the message handler time to process
await vi.waitFor(() => {
expect(session.writeBuffer).toContain('ls -la\r');
});
} finally {
ws.close();
}
});
it('ignores input exceeding MAX_INPUT_LENGTH', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
session.writeBuffer = [];
// MAX_INPUT_LENGTH is 64KB
const hugeInput = 'x'.repeat(65 * 1024);
ws.send(JSON.stringify({ t: 'i', d: hugeInput }));
// Send a valid message after to confirm the connection still works
ws.send(JSON.stringify({ t: 'i', d: 'ok' }));
await vi.waitFor(() => {
expect(session.writeBuffer).toContain('ok');
});
// The oversized input should not have been written
expect(session.writeBuffer).not.toContain(hugeInput);
} finally {
ws.close();
}
});
it('ignores malformed JSON messages', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
session.writeBuffer = [];
ws.send('not-json{{{');
// Send valid input to verify connection still alive
ws.send(JSON.stringify({ t: 'i', d: 'after-bad' }));
await vi.waitFor(() => {
expect(session.writeBuffer).toContain('after-bad');
});
// Only 'after-bad' should be in the buffer
expect(session.writeBuffer).toHaveLength(1);
} finally {
ws.close();
}
});
});
// ========== Resize validation ==========
describe('resize validation', () => {
it('accepts valid resize within bounds', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
ws.send(JSON.stringify({ t: 'z', c: 120, r: 40 }));
await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(120, 40);
});
} finally {
ws.close();
}
});
it('accepts resize at minimum bounds (1x1)', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
ws.send(JSON.stringify({ t: 'z', c: 1, r: 1 }));
await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(1, 1);
});
} finally {
ws.close();
}
});
it('accepts resize at maximum bounds (500x200)', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
ws.send(JSON.stringify({ t: 'z', c: 500, r: 200 }));
await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(500, 200);
});
} finally {
ws.close();
}
});
it('rejects resize with cols out of bounds (0 cols)', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
ws.send(JSON.stringify({ t: 'z', c: 0, r: 40 }));
// Send a valid message to confirm processing continues
ws.send(JSON.stringify({ t: 'i', d: 'sentinel' }));
await vi.waitFor(() => {
expect(session.writeBuffer).toContain('sentinel');
});
expect(session.resize).not.toHaveBeenCalled();
} finally {
ws.close();
}
});
it('rejects resize with cols exceeding 500', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
ws.send(JSON.stringify({ t: 'z', c: 501, r: 40 }));
ws.send(JSON.stringify({ t: 'i', d: 'sentinel' }));
await vi.waitFor(() => {
expect(session.writeBuffer).toContain('sentinel');
});
expect(session.resize).not.toHaveBeenCalled();
} finally {
ws.close();
}
});
it('rejects resize with rows exceeding 200', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
ws.send(JSON.stringify({ t: 'z', c: 80, r: 201 }));
ws.send(JSON.stringify({ t: 'i', d: 'sentinel' }));
await vi.waitFor(() => {
expect(session.writeBuffer).toContain('sentinel');
});
expect(session.resize).not.toHaveBeenCalled();
} finally {
ws.close();
}
});
it('rejects resize with non-integer values', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
ws.send(JSON.stringify({ t: 'z', c: 80.5, r: 24 }));
ws.send(JSON.stringify({ t: 'i', d: 'sentinel' }));
await vi.waitFor(() => {
expect(session.writeBuffer).toContain('sentinel');
});
expect(session.resize).not.toHaveBeenCalled();
} finally {
ws.close();
}
});
it('rejects resize with negative values', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
ws.send(JSON.stringify({ t: 'z', c: -1, r: 24 }));
ws.send(JSON.stringify({ t: 'i', d: 'sentinel' }));
await vi.waitFor(() => {
expect(session.writeBuffer).toContain('sentinel');
});
expect(session.resize).not.toHaveBeenCalled();
} finally {
ws.close();
}
});
});
// ========== Connection cleanup ==========
describe('connection cleanup', () => {
it('removes session event listeners on close', async () => {
const session = ctx._session;
const listenersBefore = session.listenerCount('terminal');
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
// A listener was added for 'terminal'
expect(session.listenerCount('terminal')).toBe(listenersBefore + 1);
expect(session.listenerCount('clearTerminal')).toBeGreaterThanOrEqual(1);
expect(session.listenerCount('needsRefresh')).toBeGreaterThanOrEqual(1);
// Close the WS connection
ws.close();
await waitForClose(ws);
// Give the server-side close handler time to run
await new Promise((resolve) => setTimeout(resolve, 50));
// Listeners should be cleaned up
expect(session.listenerCount('terminal')).toBe(listenersBefore);
expect(session.listenerCount('clearTerminal')).toBe(0);
expect(session.listenerCount('needsRefresh')).toBe(0);
});
});
});
+167 -138
View File
@@ -100,11 +100,7 @@ function createAssistantTextEntry(text: string, timestamp?: string): string {
}); });
} }
function createToolUseEntry( function createToolUseEntry(toolName: string, input: Record<string, unknown>, timestamp?: string): string {
toolName: string,
input: Record<string, unknown>,
timestamp?: string
): string {
return JSON.stringify({ return JSON.stringify({
type: 'assistant', type: 'assistant',
timestamp: timestamp || new Date().toISOString(), timestamp: timestamp || new Date().toISOString(),
@@ -134,6 +130,13 @@ function createToolResultEntry(content: string, timestamp?: string): string {
}); });
} }
/** Create a mock readline interface with .close() method */
function createMockRl() {
const rl = new EventEmitter() as EventEmitter & { close: ReturnType<typeof vi.fn> };
rl.close = vi.fn();
return rl;
}
describe('SubagentWatcher', () => { describe('SubagentWatcher', () => {
let watcher: SubagentWatcher; let watcher: SubagentWatcher;
let mockExistsSync: Mock; let mockExistsSync: Mock;
@@ -166,12 +169,12 @@ describe('SubagentWatcher', () => {
// Default mocks - no projects exist // Default mocks - no projects exist
mockExistsSync.mockReturnValue(false); mockExistsSync.mockReturnValue(false);
mockStatSync.mockReturnValue({ mockStatSync.mockImplementation(() => ({
isDirectory: () => true, isDirectory: () => true,
birthtime: new Date(), birthtime: new Date(),
mtime: new Date(), mtime: new Date(),
size: 0, size: 0,
}); }));
mockReaddirSync.mockReturnValue([]); mockReaddirSync.mockReturnValue([]);
mockReadFileSync.mockReturnValue(''); mockReadFileSync.mockReturnValue('');
mockWatch.mockReturnValue({ close: vi.fn(), on: vi.fn(), off: vi.fn() }); mockWatch.mockReturnValue({ close: vi.fn(), on: vi.fn(), off: vi.fn() });
@@ -239,9 +242,9 @@ describe('SubagentWatcher', () => {
const lines = [validEntry]; const lines = [validEntry];
// Setup mock readline interface // Setup mock readline interface
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
// Setup file discovery // Setup file discovery
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
@@ -281,16 +284,11 @@ describe('SubagentWatcher', () => {
it('should skip malformed JSON lines', async () => { it('should skip malformed JSON lines', async () => {
const validEntry = createUserEntry('Valid entry'); const validEntry = createUserEntry('Valid entry');
const malformedLines = [ const malformedLines = ['not json at all', '{"incomplete": true', validEntry, '}{bad json}{'];
'not json at all',
'{"incomplete": true',
validEntry,
'}{bad json}{',
];
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -330,9 +328,9 @@ describe('SubagentWatcher', () => {
// Simulate partial write where line is incomplete // Simulate partial write where line is incomplete
const partialContent = '{"type": "user", "timestamp": "2024-01-01T00:00:00Z"'; const partialContent = '{"type": "user", "timestamp": "2024-01-01T00:00:00Z"';
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -369,9 +367,9 @@ describe('SubagentWatcher', () => {
const validEntry = createUserEntry('Valid'); const validEntry = createUserEntry('Valid');
const contentWithEmptyLines = ['', validEntry, ' ', '', validEntry].join('\n'); const contentWithEmptyLines = ['', validEntry, ' ', '', validEntry].join('\n');
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -408,9 +406,9 @@ describe('SubagentWatcher', () => {
describe('Status Lifecycle', () => { describe('Status Lifecycle', () => {
it('should start agents as active', async () => { it('should start agents as active', async () => {
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -442,9 +440,9 @@ describe('SubagentWatcher', () => {
}); });
it('should transition to idle after IDLE_TIMEOUT_MS (30s)', async () => { it('should transition to idle after IDLE_TIMEOUT_MS (30s)', async () => {
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -453,12 +451,12 @@ describe('SubagentWatcher', () => {
if (path.includes('project1')) return ['session1']; if (path.includes('project1')) return ['session1'];
return ['project1']; return ['project1'];
}); });
mockStatSync.mockReturnValue({ mockStatSync.mockImplementation(() => ({
isDirectory: () => true, isDirectory: () => true,
birthtime: new Date(), birthtime: new Date(),
mtime: new Date(), mtime: new Date(),
size: 100, size: 100,
}); }));
mockReadFileSync.mockReturnValue(createUserEntry('Test subagent task')); mockReadFileSync.mockReturnValue(createUserEntry('Test subagent task'));
watcher.start(); watcher.start();
@@ -481,9 +479,9 @@ describe('SubagentWatcher', () => {
}); });
it('should reset to active on new activity', async () => { it('should reset to active on new activity', async () => {
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
const mockWatcher = { close: vi.fn(), on: vi.fn(), off: vi.fn() }; const mockWatcher = { close: vi.fn(), on: vi.fn(), off: vi.fn() };
mockWatch.mockReturnValue(mockWatcher); mockWatch.mockReturnValue(mockWatcher);
@@ -495,12 +493,12 @@ describe('SubagentWatcher', () => {
if (path.includes('project1')) return ['session1']; if (path.includes('project1')) return ['session1'];
return ['project1']; return ['project1'];
}); });
mockStatSync.mockReturnValue({ mockStatSync.mockImplementation(() => ({
isDirectory: () => true, isDirectory: () => true,
birthtime: new Date(), birthtime: new Date(),
mtime: new Date(), mtime: new Date(),
size: 100, size: 100,
}); }));
mockReadFileSync.mockReturnValue(createUserEntry('Test subagent task')); mockReadFileSync.mockReturnValue(createUserEntry('Test subagent task'));
watcher.start(); watcher.start();
@@ -514,19 +512,20 @@ describe('SubagentWatcher', () => {
expect(watcher.getSubagents()[0].status).toBe('idle'); expect(watcher.getSubagents()[0].status).toBe('idle');
// Simulate file change event - get the callback from mockWatch // Simulate file change event - get the callback from mockWatch
const watchCallback = mockWatch.mock.calls.find( const watchCallback = mockWatch.mock.calls.find((call: unknown[]) => typeof call[1] === 'function')?.[1];
(call: unknown[]) => typeof call[1] === 'function'
)?.[1];
if (watchCallback) { if (watchCallback) {
// Need to reset the readline mock for the new read // Need to reset the readline mock for the new read
const newMockRl = new EventEmitter(); const newMockRl = createMockRl();
mockCreateInterface.mockReturnValue(newMockRl); mockCreateInterface.mockReturnValue(newMockRl);
// Trigger file change // Trigger file change
watchCallback('change', 'agent-reactive.jsonl'); watchCallback('change', 'agent-reactive.jsonl');
// Complete the new readline // Advance past the fileDeb debounce (100ms) so handleFileChange runs
await vi.advanceTimersByTimeAsync(150);
// Complete the new readline (tailFile)
newMockRl.emit('close'); newMockRl.emit('close');
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
@@ -536,9 +535,9 @@ describe('SubagentWatcher', () => {
}); });
it('should transition to completed when file becomes stale', async () => { it('should transition to completed when file becomes stale', async () => {
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -588,9 +587,10 @@ describe('SubagentWatcher', () => {
it('should extract tool_use entries', async () => { it('should extract tool_use entries', async () => {
const toolEntry = createToolUseEntry('WebSearch', { query: 'test query' }); const toolEntry = createToolUseEntry('WebSearch', { query: 'test query' });
const mockRl = new EventEmitter(); const descRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); const tailRl = createMockRl();
mockCreateReadStream.mockReturnValue({}); mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -612,9 +612,14 @@ describe('SubagentWatcher', () => {
watcher.start(); watcher.start();
await flushAsyncScan(); await flushAsyncScan();
mockRl.emit('line', toolEntry);
mockRl.emit('close');
// Resolve extractDescriptionFromFile
descRl.emit('close');
await vi.advanceTimersByTimeAsync(100);
// Now tailFile is set up — emit entries on tailRl
tailRl.emit('line', toolEntry);
tailRl.emit('close');
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
expect(toolCallHandler).toHaveBeenCalled(); expect(toolCallHandler).toHaveBeenCalled();
@@ -626,9 +631,10 @@ describe('SubagentWatcher', () => {
it('should extract text messages', async () => { it('should extract text messages', async () => {
const textEntry = createAssistantTextEntry('This is the assistant response'); const textEntry = createAssistantTextEntry('This is the assistant response');
const mockRl = new EventEmitter(); const descRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); const tailRl = createMockRl();
mockCreateReadStream.mockReturnValue({}); mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -650,9 +656,14 @@ describe('SubagentWatcher', () => {
watcher.start(); watcher.start();
await flushAsyncScan(); await flushAsyncScan();
mockRl.emit('line', textEntry);
mockRl.emit('close');
// Resolve extractDescriptionFromFile
descRl.emit('close');
await vi.advanceTimersByTimeAsync(100);
// Now tailFile is set up — emit entries on tailRl
tailRl.emit('line', textEntry);
tailRl.emit('close');
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
expect(messageHandler).toHaveBeenCalled(); expect(messageHandler).toHaveBeenCalled();
@@ -682,9 +693,9 @@ describe('SubagentWatcher', () => {
}, },
}); });
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -720,9 +731,10 @@ describe('SubagentWatcher', () => {
const longText = 'x'.repeat(1000); const longText = 'x'.repeat(1000);
const textEntry = createAssistantTextEntry(longText); const textEntry = createAssistantTextEntry(longText);
const mockRl = new EventEmitter(); const descRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); const tailRl = createMockRl();
mockCreateReadStream.mockReturnValue({}); mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -744,9 +756,14 @@ describe('SubagentWatcher', () => {
watcher.start(); watcher.start();
await flushAsyncScan(); await flushAsyncScan();
mockRl.emit('line', textEntry);
mockRl.emit('close');
// Resolve extractDescriptionFromFile
descRl.emit('close');
await vi.advanceTimersByTimeAsync(100);
// Now tailFile is set up — emit entries on tailRl
tailRl.emit('line', textEntry);
tailRl.emit('close');
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
expect(messageHandler).toHaveBeenCalled(); expect(messageHandler).toHaveBeenCalled();
@@ -757,9 +774,10 @@ describe('SubagentWatcher', () => {
it('should extract progress events', async () => { it('should extract progress events', async () => {
const progressEntry = createProgressEntry('query_update', { query: 'searching for files' }); const progressEntry = createProgressEntry('query_update', { query: 'searching for files' });
const mockRl = new EventEmitter(); const descRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); const tailRl = createMockRl();
mockCreateReadStream.mockReturnValue({}); mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -781,9 +799,14 @@ describe('SubagentWatcher', () => {
watcher.start(); watcher.start();
await flushAsyncScan(); await flushAsyncScan();
mockRl.emit('line', progressEntry);
mockRl.emit('close');
// Resolve extractDescriptionFromFile
descRl.emit('close');
await vi.advanceTimersByTimeAsync(100);
// Now tailFile is set up — emit entries on tailRl
tailRl.emit('line', progressEntry);
tailRl.emit('close');
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
expect(progressHandler).toHaveBeenCalled(); expect(progressHandler).toHaveBeenCalled();
@@ -795,9 +818,9 @@ describe('SubagentWatcher', () => {
describe('Memory Management', () => { describe('Memory Management', () => {
it('should track agents in agentInfo map', async () => { it('should track agents in agentInfo map', async () => {
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -832,9 +855,10 @@ describe('SubagentWatcher', () => {
const toolEntry1 = createToolUseEntry('Read', { file_path: '/test1.ts' }); const toolEntry1 = createToolUseEntry('Read', { file_path: '/test1.ts' });
const toolEntry2 = createToolUseEntry('Write', { file_path: '/test2.ts' }); const toolEntry2 = createToolUseEntry('Write', { file_path: '/test2.ts' });
const mockRl = new EventEmitter(); const descRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); const tailRl = createMockRl();
mockCreateReadStream.mockReturnValue({}); mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -853,10 +877,15 @@ describe('SubagentWatcher', () => {
watcher.start(); watcher.start();
await flushAsyncScan(); await flushAsyncScan();
mockRl.emit('line', toolEntry1);
mockRl.emit('line', toolEntry2);
mockRl.emit('close');
// Resolve extractDescriptionFromFile
descRl.emit('close');
await vi.advanceTimersByTimeAsync(100);
// Now tailFile is set up — emit entries on tailRl
tailRl.emit('line', toolEntry1);
tailRl.emit('line', toolEntry2);
tailRl.emit('close');
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
const agent = watcher.getSubagent('toolcount'); const agent = watcher.getSubagent('toolcount');
@@ -871,9 +900,10 @@ describe('SubagentWatcher', () => {
createToolUseEntry('Read', { file_path: '/test.ts' }), createToolUseEntry('Read', { file_path: '/test.ts' }),
]; ];
const mockRl = new EventEmitter(); const descRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); const tailRl = createMockRl();
mockCreateReadStream.mockReturnValue({}); mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -892,11 +922,16 @@ describe('SubagentWatcher', () => {
watcher.start(); watcher.start();
await flushAsyncScan(); await flushAsyncScan();
for (const entry of entries) {
mockRl.emit('line', entry);
}
mockRl.emit('close');
// Resolve extractDescriptionFromFile
descRl.emit('close');
await vi.advanceTimersByTimeAsync(100);
// Now tailFile is set up — emit entries on tailRl
for (const entry of entries) {
tailRl.emit('line', entry);
}
tailRl.emit('close');
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
const agent = watcher.getSubagent('entrycount'); const agent = watcher.getSubagent('entrycount');
@@ -907,9 +942,9 @@ describe('SubagentWatcher', () => {
// Note: Current implementation has no cleanup/eviction policy // Note: Current implementation has no cleanup/eviction policy
// This documents the behavior as a known issue // This documents the behavior as a known issue
it('should retain all agents indefinitely (no cleanup policy)', async () => { it('should retain all agents indefinitely (no cleanup policy)', async () => {
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
@@ -949,9 +984,9 @@ describe('SubagentWatcher', () => {
createToolUseEntry('Read', { file_path: '/test.ts' }), createToolUseEntry('Read', { file_path: '/test.ts' }),
]; ];
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -979,13 +1014,11 @@ describe('SubagentWatcher', () => {
}); });
it('should limit transcript entries when limit is specified', async () => { it('should limit transcript entries when limit is specified', async () => {
const entries = Array.from({ length: 10 }, (_, i) => const entries = Array.from({ length: 10 }, (_, i) => createUserEntry(`Message ${i}`));
createUserEntry(`Message ${i}`)
);
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -1013,9 +1046,9 @@ describe('SubagentWatcher', () => {
}); });
it('should return empty array for unknown agent', async () => { it('should return empty array for unknown agent', async () => {
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockReturnValue([]); mockReaddirSync.mockReturnValue([]);
@@ -1037,9 +1070,7 @@ describe('SubagentWatcher', () => {
sessionId: 'sess1', sessionId: 'sess1',
message: { message: {
role: 'assistant', role: 'assistant',
content: [ content: [{ type: 'tool_use', name: 'WebSearch', input: { query: 'test query' } }],
{ type: 'tool_use', name: 'WebSearch', input: { query: 'test query' } },
],
}, },
}, },
]; ];
@@ -1092,9 +1123,9 @@ describe('SubagentWatcher', () => {
it('should extract description from first user message', async () => { it('should extract description from first user message', async () => {
const userEntry = createUserEntry('Create comprehensive tests for the module'); const userEntry = createUserEntry('Create comprehensive tests for the module');
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -1116,6 +1147,7 @@ describe('SubagentWatcher', () => {
watcher.start(); watcher.start();
await flushAsyncScan(); await flushAsyncScan();
mockRl.emit('line', userEntry);
mockRl.emit('close'); mockRl.emit('close');
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
@@ -1134,9 +1166,9 @@ describe('SubagentWatcher', () => {
const userEntry = createUserEntry(longPrompt); const userEntry = createUserEntry(longPrompt);
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -1158,6 +1190,7 @@ describe('SubagentWatcher', () => {
watcher.start(); watcher.start();
await flushAsyncScan(); await flushAsyncScan();
mockRl.emit('line', userEntry);
mockRl.emit('close'); mockRl.emit('close');
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
@@ -1169,9 +1202,9 @@ describe('SubagentWatcher', () => {
}); });
it('should emit subagent:updated when description is extracted from processEntry', async () => { it('should emit subagent:updated when description is extracted from processEntry', async () => {
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -1234,7 +1267,7 @@ describe('SubagentWatcher', () => {
}, },
}); });
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
// createReadStream now used for parent transcript reading (stream tail) // createReadStream now used for parent transcript reading (stream tail)
@@ -1287,9 +1320,9 @@ describe('SubagentWatcher', () => {
describe('getRecentSubagents', () => { describe('getRecentSubagents', () => {
it('should return only recent subagents', async () => { it('should return only recent subagents', async () => {
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -1317,9 +1350,9 @@ describe('SubagentWatcher', () => {
}); });
it('should sort by lastActivityAt descending', async () => { it('should sort by lastActivityAt descending', async () => {
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -1354,9 +1387,9 @@ describe('SubagentWatcher', () => {
describe('getSubagentsForSession', () => { describe('getSubagentsForSession', () => {
it('should filter subagents by working directory', async () => { it('should filter subagents by working directory', async () => {
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -1392,9 +1425,9 @@ describe('SubagentWatcher', () => {
}); });
it('should return false for already completed agent', async () => { it('should return false for already completed agent', async () => {
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -1428,9 +1461,9 @@ describe('SubagentWatcher', () => {
}); });
it('should emit completed event when killing active agent', async () => { it('should emit completed event when killing active agent', async () => {
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -1485,9 +1518,9 @@ describe('SubagentWatcher', () => {
}); });
it('should handle readline errors gracefully', async () => { it('should handle readline errors gracefully', async () => {
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -1524,9 +1557,9 @@ describe('SubagentWatcher', () => {
// Only directory watchers are created (no per-file watchers) // Only directory watchers are created (no per-file watchers)
mockWatch.mockReturnValue(mockDirWatcher); mockWatch.mockReturnValue(mockDirWatcher);
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -1556,9 +1589,9 @@ describe('SubagentWatcher', () => {
}); });
it('should clear idle timers on stop', async () => { it('should clear idle timers on stop', async () => {
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -1596,9 +1629,9 @@ describe('SubagentWatcher', () => {
describe('Project Hash Conversion', () => { describe('Project Hash Conversion', () => {
it('should convert working directory to project hash format', async () => { it('should convert working directory to project hash format', async () => {
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
@@ -1640,9 +1673,7 @@ describe('SubagentWatcher', () => {
sessionId: 'sess1', sessionId: 'sess1',
message: { message: {
role: 'assistant', role: 'assistant',
content: [ content: [{ type: 'tool_use', name: 'WebSearch', input: { query: 'nodejs best practices' } }],
{ type: 'tool_use', name: 'WebSearch', input: { query: 'nodejs best practices' } },
],
}, },
}, },
]; ];
@@ -1661,9 +1692,7 @@ describe('SubagentWatcher', () => {
sessionId: 'sess1', sessionId: 'sess1',
message: { message: {
role: 'assistant', role: 'assistant',
content: [ content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/src/index.ts' } }],
{ type: 'tool_use', name: 'Read', input: { file_path: '/src/index.ts' } },
],
}, },
}, },
]; ];
@@ -1682,9 +1711,7 @@ describe('SubagentWatcher', () => {
sessionId: 'sess1', sessionId: 'sess1',
message: { message: {
role: 'assistant', role: 'assistant',
content: [ content: [{ type: 'tool_use', name: 'Bash', input: { command: 'npm test' } }],
{ type: 'tool_use', name: 'Bash', input: { command: 'npm test' } },
],
}, },
}, },
]; ];
@@ -1704,9 +1731,7 @@ describe('SubagentWatcher', () => {
sessionId: 'sess1', sessionId: 'sess1',
message: { message: {
role: 'assistant', role: 'assistant',
content: [ content: [{ type: 'tool_use', name: 'Bash', input: { command: longCommand } }],
{ type: 'tool_use', name: 'Bash', input: { command: longCommand } },
],
}, },
}, },
]; ];
@@ -1753,9 +1778,10 @@ describe('SubagentWatcher', () => {
it('should emit user messages under 500 chars', async () => { it('should emit user messages under 500 chars', async () => {
const userEntry = createUserEntry('Short user message'); const userEntry = createUserEntry('Short user message');
const mockRl = new EventEmitter(); const descRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); const tailRl = createMockRl();
mockCreateReadStream.mockReturnValue({}); mockCreateInterface.mockReturnValueOnce(descRl).mockReturnValue(tailRl);
mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -1777,9 +1803,14 @@ describe('SubagentWatcher', () => {
watcher.start(); watcher.start();
await flushAsyncScan(); await flushAsyncScan();
mockRl.emit('line', userEntry);
mockRl.emit('close');
// Resolve extractDescriptionFromFile
descRl.emit('close');
await vi.advanceTimersByTimeAsync(100);
// Now tailFile is set up — emit entries on tailRl
tailRl.emit('line', userEntry);
tailRl.emit('close');
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
expect(messageHandler).toHaveBeenCalled(); expect(messageHandler).toHaveBeenCalled();
@@ -1790,9 +1821,9 @@ describe('SubagentWatcher', () => {
it('should not emit long user messages (over 500 chars)', async () => { it('should not emit long user messages (over 500 chars)', async () => {
const longUserEntry = createUserEntry('x'.repeat(600)); const longUserEntry = createUserEntry('x'.repeat(600));
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {
@@ -1820,9 +1851,7 @@ describe('SubagentWatcher', () => {
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
// Long user messages are filtered out // Long user messages are filtered out
const userMessages = messageHandler.mock.calls.filter( const userMessages = messageHandler.mock.calls.filter((call) => (call[0] as SubagentMessage).role === 'user');
(call) => (call[0] as SubagentMessage).role === 'user'
);
expect(userMessages.length).toBe(0); expect(userMessages.length).toBe(0);
}); });
}); });
@@ -1831,9 +1860,9 @@ describe('SubagentWatcher', () => {
it('should not emit message for empty text content', async () => { it('should not emit message for empty text content', async () => {
const emptyTextEntry = createAssistantTextEntry(' '); const emptyTextEntry = createAssistantTextEntry(' ');
const mockRl = new EventEmitter(); const mockRl = createMockRl();
mockCreateInterface.mockReturnValue(mockRl); mockCreateInterface.mockReturnValue(mockRl);
mockCreateReadStream.mockReturnValue({}); mockCreateReadStream.mockReturnValue({ destroy: vi.fn() });
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockReaddirSync.mockImplementation((path: string) => { mockReaddirSync.mockImplementation((path: string) => {